This commit is contained in:
33333-33333 2026-07-09 16:08:11 +09:00
commit f1c3af2b89
49 changed files with 989 additions and 310 deletions

View file

@ -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()