あ
This commit is contained in:
parent
8f9f5b5100
commit
f1c3af2b89
49 changed files with 989 additions and 310 deletions
|
|
@ -177,6 +177,42 @@ if (context.currentTarinaiBehavior(t) !== null) throw new Error('behavior not cl
|
|||
ok("behavior state smoke test passed")
|
||||
|
||||
|
||||
def need_action_runtime_bridge_smoke() -> None:
|
||||
items_source = read_text(ROOT / "js/tarinai_needs_items.js")
|
||||
core_source = read_text(ROOT / "js/tarinai_needs_core.js")
|
||||
forced_source = read_text(ROOT / "js/tarinai_forced_behavior.js")
|
||||
js = f"""
|
||||
const vm = require('vm');
|
||||
const context = {{ console, globalThis: null }};
|
||||
context.globalThis = context;
|
||||
context.window = context;
|
||||
context.Tarinai = class Tarinai {{}};
|
||||
vm.createContext(context);
|
||||
vm.runInContext({items_source!r}, context, {{ filename: 'tarinai_needs_items.js' }});
|
||||
if (!context.TarinaiNeedsRuntime) throw new Error('TarinaiNeedsRuntime missing');
|
||||
if (typeof context.TarinaiNeedsRuntime.startNeedAction !== 'function') throw new Error('startNeedAction bridge missing');
|
||||
"""
|
||||
with tempfile.NamedTemporaryFile("w", encoding="utf-8", suffix=".js", delete=False) as f:
|
||||
f.write(js)
|
||||
tmp = Path(f.name)
|
||||
try:
|
||||
result = subprocess.run(["node", str(tmp)], cwd=ROOT, text=True, capture_output=True)
|
||||
finally:
|
||||
tmp.unlink(missing_ok=True)
|
||||
if result.returncode != 0:
|
||||
sys.stderr.write(result.stdout)
|
||||
sys.stderr.write(result.stderr)
|
||||
fail("need-action runtime bridge smoke test failed")
|
||||
qualified_call = "globalThis.TarinaiNeedsRuntime?.startNeedAction?.("
|
||||
if core_source.count(qualified_call) != 2:
|
||||
fail("tarinai_needs_core.js must route both emergency starts through TarinaiNeedsRuntime")
|
||||
if forced_source.count(qualified_call) != 1:
|
||||
fail("tarinai_forced_behavior.js must route forced starts through TarinaiNeedsRuntime")
|
||||
if re.search(r"(?<![.\w])startNeedAction\s*\(", core_source + "\n" + forced_source):
|
||||
fail("cross-file bare startNeedAction call remains")
|
||||
ok("need-action runtime bridge is exported and all cross-file calls are qualified")
|
||||
|
||||
|
||||
def check_legacy_behavior_refs() -> None:
|
||||
tokens = re.compile(r"\b(activeBehavior|currentAction|activeForcedBehavior|intent)\b")
|
||||
offenders = []
|
||||
|
|
@ -1216,6 +1252,383 @@ if (grass.normalized) throw new Error('grass should not be normalized by per-ite
|
|||
fail("Item lifecycle pipeline smoke test failed")
|
||||
ok("Item lifecycle pipeline boundary present")
|
||||
|
||||
|
||||
|
||||
def mechanical_collision_and_editor_smoke() -> None:
|
||||
footprints = read_text(ROOT / "js/collision_footprint_system.js")
|
||||
editor_source = read_text(ROOT / "js/physics_shape_editor_system.js")
|
||||
placement_source = read_text(ROOT / "js/world_placement_log.js")
|
||||
for token in ("rectCorners,", "rectAxes,", "projectPoints,", "aabbOverlap,", "rectOverlapInfo,", "rectsOverlap,"):
|
||||
if token not in footprints:
|
||||
fail(f"mechanical SAT primitive is not exported: {token}")
|
||||
if "const DEFAULT_GRID_SIZE = 20;" not in editor_source:
|
||||
fail("mechanical shape editor grid must use coarse 20px snapping")
|
||||
if editor_source.count("snapPoint(") < 2 or "gridSize: DEFAULT_GRID_SIZE" not in editor_source:
|
||||
fail("mechanical shape editor does not route pointer coordinates through grid snapping")
|
||||
if placement_source.count("20px\\u9593\\u9694") < 2:
|
||||
fail("mechanical editor hints do not disclose 20px grid snapping")
|
||||
|
||||
js = f"""
|
||||
const vm = require('vm');
|
||||
const fs = require('fs');
|
||||
const context = {{ console, globalThis: null }};
|
||||
context.globalThis = context;
|
||||
context.window = context;
|
||||
context.TarinaiPerf = {{ begin() {{ return null; }} }};
|
||||
context.TarinaiConstraintSystem = {{ updateWorld() {{ return 0; }} }};
|
||||
context.normalizedItemAngle = angle => angle;
|
||||
vm.createContext(context);
|
||||
for (const path of {repr([str(ROOT / rel) for rel in [
|
||||
'js/math.js', 'js/geometry_helpers.js', 'js/collision_footprint_system.js',
|
||||
'js/physics_world_system.js', 'js/mechanical_system.js', 'js/physics_shape_editor_system.js'
|
||||
]])}) vm.runInContext(fs.readFileSync(path, 'utf8'), context, {{ filename: path }});
|
||||
|
||||
const fp = context.TarinaiCollisionFootprints;
|
||||
for (const key of ['rectCorners', 'rectAxes', 'projectPoints', 'aabbOverlap', 'rectOverlapInfo', 'rectsOverlap']) {{
|
||||
if (typeof fp[key] !== 'function') throw new Error(`missing footprint primitive: ${{key}}`);
|
||||
}}
|
||||
|
||||
const editor = context.TarinaiPhysicsShapeEditorSystem;
|
||||
if (editor.gridSize !== 20) throw new Error('editor grid size mismatch');
|
||||
const snapped = editor.snapPoint({{ x: 13, y: 31 }});
|
||||
if (snapped.x !== 20 || snapped.y !== 40) throw new Error('pointer did not snap to grid');
|
||||
const cleaned = editor.cleanSegments([[13, 31, 91, 44], [-52, -20, 52, -20]], {{ fallback: [-80, 0, 80, 0] }});
|
||||
if (!cleaned.length || cleaned.some(seg => seg.some(value => Math.abs(value % 20) > 1e-9))) throw new Error('segment endpoints are not grid aligned');
|
||||
if (cleaned.some(seg => Math.hypot(seg[2] - seg[0], seg[3] - seg[1]) < 20)) throw new Error('editor retained sub-grid segments');
|
||||
|
||||
let serial = 0;
|
||||
function makeItem(type, x, y) {{
|
||||
return {{ id: `m${{++serial}}`, type, x, y, angle: 0, r: 64, amount: 999, dead: false }};
|
||||
}}
|
||||
function activate(item) {{
|
||||
const api = context.TarinaiPhysicsBodySystem;
|
||||
api.setSegments(item, [[-80, 0, 80, 0]], 'test-shape');
|
||||
api.setScalar(item, 'solid', true, 'test-solid');
|
||||
if (item.type === 'rotator') {{ api.setScalar(item, 'motorOn', true, 'test'); api.setScalar(item, 'motorSpeed', 1.4, 'test'); }}
|
||||
if (item.type === 'reciprocator') {{ api.setScalar(item, 'railOn', true, 'test'); api.setScalar(item, 'railAxis', 0, 'test'); api.setScalar(item, 'railMotorSpeed', 80, 'test'); }}
|
||||
if (item.type === 'poison_block') {{ api.setScalar(item, 'xv', 24, 'test'); api.setScalar(item, 'awakeUntil', 10, 'test'); }}
|
||||
}}
|
||||
function pairResult(typeA, typeB, disableB = false) {{
|
||||
const a = makeItem(typeA, 120, 120);
|
||||
const b = makeItem(typeB, 120, 128);
|
||||
const items = [a, b];
|
||||
const world = {{
|
||||
items, tarinai: [], time: 1, frameCount: 1, w: 900, h: 700, drawListDirty: false,
|
||||
itemCounts: {{ rotator: 0, poison_block: 0, reciprocator: 0 }},
|
||||
ensureItemBuckets() {{}}, markSpatialDirty() {{}}, markItemBucketsDirty() {{}},
|
||||
nearbyObstacles() {{ return items; }}, nearbyItems() {{ return items; }},
|
||||
itemsOfType(type) {{ return items.filter(item => item.type === type && !item.dead); }},
|
||||
}};
|
||||
for (const item of items) {{ item.world = world; world.itemCounts[item.type] += 1; context.TarinaiPhysicsBodySystem.ensureBody(item, world); activate(item); }}
|
||||
if (disableB) context.TarinaiPhysicsBodySystem.setScalar(b, 'solid', false, 'test-disabled');
|
||||
const solved = context.TarinaiMechanicalSystem.resolveMechanicalPairs(world, 0.016, {{ maxPairs: 40 }});
|
||||
return {{ solved, a, b }};
|
||||
}}
|
||||
for (const [a, b] of [['rotator', 'rotator'], ['rotator', 'reciprocator'], ['rotator', 'poison_block'], ['reciprocator', 'reciprocator'], ['reciprocator', 'poison_block'], ['poison_block', 'poison_block']]) {{
|
||||
const result = pairResult(a, b, false);
|
||||
if (!(result.solved > 0)) throw new Error(`${{a}}/${{b}} collision-on pair did not interact`);
|
||||
const disabled = pairResult(a, b, true);
|
||||
if (disabled.solved !== 0) throw new Error(`${{a}}/${{b}} collision-off pair still interacted`);
|
||||
}}
|
||||
"""
|
||||
with tempfile.NamedTemporaryFile("w", encoding="utf-8", suffix=".js", delete=False) as f:
|
||||
f.write(js)
|
||||
tmp = Path(f.name)
|
||||
try:
|
||||
result = subprocess.run(["node", str(tmp)], cwd=ROOT, text=True, capture_output=True)
|
||||
finally:
|
||||
tmp.unlink(missing_ok=True)
|
||||
if result.returncode != 0:
|
||||
sys.stderr.write(result.stdout)
|
||||
sys.stderr.write(result.stderr)
|
||||
fail("mechanical collision/editor smoke test failed")
|
||||
ok("collision-enabled mechanical pairs interact and editor geometry snaps to the 20px lattice")
|
||||
|
||||
def colony_limits_and_defaults_smoke() -> None:
|
||||
tool_defs = read_text(ROOT / "js/item_tool_definitions.js")
|
||||
initializer = read_text(ROOT / "js/item_type_initializers.js")
|
||||
world_budget = read_text(ROOT / "js/world_spatial_budget.js")
|
||||
world_family = read_text(ROOT / "js/world_family_social.js")
|
||||
needs = read_text(ROOT / "js/tarinai_needs_items.js")
|
||||
social = read_text(ROOT / "js/tarinai_social_action_runtime.js")
|
||||
snapshot = read_text(ROOT / "js/snapshot_system.js")
|
||||
codec = read_text(ROOT / "js/save_codec.js")
|
||||
index = read_text(ROOT / "index.html")
|
||||
if 't === "reciprocator") return Math.PI / 2' not in tool_defs:
|
||||
fail("reciprocator default angle is not 90 degrees")
|
||||
small = "[[-52, -20, 52, -20], [52, -20, 52, 20], [52, 20, -52, 20], [-52, 20, -52, -20]]"
|
||||
poison_files = [
|
||||
"js/item_type_initializers.js", "js/physics_world_system.js", "js/item_render_runtime.js",
|
||||
"js/mechanical_system.js", "js/world_placement_log.js",
|
||||
]
|
||||
for rel in poison_files:
|
||||
body = read_text(ROOT / rel)
|
||||
if not all(token in body for token in ("-52", "-20", "52", "20")):
|
||||
fail(f"small poison-block shape missing from {rel}")
|
||||
if "[-82, -28, 82, -28]" in body:
|
||||
fail(f"legacy large poison-block shape remains in {rel}")
|
||||
if 'radius: 44' not in tool_defs or 'thickness: 11' not in initializer:
|
||||
fail("small poison-block radius/thickness defaults are not synchronized")
|
||||
for token in ("canAddTarinai(count = 1)", "canAddObjects(count = 1)", "setTarinaiPopulationLimit", "setObjectLimit"):
|
||||
if token not in world_budget:
|
||||
fail(f"colony limit runtime missing: {token}")
|
||||
if "if (!(this.canAddTarinai?.(1) ?? true)) return null;" not in world_family:
|
||||
fail("tarinai creation/birth is not unconditionally guarded by population limit")
|
||||
if "if (!this.canAddObjects(1)) return null;" not in world_budget:
|
||||
fail("object creation is not unconditionally guarded by object limit")
|
||||
if "ignoreColonyLimit" in world_family or "ignoreColonyLimit" in world_budget:
|
||||
fail("colony limit bypass option remains in central creation APIs")
|
||||
unsafe_fallback = re.compile(r"addItem\?\.[^\n;]+\|\|[^\n;]*items\.push")
|
||||
for rel in (ROOT / "js").glob("*.js"):
|
||||
if unsafe_fallback.search(read_text(rel)):
|
||||
fail(f"object-limit bypass fallback remains in {rel.name}")
|
||||
direct_item_pushes = []
|
||||
direct_tarinai_pushes = []
|
||||
for rel in (ROOT / "js").glob("*.js"):
|
||||
body = read_text(rel)
|
||||
if re.search(r"\b(?:this|worldRef|world)\.items\.push\(", body):
|
||||
direct_item_pushes.append(rel.name)
|
||||
if re.search(r"\b(?:this|worldRef|world)\.tarinai\.push\(", body):
|
||||
direct_tarinai_pushes.append(rel.name)
|
||||
if sorted(direct_item_pushes) != ["snapshot_system.js", "world_spatial_budget.js"]:
|
||||
fail("unexpected direct world item insertion can bypass object limit: " + ", ".join(sorted(direct_item_pushes)))
|
||||
if sorted(direct_tarinai_pushes) != ["snapshot_system.js", "world_family_social.js"]:
|
||||
fail("unexpected direct tarinai insertion can bypass population limit: " + ", ".join(sorted(direct_tarinai_pushes)))
|
||||
guarded_sources = {
|
||||
"js/item_dynamic_ball_system.js": "if (!addedDrop) continue;",
|
||||
"js/fire_runtime_system.js": "if (!addedFire) return null;",
|
||||
"js/tarinai_building_behavior.js": "canAddObjects?.(1)",
|
||||
"js/world_combat_effects.js": "if (!spawned) return 0;",
|
||||
}
|
||||
for rel, token in guarded_sources.items():
|
||||
if token not in read_text(ROOT / rel):
|
||||
fail(f"object-limit guard missing from {rel}: {token}")
|
||||
if "championMate" not in needs or "championPair" not in social or "mateWeight *= 5.0" not in social:
|
||||
fail("champion-pair breeding preference is incomplete")
|
||||
for token in ("tarinaiPopulationLimit", "objectLimit"):
|
||||
if token not in snapshot or token not in codec:
|
||||
fail(f"colony limit persistence missing: {token}")
|
||||
for element_id in ("tarinaiPopulationLimitInput", "objectLimitInput", "colonyLimitStatus"):
|
||||
if f'id="{element_id}"' not in index:
|
||||
fail(f"colony limit UI missing: {element_id}")
|
||||
|
||||
js = f"""
|
||||
const vm = require('vm');
|
||||
const fs = require('fs');
|
||||
class World {{
|
||||
constructor() {{
|
||||
this.items = [];
|
||||
this.tarinai = [];
|
||||
this.itemCounts = {{}};
|
||||
this.tarinaiPopulationLimit = 0;
|
||||
this.objectLimit = 0;
|
||||
}}
|
||||
markItemBucketsDirty() {{}}
|
||||
markSpatialDirty() {{}}
|
||||
markTerrainDirty() {{}}
|
||||
markTerrainDirtyAt() {{}}
|
||||
}}
|
||||
const context = {{ console, World, CONFIG: {{ grassLimit: Infinity }}, globalThis: null }};
|
||||
context.globalThis = context;
|
||||
context.window = context;
|
||||
context.TarinaiGround = {{ grassLimit() {{ return Infinity; }} }};
|
||||
context.TarinaiPerf = {{ snapshot() {{ return null; }}, begin() {{ return null; }} }};
|
||||
vm.createContext(context);
|
||||
vm.runInContext(fs.readFileSync({str(ROOT / 'js/world_spatial_budget.js')!r}, 'utf8'), context);
|
||||
const world = new context.World();
|
||||
if (world.tarinaiPopulationLimit !== 0 || world.objectLimit !== 0) throw new Error('limits must default to unlimited');
|
||||
world.setObjectLimit(2.9);
|
||||
if (world.objectLimit !== 2) throw new Error('object limit must normalize to a positive integer');
|
||||
if (!world.canAddObjects(2) || world.canAddObjects(3)) throw new Error('object capacity query is incorrect');
|
||||
if (!world.addItem({{ type: 'stone' }}, 'test-1')) throw new Error('first object rejected');
|
||||
if (!world.addItem({{ type: 'stone' }}, 'test-2')) throw new Error('second object rejected');
|
||||
if (world.addItem({{ type: 'stone' }}, 'test-3') !== null) throw new Error('object limit not enforced');
|
||||
world.items[0].dead = true;
|
||||
if (!world.addItem({{ type: 'stone' }}, 'test-4')) throw new Error('dead object should free capacity');
|
||||
world.setObjectLimit(1);
|
||||
if (world.addItem({{ type: 'stone' }}, 'over-limit-existing') !== null) throw new Error('existing over-limit world must reject new objects');
|
||||
for (const item of world.items) item.dead = true;
|
||||
if (!world.addItem({{ type: 'stone' }}, 'capacity-restored')) throw new Error('capacity was not restored after all live objects died');
|
||||
world.setTarinaiPopulationLimit(1.8);
|
||||
if (world.tarinaiPopulationLimit !== 1) throw new Error('tarinai limit must normalize to a positive integer');
|
||||
world.tarinai.push({{ dead: false }});
|
||||
if (world.canAddTarinai(1)) throw new Error('tarinai limit not enforced');
|
||||
world.tarinai[0].dead = true;
|
||||
if (!world.canAddTarinai(1)) throw new Error('dead tarinai should free capacity');
|
||||
world.setObjectLimit('');
|
||||
if (world.objectLimit !== 0 || !world.canAddObjects(1000)) throw new Error('blank/zero must mean unlimited');
|
||||
context.TarinaiSnapshot = {{ version: 23 }};
|
||||
context.TarinaiSaveSchema = {{ BINARY_SCHEMA_VERSION: 28, FIELD_IDS: ['garden'] }};
|
||||
context.CONFIG.dayLength = 120;
|
||||
context.TextEncoder = TextEncoder;
|
||||
context.TextDecoder = TextDecoder;
|
||||
vm.runInContext(fs.readFileSync({str(ROOT / 'js/save_codec.js')!r}, 'utf8'), context);
|
||||
(async () => {{
|
||||
const source = {{ v: 23, a: 'tj1', m: [1, 0, 'garden'], w: [0, 0, 0, 120, 0, -9990, 1, 0, 'seed', 0, 12, 345], t: [], i: [] }};
|
||||
const encoded = await context.TarinaiSaveCodec.encodeSnapshot(source);
|
||||
const decoded = await context.TarinaiSaveCodec.decodeSnapshot(encoded);
|
||||
if (decoded.w[10] !== 12 || decoded.w[11] !== 345) throw new Error('colony limits did not survive save roundtrip');
|
||||
}})().catch(error => {{ console.error(error); process.exitCode = 1; }});
|
||||
"""
|
||||
with tempfile.NamedTemporaryFile("w", encoding="utf-8", suffix=".js", delete=False) as f:
|
||||
f.write(js)
|
||||
tmp = Path(f.name)
|
||||
try:
|
||||
result = subprocess.run(["node", str(tmp)], cwd=ROOT, text=True, capture_output=True)
|
||||
finally:
|
||||
tmp.unlink(missing_ok=True)
|
||||
if result.returncode != 0:
|
||||
sys.stderr.write(result.stdout)
|
||||
sys.stderr.write(result.stderr)
|
||||
fail("colony limit runtime smoke test failed")
|
||||
ok("champion breeding, mechanical defaults, and colony limits are wired")
|
||||
|
||||
|
||||
def physics_tuning_smoke() -> None:
|
||||
editor = read_text(ROOT / "js/physics_shape_editor_system.js")
|
||||
environment = read_text(ROOT / "js/world_environment.js")
|
||||
helpers = read_text(ROOT / "js/physics_helpers.js")
|
||||
constraints = read_text(ROOT / "js/constraint_system.js")
|
||||
dynamic_tools = read_text(ROOT / "js/item_dynamic_tool_system.js")
|
||||
initializer = read_text(ROOT / "js/item_type_initializers.js")
|
||||
|
||||
for token in ("BOUNCE_FENCE_RESTITUTION = 2.40", "BOUNCE_FENCE_MIN_SPEED = 520", "BOUNCE_FENCE_MAX_SPEED = 1180"):
|
||||
if token not in environment:
|
||||
fail(f"strong bounce-fence tuning missing: {token}")
|
||||
if "const iterations = 8" not in constraints or "const compliance = mode === \"rod\" ? 0.085 / (step * 60) : 0" not in constraints:
|
||||
fail("rope endpoint constraint is still compliant/stretchable")
|
||||
if "STICKY_BOMB_BLAST_SCALE = 0.68" not in dynamic_tools or "item.blastScale = 0.68" not in initializer:
|
||||
fail("sticky bomb is not synchronized with small-firecracker blast scale")
|
||||
|
||||
js = f"""
|
||||
const vm = require('vm');
|
||||
const editorSource = {editor!r};
|
||||
const helperSource = {helpers!r};
|
||||
const constraintSource = {constraints!r};
|
||||
const dynamicSource = {dynamic_tools!r};
|
||||
|
||||
const context = {{ console, globalThis: null }};
|
||||
context.globalThis = context;
|
||||
context.window = context;
|
||||
context.clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
|
||||
context.distXY = (x1, y1, x2, y2) => Math.hypot(x2 - x1, y2 - y1);
|
||||
context.CONFIG = {{ worldPadding: 0 }};
|
||||
context.Effect = function Effect() {{}};
|
||||
context.TarinaiGeometry = {{
|
||||
pointSegmentDistance(px, py, x1, y1, x2, y2) {{
|
||||
const dx = x2 - x1, dy = y2 - y1;
|
||||
const den = dx * dx + dy * dy || 1;
|
||||
const t = Math.max(0, Math.min(1, ((px - x1) * dx + (py - y1) * dy) / den));
|
||||
return Math.hypot(px - (x1 + dx * t), py - (y1 + dy * t));
|
||||
}},
|
||||
rectLocalPoint(rect, x, y) {{ return {{ x: x - (rect.cx || 0), y: y - (rect.cy || 0) }}; }},
|
||||
rectWorldVector(_rect, x, y) {{ return {{ x, y }}; }},
|
||||
rectWorldNormal(_rect, x, y) {{ return {{ x, y }}; }},
|
||||
}};
|
||||
context.TarinaiMechanicalSystem = {{
|
||||
isMechanicalType() {{ return false; }},
|
||||
isPowered() {{ return false; }},
|
||||
applySurfaceVelocityToCircle() {{}},
|
||||
wakeItem() {{}},
|
||||
}};
|
||||
context.TarinaiPhysicsProjectionSystem = {{ markSpatialDirty() {{ return false; }} }};
|
||||
context.TarinaiMovementUpdateStep = {{ markSleepExternalMotion() {{}} }};
|
||||
context.itemAngleFor = item => Number(item?.angle || 0) || 0;
|
||||
context.normalizedItemAngle = angle => Number(angle || 0) || 0;
|
||||
vm.createContext(context);
|
||||
|
||||
vm.runInContext(editorSource, context, {{ filename: 'physics_shape_editor_system.js' }});
|
||||
const circle = context.TarinaiPhysicsShapeEditorSystem.templateSegments('circle');
|
||||
if (!Array.isArray(circle) || circle.length !== 24) throw new Error('circle template is not a 24-sided contour');
|
||||
for (const segment of circle) {{
|
||||
if (segment.length !== 4 || segment.some(value => value % 20 !== 0)) throw new Error('circle template left the 20px grid');
|
||||
}}
|
||||
for (let i = 0; i < circle.length; i += 1) {{
|
||||
const next = circle[(i + 1) % circle.length];
|
||||
if (circle[i][2] !== next[0] || circle[i][3] !== next[1]) throw new Error('circle contour is not closed');
|
||||
}}
|
||||
|
||||
vm.runInContext(helperSource, context, {{ filename: 'physics_helpers.js' }});
|
||||
const ball = {{ x: -4, y: 5, prevX: -14, prevY: 5, vx: 100, vy: 0, r: 5, spinVelocity: 0 }};
|
||||
const fence = {{ left: 0, right: 10, top: 0, bottom: 10, bounce: true, restitution: 2.40, minBounceSpeed: 520 }};
|
||||
if (!context.TarinaiPhysics.bounceCircleOffRect(ball, fence, 5, fence.restitution, null, {{ minBounceSpeed: fence.minBounceSpeed }})) throw new Error('bounce-fence collision did not resolve');
|
||||
if (ball.vx > -519) throw new Error('bounce-fence launch is not substantially stronger');
|
||||
|
||||
context.TarinaiPhysicsBodySystem = {{
|
||||
scalar(item, key, fallback) {{ return Number(item.physicsBody?.[key] ?? fallback); }},
|
||||
setScalar(item, key, value) {{ item.physicsBody ||= {{}}; item.physicsBody[key] = value; return true; }},
|
||||
linkScalar(item, key, fallback) {{
|
||||
const c = item.physicsConstraint || {{}};
|
||||
const map = {{ len: 'length', midX: 'midX', midY: 'midY', midVx: 'midVx', midVy: 'midVy', awakeUntil: 'awakeUntil' }};
|
||||
return Number(c[map[key] || key] ?? fallback);
|
||||
}},
|
||||
setLinkScalar(item, key, value) {{
|
||||
const c = item.physicsConstraint ||= {{}};
|
||||
const map = {{ len: 'length', midX: 'midX', midY: 'midY', midVx: 'midVx', midVy: 'midVy', awakeUntil: 'awakeUntil' }};
|
||||
c[map[key] || key] = value;
|
||||
return true;
|
||||
}},
|
||||
endpoint(item, index) {{ return item.physicsConstraint?.endpoints?.[index] || null; }},
|
||||
particles(item) {{ return item.physicsConstraint?.particles || null; }},
|
||||
setParticles(item, list) {{ item.physicsConstraint.particles = list; return true; }},
|
||||
ensureConstraint(item) {{ return item.physicsConstraint; }},
|
||||
applyConstraintState() {{ return true; }},
|
||||
normalizeConstraintState(_item, state) {{ return state; }},
|
||||
syncPoseFromItem() {{ return true; }},
|
||||
normalizeBodyState() {{ return true; }},
|
||||
markBodyChanged() {{ return true; }},
|
||||
}};
|
||||
vm.runInContext(constraintSource, context, {{ filename: 'constraint_system.js' }});
|
||||
const a = {{ id: 'a', type: 'ball', x: 0, y: 0, r: 10, dead: false }};
|
||||
const b = {{ id: 'b', type: 'ball', x: 200, y: 0, r: 10, dead: false }};
|
||||
const objects = new Map([[a.id, a], [b.id, b]]);
|
||||
const world = {{
|
||||
time: 1,
|
||||
items: [a, b],
|
||||
tarinai: [],
|
||||
itemCounts: {{ rope: 1 }},
|
||||
itemById(id) {{ return objects.get(id) || null; }},
|
||||
nearbyTarinai() {{ return []; }},
|
||||
nearbySolidObstacleRects() {{ return []; }},
|
||||
markSpatialDirty() {{}},
|
||||
drawListDirty: false,
|
||||
}};
|
||||
const rope = {{
|
||||
id: 'rope-1', type: 'rope', dead: false, amount: 999, world,
|
||||
physicsConstraint: {{ endpoints: [{{ kind: 'item', id: 'a', x: 0, y: 0 }}, {{ kind: 'item', id: 'b', x: 200, y: 0 }}], length: 100, particles: null }},
|
||||
}};
|
||||
context.TarinaiConstraintSystem.updateFlexibleLink(rope, 1 / 60, world);
|
||||
const tautDistance = Math.hypot(b.x - a.x, b.y - a.y);
|
||||
if (tautDistance > 100.26) throw new Error(`rope stretched to ${{tautDistance}}px`);
|
||||
const beforeA = {{ x: a.x, y: a.y }}, beforeB = {{ x: b.x, y: b.y }};
|
||||
b.x = a.x + 60;
|
||||
context.TarinaiConstraintSystem.updateFlexibleLink(rope, 1 / 60, world);
|
||||
if (Math.hypot(a.x - beforeA.x, a.y - beforeA.y) > 0.01) throw new Error('slack rope pushed endpoint A');
|
||||
if (Math.abs((b.x - a.x) - 60) > 0.01) throw new Error('slack rope pushed endpoints apart');
|
||||
|
||||
context.deterministicChance = () => false;
|
||||
context.Effect = function Effect() {{}};
|
||||
context.audio = {{ explode() {{}} }};
|
||||
vm.runInContext(dynamicSource, context, {{ filename: 'item_dynamic_tool_system.js' }});
|
||||
let capturedScale = null;
|
||||
const sticky = {{ type: 'sticky_bomb', dead: false, fuseTimer: 0, stickyBombCarrierId: '', stickyBombTransferCooldown: 0, spin: 0, spinVelocity: 0 }};
|
||||
const stickyWorld = {{ tarinai: [], explodeFirecracker(item) {{ capturedScale = item.blastScale; return true; }} }};
|
||||
context.TarinaiItemDynamicToolSystem.update(sticky, 1 / 60, stickyWorld);
|
||||
if (Math.abs(capturedScale - 0.68) > 1e-9) throw new Error('sticky bomb does not match small firecracker blast scale');
|
||||
"""
|
||||
with tempfile.NamedTemporaryFile("w", encoding="utf-8", suffix=".js", delete=False) as f:
|
||||
f.write(js)
|
||||
tmp = Path(f.name)
|
||||
try:
|
||||
result = subprocess.run(["node", str(tmp)], cwd=ROOT, text=True, capture_output=True)
|
||||
finally:
|
||||
tmp.unlink(missing_ok=True)
|
||||
if result.returncode != 0:
|
||||
sys.stderr.write(result.stdout)
|
||||
sys.stderr.write(result.stderr)
|
||||
fail("physics tuning smoke test failed")
|
||||
ok("round circle template, strong bounce fence, inextensible rope, and small sticky-bomb blast verified")
|
||||
|
||||
def main() -> None:
|
||||
data = manifest()
|
||||
check_files_exist(data)
|
||||
|
|
@ -1227,6 +1640,10 @@ def main() -> None:
|
|||
node_check([*(ROOT / "js").glob("*.js"), ROOT / "service-worker.js"])
|
||||
action_spec_smoke()
|
||||
behavior_state_smoke()
|
||||
need_action_runtime_bridge_smoke()
|
||||
mechanical_collision_and_editor_smoke()
|
||||
physics_tuning_smoke()
|
||||
colony_limits_and_defaults_smoke()
|
||||
check_basic_action_spec_runtime()
|
||||
check_social_action_spec_runtime()
|
||||
check_action_spec_no_legacy_hooks()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue