This commit is contained in:
33333-33333 2026-07-10 16:40:06 +09:00
commit 2636d580a8
75 changed files with 3890 additions and 1084 deletions

View file

@ -2,8 +2,7 @@
"""Lightweight regression guard for the buildless Tarinai app.
Checks static-file consistency, generated-load order, syntax, and the ActionSpec
registry contract. This intentionally avoids save-data migration checks; save
compatibility is intentionally not retained for save format changes.
registry contract. This covers static contracts and focused runtime smoke tests. The current codec intentionally supports only the current binary schema.
"""
from __future__ import annotations
@ -250,6 +249,26 @@ def check_legacy_physics_props_removed() -> None:
ok("legacy physics property names removed")
def check_redundant_correction_paths_removed() -> None:
terms = [
"markBodyChanged", "purgeLegacyPhysicsStorage", "nextTarinaiCollisionCheckAt",
"_physicsWorldFrame", "bodyReads", "bodyWrites", "constraintReads", "constraintWrites",
"compactEffects",
]
offenders = []
for path in (ROOT / "js").glob("*.js"):
text = read_text(path)
for term in terms:
if term in text:
offenders.append(f"{path.relative_to(ROOT).as_posix()}:{term}")
if offenders:
fail("redundant correction path returned: " + ", ".join(offenders[:20]))
ball_source = read_text(ROOT / "js/item_dynamic_ball_system.js")
if ball_source.count("tryBallPickup(") != 1:
fail("ball update must perform exactly one pickup check")
ok("redundant correction paths remain removed")
def check_basic_action_spec_runtime() -> None:
source = "\n".join(read_text(ROOT / rel) for rel in ["js/tarinai_action_definitions.js", "js/tarinai_needs_items.js"])
required = {
@ -459,20 +478,47 @@ def check_current_save_format() -> None:
fail("broad snapshot fields still present after light-save conversion: " + ", ".join(offenders))
required_codec = [
'EXPORT_PREFIX = "\\u305f"',
'SAVE_TEXT_BITS = 12',
'SAVE_HEADER_CHARS = "\\u3089\\u308A\\u308B"',
'SAVE_TEXT_BITS = 15',
"writeStringTable",
"SAVE_TEXT_ALPHABET",
"jp4096Encode",
"jp4096Decode",
"writeCurrentItemExtra",
"readCurrentItemExtra",
"saveTextCharFromValue",
"saveTextValueFromCode",
"base32768Encode",
"base32768Decode",
"makeSaveFrame",
"openSaveFrame",
"writeTarinaiBitPlane",
"readTarinaiBitPlane",
"TARINAI_COLUMN_LAYOUT",
"chooseCommonExtraPlan",
"standardItemAmount",
"chooseAmountPlan",
"worldSeedWords",
"saveTextCharFromValue",
"saveTextValueFromCode",
"itemFlagPlaneBytes",
"writeNormalItemExtra",
"readNormalItemExtra",
"writeBirthDescriptor",
"readBirthDescriptor",
"async function encodeSnapshot",
"async function decodeSnapshot",
]
for token in required_codec:
if token not in save_codec:
fail(f"binary save codec token missing: {token}")
if "SAVE_TEXT_ALPHABET" in save_codec or "SAVE_TEXT_REVERSE" in save_codec:
fail("15-bit save text still allocates the legacy alphabet or reverse map")
if "writer.u(Snapshot.version)" in save_codec:
fail("duplicate snapshot version remains in the binary header")
if "writeSaveString(writer, w[8]" in save_codec:
fail("worldSeed is still stored as a string")
for token in ("formatHeader & 0x7f", "formatHeader & 0x80", "payload.length * 2 + (useCompressed ? 1 : 0)"):
if token not in save_codec:
fail(f"compact save framing token missing: {token}")
if "tailMask" in save_codec:
fail("redundant Tarinai tailMask remains in current schema")
if "costBits < best.costBits" not in save_codec or "itemFlagPlaneBytes" not in save_codec:
fail("cost-based common extra or continuous item flag plane is missing")
required_storage = [
'STORAGE_PREFIX = "tarinai_japanese_hash_slot_v1_"',
'hash.startsWith("\\u305f")',
@ -1277,10 +1323,14 @@ context.window = context;
context.TarinaiPerf = {{ begin() {{ return null; }} }};
context.TarinaiConstraintSystem = {{ updateWorld() {{ return 0; }} }};
context.normalizedItemAngle = angle => angle;
context.isRotatableItemType = type => ['rotator', 'poison_block', 'reciprocator'].includes(String(type || ''));
context.defaultItemAngle = type => String(type || '') === 'reciprocator' ? Math.PI / 2 : 0;
context.itemRadiusFor = (_type, fallback = 12) => fallback;
context.toolItemType = tool => tool;
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'
'js/physics_world_system.js', 'js/mechanical_system.js', 'js/placement_preview_system.js', 'js/physics_shape_editor_system.js'
]])}) vm.runInContext(fs.readFileSync(path, 'utf8'), context, {{ filename: path }});
const fp = context.TarinaiCollisionFootprints;
@ -1308,6 +1358,16 @@ function activate(item) {{
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'); }}
}}
const reverseRotator = makeItem('rotator', 80, 80);
reverseRotator.world = {{ time: 0 }};
context.TarinaiPhysicsBodySystem.ensureBody(reverseRotator, reverseRotator.world);
context.TarinaiPhysicsBodySystem.setScalar(reverseRotator, 'motorSpeed', -1.25, 'reverse-test');
if (Math.abs(context.TarinaiPhysicsBodySystem.scalar(reverseRotator, 'motorSpeed', 0) + 1.25) > 1e-9) throw new Error('negative rotator motor speed was clamped');
reverseRotator.angle = 1.0;
reverseRotator.world = {{ time: 0, drawListDirty: false, markSpatialDirty() {{}}, nearbyItems() {{ return []; }}, nearbyObstacles() {{ return []; }}, items: [], tarinai: [] }};
context.TarinaiMechanicalSystem.updateRotator(reverseRotator, 0.1, reverseRotator.world, {{ skipInteractions: true }});
if (!(reverseRotator.angle < 0.99)) throw new Error('negative rotator speed did not rotate backward');
function pairResult(typeA, typeB, disableB = false) {{
const a = makeItem(typeA, 120, 120);
const b = makeItem(typeB, 120, 128);
@ -1330,6 +1390,67 @@ for (const [a, b] of [['rotator', 'rotator'], ['rotator', 'reciprocator'], ['rot
const disabled = pairResult(a, b, true);
if (disabled.solved !== 0) throw new Error(`${{a}}/${{b}} collision-off pair still interacted`);
}}
const orientationWorld = {{ toolAngleFor() {{ return 0; }} }};
const previewReciprocator = makeItem('reciprocator', 40, 40);
context.TarinaiPlacementPreviewSystem.applyToolOrientation(orientationWorld, previewReciprocator, 0);
if (Math.abs(previewReciprocator.physicsBody.pose.angle - Math.PI / 2) > 1e-9) throw new Error('reciprocator placement body angle mismatch');
if (Math.abs(previewReciprocator.physicsBody.rail.axisAngle) > 1e-9) throw new Error('reciprocator placement rail angle mismatch');
const rail = makeItem('reciprocator', 0, 0);
context.TarinaiPlacementPreviewSystem.applyToolOrientation(orientationWorld, rail, 0);
activate(rail);
const fence = {{ id: 'fence', type: 'fence_v', x: 8, y: 0, amount: 999, dead: false }};
const collisionWorld = {{
items: [rail, fence], tarinai: [], time: 1, frameCount: 1, w: 900, h: 700, drawListDirty: false, itemBucketRebuildsTotal: 0,
ensureItemBuckets() {{}}, markSpatialDirty() {{}},
nearbyObstacles() {{ return this.items; }}, nearbyItems() {{ return this.items; }},
itemsOfType(type) {{ return this.items.filter(item => item.type === type && !item.dead); }},
isFenceType(type) {{ return type === 'fence_v'; }},
solidObstacleRects(item) {{
if (item !== fence) return [];
return [{{ left: 4, right: 12, top: -100, bottom: 100, cx: 8, cy: 0, halfW: 4, halfH: 100, angle: 0, cos: 1, sin: 0, oriented: true, type: 'fence_v', item }}];
}},
}};
rail.world = collisionWorld; fence.world = collisionWorld;
context.TarinaiMechanicalSystem.updateWorld(collisionWorld, 0.016, {{ maxSubsteps: 1 }});
if (context.TarinaiPhysicsBodySystem.scalar(rail, 'railDir', 1) !== -1) throw new Error('reciprocator did not reverse at a solid obstacle');
const oneWayRail = makeItem('reciprocator', 0, 0);
context.TarinaiPlacementPreviewSystem.applyToolOrientation(orientationWorld, oneWayRail, 0);
activate(oneWayRail);
const oneWayFence = {{ id: 'one-way-fence', type: 'one_way_fence', x: 8, y: 0, amount: 999, dead: false }};
const oneWayWorld = {{
items: [oneWayRail, oneWayFence], tarinai: [], time: 1, frameCount: 2, w: 900, h: 700, drawListDirty: false, itemBucketRebuildsTotal: 0,
ensureItemBuckets() {{}}, markSpatialDirty() {{}},
nearbyObstacles() {{ return this.items; }}, nearbyItems() {{ return this.items; }},
itemsOfType(type) {{ return this.items.filter(item => item.type === type && !item.dead); }},
isFenceType(type) {{ return type === 'one_way_fence'; }},
solidObstacleRects(item) {{
if (item !== oneWayFence) return [];
return [{{ left: 4, right: 12, top: -100, bottom: 100, cx: 8, cy: 0, halfW: 4, halfH: 100, angle: 0, cos: 1, sin: 0, oriented: true, type: 'one_way_fence', item, oneWay: true, oneWayNx: 1, oneWayNy: 0 }}];
}},
}};
oneWayRail.world = oneWayWorld; oneWayFence.world = oneWayWorld;
context.TarinaiMechanicalSystem.updateWorld(oneWayWorld, 0.016, {{ maxSubsteps: 1 }});
if (!Number.isFinite(context.TarinaiPhysicsBodySystem.scalar(oneWayRail, 'railDir', 0))) throw new Error('one-way mechanical fence update produced an invalid rail direction');
const cargoRail = makeItem('reciprocator', 0, 0);
context.TarinaiPlacementPreviewSystem.applyToolOrientation(orientationWorld, cargoRail, 0);
activate(cargoRail);
const ball = {{ id: 'ball', type: 'ball', x: 8, y: 0, r: 18, radius: 18, vx: 0, vy: 0, dead: false }};
const cargoWorld = {{
items: [cargoRail, ball], tarinai: [], time: 2, frameCount: 2, w: 900, h: 700, drawListDirty: false, itemBucketRebuildsTotal: 0,
ensureItemBuckets() {{}}, markSpatialDirty() {{}},
nearbyObstacles() {{ return this.items; }}, nearbyItems() {{ return this.items; }},
itemsOfType(type) {{ return this.items.filter(item => item.type === type && !item.dead); }},
isFenceType() {{ return false; }}, solidObstacleRects() {{ return []; }},
}};
cargoRail.world = cargoWorld; ball.world = cargoWorld;
context.TarinaiMechanicalSystem.updateWorld(cargoWorld, 0.016, {{ maxSubsteps: 1 }});
if (context.TarinaiPhysicsBodySystem.scalar(cargoRail, 'railDir', 1) !== 1) throw new Error('reciprocator reversed on movable cargo');
if (!(ball.x > 8 || Math.abs(ball.vx) > 0.01 || Math.abs(ball.vy) > 0.01)) throw new Error('reciprocator did not push movable cargo');
"""
with tempfile.NamedTemporaryFile("w", encoding="utf-8", suffix=".js", delete=False) as f:
f.write(js)
@ -1342,7 +1463,7 @@ for (const [a, b] of [['rotator', 'rotator'], ['rotator', 'reciprocator'], ['rot
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")
ok("mechanical pairs, obstacle-only reciprocator reversal, cargo pushing, placement orientation, and 20px editor snapping verified")
def colony_limits_and_defaults_smoke() -> None:
tool_defs = read_text(ROOT / "js/item_tool_definitions.js")
@ -1403,8 +1524,17 @@ def colony_limits_and_defaults_smoke() -> None:
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:
if "championMate" not in needs or "championPair" not in social or "mateWeight *= 4.0" not in social:
fail("champion-pair breeding preference is incomplete")
charts = read_text(ROOT / "js/ui_charts.js")
family_social = read_text(ROOT / "js/world_family_social.js")
if 'key: "births"' not in charts or 'key: "deaths"' not in charts or "birthEventCount" not in charts:
fail("colony population chart birth/death series are missing")
if "inheritedChampionPersonality" not in family_social or "parent.isTarinaiChampion" not in family_social or "* 0.70" not in family_social:
fail("champion personality inheritance is missing")
inheritance_block = family_social.split("function inheritedChampionPersonality", 1)[1].split("(function (global)", 1)[0]
if "isZunchiSlave" in inheritance_block or "zunchiSlaveLocked" in inheritance_block:
fail("champion inheritance must not alter zunchi-slave handling")
for token in ("tarinaiPopulationLimit", "objectLimit"):
if token not in snapshot or token not in codec:
fail(f"colony limit persistence missing: {token}")
@ -1457,14 +1587,14 @@ 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.TarinaiSnapshot = {{ version: 27 }};
context.TarinaiSaveSchema = {{ BINARY_SCHEMA_VERSION: 33, FIELD_IDS: ['garden'], itemTypeValue(i, fallback = '') {{ return fallback; }} }};
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 source = {{ v: 27, 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');
@ -1492,7 +1622,7 @@ def physics_tuning_smoke() -> None:
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"):
for token in ("BOUNCE_FENCE_RESTITUTION = 4.20", "BOUNCE_FENCE_MIN_SPEED = 820", "BOUNCE_FENCE_MAX_SPEED = 1680"):
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:
@ -1573,10 +1703,7 @@ context.TarinaiPhysicsBodySystem = {{
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 }};
@ -1629,6 +1756,105 @@ if (Math.abs(capturedScale - 0.68) > 1e-9) throw new Error('sticky bomb does not
fail("physics tuning smoke test failed")
ok("round circle template, strong bounce fence, inextensible rope, and small sticky-bomb blast verified")
def champion_inheritance_smoke() -> None:
source = read_text(ROOT / "js/world_family_social.js")
start = source.find("function inheritedChampionPersonality")
end = source.find("(function (global)", start)
if start < 0 or end < 0:
fail("champion inheritance helper not found")
helper = source[start:end]
js = f"""
const vm = require('vm');
const context = {{ console, globalThis: null }};
context.globalThis = context;
context.normalizePersonality = source => ({{
aggression: Math.max(-1, Math.min(1, Number(source?.aggression || 0))),
openness: Math.max(-1, Math.min(1, Number(source?.openness || 0))),
sociability: Math.max(-1, Math.min(1, Number(source?.sociability || 0))),
neuroticism: Math.max(-1, Math.min(1, Number(source?.neuroticism || 0))),
}});
context.clampPersonalityValue = value => Math.max(-1, Math.min(1, Number(value) || 0));
context.TarinaiSeedFactory = {{ birthProfile() {{ return {{ birthPersonality: {{ aggression: 0, openness: 0, sociability: 0, neuroticism: 0 }} }}; }} }};
vm.createContext(context);
vm.runInContext({helper!r}, context, {{ filename: 'champion_inheritance.js' }});
const normal = {{ birthSeed: 'normal', birthPersonality: {{ aggression: 0, openness: 0, sociability: 0, neuroticism: 0 }}, isTarinaiChampion: false }};
const champion = {{ birthSeed: 'champion', birthPersonality: {{ aggression: 0, openness: 0, sociability: 0, neuroticism: 0 }}, isTarinaiChampion: true }};
const child = context.inheritedChampionPersonality('child', [champion, normal], {{}});
if (!child || child.aggression < 0.5 || child.openness < 0.5 || child.sociability < 0.5) throw new Error('champion boost was not inherited');
if (Math.abs(child.neuroticism) > 1e-9) throw new Error('unboosted personality axis was inherited');
const descendant = {{ birthSeed: 'descendant', birthPersonality: child, isTarinaiChampion: false }};
const grandchild = context.inheritedChampionPersonality('grandchild', [descendant, normal], {{}});
if (!grandchild || !(grandchild.aggression > 0 && grandchild.aggression < child.aggression)) throw new Error('champion lineage did not damp across generations');
"""
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("champion inheritance smoke test failed")
ok("champion personality inheritance and generational damping verified")
def one_way_routing_smoke() -> None:
geometry_source = read_text(ROOT / "js/geometry_helpers.js")
source = read_text(ROOT / "js/world_environment.js")
render = read_text(ROOT / "js/render.js")
js = f"""
const vm = require('vm');
class World {{}}
const context = {{ console, World, globalThis: null, window: null, CONFIG: {{}},
TarinaiMechanicalShapeSystem: {{ isMechanicalType() {{ return false; }}, reach() {{ return 0; }} }},
TarinaiMechanicalSystem: {{}}, TarinaiPhysicsBodySystem: {{}},
}};
context.globalThis = context; context.window = context;
vm.createContext(context);
vm.runInContext({geometry_source!r}, context, {{ filename: 'geometry_helpers.js' }});
vm.runInContext({source!r}, context, {{ filename: 'world_environment.js' }});
const world = new context.World();
world.isFenceType = type => ['fence_h', 'one_way_fence'].includes(type);
const oneWay = {{ type: 'one_way_fence', item: {{ type: 'one_way_fence' }}, oneWay: true, oneWayNx: 1, oneWayNy: 0, oriented: false, left: -4, right: 4, top: -20, bottom: 20 }};
world.nearbySolidObstacleRects = () => [oneWay];
if (world.pathBlockedByFence(-30, 0, 30, 0, 0)) throw new Error('one-way fence blocked arrow direction');
if (!world.pathBlockedByFence(30, 0, -30, 0, 0)) throw new Error('one-way fence allowed reverse direction');
if (world.pointBlockedByObstacle(0, 0, 0, {{ directionalOneWay: true }})) throw new Error('one-way fence permanently blocked grid point');
if (!world.pointBlockedByObstacle(0, 0, 0)) throw new Error('one-way fence lost physical point occupancy');
const normal = {{ type: 'fence_h', item: {{ type: 'fence_h' }}, oriented: false, left: -4, right: 4, top: -20, bottom: 20 }};
world.nearbySolidObstacleRects = () => [normal];
if (!world.pathBlockedByFence(-30, 0, 30, 0, 0)) throw new Error('ordinary fence stopped blocking routes');
"""
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("one-way fence routing smoke test failed")
if 'type === "one_way_fence"' not in render or "arrowLength" not in render:
fail("one-way fence placement direction overlay is missing")
ok("one-way fence preview and directional routing verified")
def check_save_stream_deadlock_guard() -> None:
codec = read_text(ROOT / "js/save_codec.js")
ui = read_text(ROOT / "js/save_system.js")
read_task = codec.find("const readTask = (async () =>")
write_call = codec.find("await sink.write(")
if read_task < 0 or write_call < 0 or read_task > write_call:
fail("save compression output must be drained before awaiting writes")
if 'exportBtn.dataset.busy = "1"' not in ui or 'save text generation failed' not in ui:
fail("save export UI must recover from pending/error states")
ok("save compression stream backpressure guard present")
def main() -> None:
data = manifest()
check_files_exist(data)
@ -1643,6 +1869,8 @@ def main() -> None:
need_action_runtime_bridge_smoke()
mechanical_collision_and_editor_smoke()
physics_tuning_smoke()
champion_inheritance_smoke()
one_way_routing_smoke()
colony_limits_and_defaults_smoke()
check_basic_action_spec_runtime()
check_social_action_spec_runtime()
@ -1651,6 +1879,7 @@ def main() -> None:
check_social_balance_guards()
check_item_bucket_polish()
check_current_save_format()
check_save_stream_deadlock_guard()
check_phase_and_input_split()
check_system_order_boundary()
simulation_boundary_smoke()
@ -1662,6 +1891,7 @@ def main() -> None:
command_dispatcher_smoke()
check_legacy_behavior_refs()
check_legacy_physics_props_removed()
check_redundant_correction_paths_removed()
if __name__ == "__main__":