2944 lines
166 KiB
Python
2944 lines
166 KiB
Python
#!/usr/bin/env python3
|
|
"""Lightweight regression guard for the buildless Tarinai app.
|
|
|
|
Checks static-file consistency, generated-load order, syntax, and the ActionSpec
|
|
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
|
|
|
|
from pathlib import Path
|
|
import json
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
MANIFEST = ROOT / "app_manifest.json"
|
|
|
|
|
|
def fail(message: str) -> None:
|
|
print(f"[FAIL] {message}", file=sys.stderr)
|
|
raise SystemExit(1)
|
|
|
|
|
|
def ok(message: str) -> None:
|
|
print(f"[OK] {message}")
|
|
|
|
|
|
def read_text(path: Path) -> str:
|
|
return path.read_text(encoding="utf-8")
|
|
|
|
|
|
def manifest() -> dict:
|
|
try:
|
|
data = json.loads(read_text(MANIFEST))
|
|
except Exception as exc: # pragma: no cover - diagnostic path
|
|
fail(f"cannot read app_manifest.json: {exc}")
|
|
for key in ("version", "css", "js"):
|
|
if key not in data:
|
|
fail(f"manifest missing {key}")
|
|
return data
|
|
|
|
|
|
def check_files_exist(data: dict) -> None:
|
|
missing = [p for p in [*data["css"], *data["js"]] if not (ROOT / p).exists()]
|
|
if missing:
|
|
fail("manifest references missing files: " + ", ".join(missing))
|
|
ok("manifest file references exist")
|
|
|
|
|
|
def check_versions(data: dict) -> None:
|
|
version = str(data["version"])
|
|
version_js = read_text(ROOT / "js/version.js")
|
|
service_worker = read_text(ROOT / "service-worker.js")
|
|
if f'APP_VERSION = "{version}"' not in version_js:
|
|
fail("js/version.js APP_VERSION does not match manifest")
|
|
if f'APP_VERSION = "{version}"' not in service_worker:
|
|
fail("service-worker.js APP_VERSION does not match manifest")
|
|
ok(f"version synchronized: {version}")
|
|
|
|
|
|
def check_index_order(data: dict) -> None:
|
|
index = read_text(ROOT / "index.html")
|
|
css = re.findall(r'<link rel="stylesheet" href="([^"]+)"\s*/>', index)
|
|
scripts = re.findall(r'<script src="([^"]+)" defer></script>', index)
|
|
css_clean = [entry.split("?", 1)[0] for entry in css if entry.startswith("css/")]
|
|
script_clean = [entry.split("?", 1)[0] for entry in scripts if entry.startswith("js/")]
|
|
if css_clean[: len(data["css"])] != data["css"]:
|
|
fail("index.html CSS order differs from manifest")
|
|
if script_clean[: len(data["js"])] != data["js"]:
|
|
fail("index.html JS order differs from manifest")
|
|
ok("index.html load order matches manifest")
|
|
|
|
|
|
def check_service_worker_order(data: dict) -> None:
|
|
sw = read_text(ROOT / "service-worker.js")
|
|
match = re.search(r"const coreScriptNames = \[\s*(.*?)\s*\];", sw, re.S)
|
|
if not match:
|
|
fail("service-worker.js coreScriptNames not found")
|
|
names = re.findall(r'"([^"]+)"', match.group(1))
|
|
expected = [Path(p).stem for p in data["js"]]
|
|
if names != expected:
|
|
fail("service-worker.js coreScriptNames differs from manifest JS order")
|
|
ok("service-worker.js core script order matches manifest")
|
|
|
|
|
|
def node_check(paths: list[Path]) -> None:
|
|
node = "node"
|
|
for path in paths:
|
|
result = subprocess.run([node, "--check", str(path)], cwd=ROOT, text=True, capture_output=True)
|
|
if result.returncode != 0:
|
|
sys.stderr.write(result.stdout)
|
|
sys.stderr.write(result.stderr)
|
|
fail(f"node --check failed: {path.relative_to(ROOT)}")
|
|
ok(f"node --check passed for {len(paths)} files")
|
|
|
|
|
|
def action_spec_smoke() -> None:
|
|
source = read_text(ROOT / "js/tarinai_action_spec.js")
|
|
js = f"""
|
|
const vm = require('vm');
|
|
const context = {{ console, globalThis: null }};
|
|
context.globalThis = context;
|
|
context.window = context;
|
|
vm.createContext(context);
|
|
vm.runInContext({source!r}, context, {{ filename: 'tarinai_action_spec.js' }});
|
|
const action = context.registerTarinaiActionSpecs([{{
|
|
id: 'smoke_action',
|
|
need: 'fulfill',
|
|
label: 'smoke',
|
|
selectTarget() {{ return {{ id: 'target-1' }}; }},
|
|
canStart(t, world, ctx) {{ const target = ctx.target || this.selectTarget(t, world, ctx); return target && target.id === 'target-1'; }},
|
|
start(t, world, ctx) {{ t.startedWith = ctx.target.id; return true; }},
|
|
tick() {{ return 'finished'; }},
|
|
finish(t) {{ t.finished = true; return true; }},
|
|
}}])[0];
|
|
const subject = {{}};
|
|
if (!context.canStartTarinaiAction('smoke_action', subject, {{}})) throw new Error('canStart failed');
|
|
if (!context.startTarinaiAction(action, subject, {{}})) throw new Error('start failed');
|
|
if (subject.startedWith !== 'target-1') throw new Error('selectTarget/start context failed');
|
|
if (context.tickTarinaiAction('smoke_action', subject, {{}}, 0.1, {{}}) !== 'finished') throw new Error('tick failed');
|
|
if (!context.finishTarinaiAction('smoke_action', subject, {{}}, {{ phase: 'finished' }})) throw new Error('finish failed');
|
|
"""
|
|
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("ActionSpec smoke test failed")
|
|
ok("ActionSpec lifecycle smoke test passed")
|
|
|
|
|
|
def behavior_state_smoke() -> None:
|
|
source = read_text(ROOT / "js/tarinai_behavior_state.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({source!r}, context, {{ filename: 'tarinai_behavior_state.js' }});
|
|
const t = new context.Tarinai();
|
|
t.world = {{ time: 12 }};
|
|
t.state = 'seek_food';
|
|
context.setTarinaiBehavior(t, {{ actionId: 'eat_food', need: 'food', label: 'eat', reason: 'hungry', text: 'hungry', tiedNeeds: ['food', 'sleep'], source: 'forced', forcedId: 'f1', forcedRequest: {{ uid: 'f1', id: 'eat_food', source: 'test', priority: 99, reasonText: 'forced hungry', status: 'active' }} }});
|
|
if (context.getTarinaiBehaviorId(t) !== 'eat_food') throw new Error('behavior id mismatch');
|
|
if (context.getTarinaiBehaviorNeed(t) !== 'food') throw new Error('behavior need mismatch');
|
|
if (!context.isTarinaiBehaviorForced(t)) throw new Error('forced state not visible');
|
|
const behavior = context.currentTarinaiBehavior(t);
|
|
if (!behavior || behavior.actionId !== 'eat_food' || behavior.reason !== 'hungry' || behavior.source !== 'forced') throw new Error('canonical behavior fields not normalized');
|
|
if ('intent' in t || 'currentAction' in t || 'activeBehavior' in t || 'activeForcedBehavior' in t) throw new Error('legacy behavior accessors still present');
|
|
const forced = context.getTarinaiForcedBehavior(t);
|
|
if (!forced || forced.uid !== 'f1' || forced.priority !== 99) throw new Error('forced request not normalized');
|
|
context.setTarinaiForcedBehavior(t, null);
|
|
if (context.currentTarinaiBehavior(t).source === 'forced' || context.getTarinaiForcedBehavior(t)) throw new Error('forced source not cleared');
|
|
context.clearTarinaiBehavior(t);
|
|
if (context.currentTarinaiBehavior(t) !== null) throw new Error('behavior not cleared');
|
|
"""
|
|
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("Behavior state smoke test failed")
|
|
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 = []
|
|
for path in (ROOT / "js").glob("*.js"):
|
|
for i, line in enumerate(read_text(path).splitlines(), start=1):
|
|
stripped = line.strip()
|
|
if stripped.startswith("//"):
|
|
continue
|
|
if tokens.search(line):
|
|
offenders.append(f"{path.relative_to(ROOT).as_posix()}:{i}")
|
|
if offenders:
|
|
fail("legacy behavior fields still present: " + ", ".join(offenders[:20]))
|
|
ok("legacy behavior runtime fields removed")
|
|
|
|
|
|
|
|
|
|
def check_legacy_physics_props_removed() -> None:
|
|
terms = [
|
|
"rotatorThickness", "rotatorSegments", "rotatorPowered", "rotatorSpeed", "rotatorAngularVelocity",
|
|
"poisonCollisionEnabled", "poisonDamage", "poisonMass", "poisonInertia",
|
|
"reciprocatorPowered", "reciprocatorSpeed", "reciprocatorTravel", "reciprocatorPhase",
|
|
"reciprocatorDirection", "reciprocatorAxisAngle", "reciprocatorVelocity", "reciprocatorAnchorX", "reciprocatorAnchorY",
|
|
"linkA", "linkB", "linkLength", "linkMidX", "linkMidY", "linkMidVX", "linkMidVY",
|
|
"ropeParticles", "_physicsAwakeUntil", "_linkAwakeUntil",
|
|
]
|
|
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("legacy physics property names still present: " + ", ".join(offenders[:20]))
|
|
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 direct_feeding_smoke() -> None:
|
|
source = read_text(ROOT / "js/tarinai_direct_feeding_system.js")
|
|
placement = read_text(ROOT / "js/world_placement_log.js")
|
|
ui = read_text(ROOT / "js/ui_input_shared.js")
|
|
render = read_text(ROOT / "js/render.js")
|
|
world_source = read_text(ROOT / "js/world.js")
|
|
js = f"""
|
|
const vm = require('vm');
|
|
class TestEffect {{
|
|
constructor(type, x, y, options = {{}}) {{ Object.assign(this, {{ type, x, y }}, options); }}
|
|
}}
|
|
const context = {{
|
|
console, globalThis: null, window: null,
|
|
rand(a, b) {{ return (Number(a) + Number(b)) * 0.5; }},
|
|
distXY(ax, ay, bx, by) {{ return Math.hypot(ax - bx, ay - by); }},
|
|
isServingFoodType(type) {{ return !['grass', 'water'].includes(type); }},
|
|
toolLabel(type) {{ return ({{ sweet: '\u305a\u3093\u3060\u9905', protein: '\u30d7\u30ed\u30c6\u30a4\u30f3' }})[type] || type; }},
|
|
TarinaiEffect: TestEffect,
|
|
TarinaiItemRegistry: {{ food: {{ nutrition(type) {{ return type === 'sweet' ? 16.5 : 15; }} }} }},
|
|
uiCache: {{ selectedSnapshot: 'stale' }},
|
|
}};
|
|
context.globalThis = context; context.window = context;
|
|
context.consumeBehaviorTarget = (t, world, item, role) => {{
|
|
item.consumedRole = role;
|
|
item.foodServingsRemaining = Math.max(0, Number(item.foodServingsRemaining || 1) - 1);
|
|
return true;
|
|
}};
|
|
context.TarinaiStructureLifecycle = {{
|
|
deleteItem(world, item) {{ item.dead = true; world.items = world.items.filter(entry => entry !== item); return true; }}
|
|
}};
|
|
vm.createContext(context);
|
|
vm.runInContext({source!r}, context, {{ filename: 'tarinai_direct_feeding_system.js' }});
|
|
const api = context.TarinaiDirectFeedingSystem;
|
|
if (!api || !api.isDirectFeedType('sweet') || !api.isDirectFeedType('protein') || api.isDirectFeedType('ball')) throw new Error('direct-feed eligibility mismatch');
|
|
const world = {{
|
|
tarinai: [], items: [], effects: [], logs: [], time: 12, hoverGiveTarget: null,
|
|
queueEffect(type, x, y, options = {{}}) {{ const effect = new TestEffect(type, x, y, options); this.effects.push(effect); return effect; }},
|
|
isTarinaiHiddenInNestBox() {{ return false; }},
|
|
log(text, kind, meta) {{ this.logs.push({{ text, kind, meta }}); }},
|
|
recordFamily(t) {{ this.recorded = t; }},
|
|
}};
|
|
function makeTarinai(name, x, y) {{
|
|
return {{
|
|
name, x, y, radius: 22, world, affection: 0, currentPersonality: {{ openness: 0, sociability: 0 }},
|
|
contains(px, py) {{ return Math.hypot(px - this.x, py - this.y) <= 24; }},
|
|
adjustPersonality(key, delta, reason) {{
|
|
this.currentPersonality[key] += delta;
|
|
this.personalityReasons = this.personalityReasons || [];
|
|
this.personalityReasons.push(reason);
|
|
return delta;
|
|
}},
|
|
bubble(text) {{ this.lastBubble = text; }},
|
|
}};
|
|
}}
|
|
const first = makeTarinai('first', 20, 20);
|
|
const top = makeTarinai('top', 20, 20);
|
|
world.tarinai = [first, top];
|
|
if (api.findTargetAt(world, 20, 20) !== top) throw new Error('top-most direct-feed target was not selected');
|
|
const sweet = {{ type: 'sweet', amount: 5, foodServingsRemaining: 5, x: 20, y: 20 }};
|
|
world.items.push(sweet);
|
|
const result = api.give(world, top, sweet, {{ source: 'pinch' }});
|
|
if (!result?.foodGift || !result.removed || !sweet.dead || world.items.includes(sweet)) throw new Error('pinched food was not consumed and removed');
|
|
const hearts = world.effects.filter(effect => effect.type === 'heart');
|
|
if (hearts.length < 10 || hearts.length > 14) throw new Error(`food gift heart count out of range: ${{hearts.length}}`);
|
|
if (!(top.currentPersonality.openness > 0.018) || !(top.currentPersonality.sociability >= 0.010)) throw new Error('food gift personality effect is too weak');
|
|
if (top.currentPersonality.openness <= 0.006) throw new Error('food gift does not exceed one second of cursor openness effect');
|
|
const expectedPersonalityReason = '\u30ab\u30fc\u30bd\u30eb\u304b\u3089\u76f4\u63a5\u98df\u3079\u7269\u3092\u98df\u3079\u305f';
|
|
if (!top.personalityReasons?.length || top.personalityReasons.some(reason => reason !== expectedPersonalityReason)) throw new Error('direct-feed personality reason mismatch');
|
|
if (top.lastBubble !== '\u2661' || context.uiCache.selectedSnapshot !== '') throw new Error('food gift feedback/cache refresh missing');
|
|
if (world.logs.some(entry => entry.meta?.eventType === 'direct_feed')) throw new Error('direct-feed player operation must not enter the event log');
|
|
const priorEffects = world.effects.length;
|
|
const medicine = {{ type: 'protein', amount: 5, foodServingsRemaining: 5, x: 20, y: 20 }};
|
|
world.items.push(medicine);
|
|
const medicineResult = api.give(world, top, medicine, {{ source: 'pinch' }});
|
|
if (!medicineResult || medicineResult.foodGift || medicine.consumedRole !== 'medicine') throw new Error('medicine direct-feed path mismatch');
|
|
if (world.effects.length !== priorEffects) throw new Error('medicine incorrectly emitted food hearts');
|
|
if (!(top.focusPulseTimer >= 0.72)) throw new Error('medicine feedback pulse missing');
|
|
const placementItem = {{ type: 'sweet', amount: 5, foodServingsRemaining: 5, x: 20, y: 20 }};
|
|
const placementResult = api.give(world, top, placementItem, {{ source: 'placement' }});
|
|
if (!placementResult || placementResult.removed || placementItem.dead) throw new Error('ephemeral placement item should be consumed without lifecycle deletion');
|
|
"""
|
|
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("direct feeding runtime smoke test failed")
|
|
placement_start = placement.find("placeItem(item, dropped = true)")
|
|
direct_call = placement.find("directFeeding.give?.", placement_start)
|
|
object_limit = placement.find("this.canAddObjects?.(1)", placement_start)
|
|
blocked = placement.find("this.placementBlocked(item)", placement_start)
|
|
if min(placement_start, direct_call, object_limit, blocked) < 0 or not (direct_call < object_limit < blocked):
|
|
fail("direct placement feeding must run before object-limit and overlap rejection")
|
|
click_section = placement.find("const itemType = toolItemType(tool)")
|
|
click_direct = placement.find("const directTarget =", click_section)
|
|
duplicator_set = placement.find("this.directSetDuplicatorAt?.", click_section)
|
|
if min(click_section, click_direct, duplicator_set) < 0 or duplicator_set > click_direct:
|
|
fail("Duplicator loading must take precedence over Tarinai direct feeding at the same point")
|
|
required_ui = ["directFeedTargetAt", 'source: "pinch"', "setGiveHover", "hoverGiveTarget"]
|
|
if any(token not in ui for token in required_ui):
|
|
fail("pinch direct-feeding input bridge is incomplete")
|
|
if 'label: "\\u4e0e\\u3048\\u308b"' not in render or "world.hoverGiveTarget" not in render:
|
|
fail("direct-feeding hover overlay is missing")
|
|
if world_source.count("this.hoverGiveTarget = null;") < 2:
|
|
fail("direct-feeding hover state is not initialized and reset")
|
|
ok("direct food/medicine feeding, placement interception, personality, hearts, and overlay verified")
|
|
|
|
|
|
def check_collision_reason_and_projection_dirty_removed() -> None:
|
|
collision_paths = [
|
|
ROOT / "js/world_environment.js",
|
|
ROOT / "js/tarinai_update_step_movement.js",
|
|
ROOT / "js/tarinai_social_move_life.js",
|
|
]
|
|
collision_source = "\n".join(read_text(path) for path in collision_paths)
|
|
if re.search(r"resolveSolidObstacleCollision\s*\([^)]*\{[^}]*\breason\s*:", collision_source, re.S):
|
|
fail("unused resolveSolidObstacleCollision reason option returned")
|
|
environment = read_text(ROOT / "js/world_environment.js")
|
|
if "opts.reason" in environment:
|
|
fail("resolveSolidObstacleCollision still reads unused reason option")
|
|
constraint = read_text(ROOT / "js/constraint_system.js")
|
|
match = re.search(r"function finishLinkSpatialUpdate\([^)]*\)\s*\{(.*?)\n \}", constraint, re.S)
|
|
if not match:
|
|
fail("finishLinkSpatialUpdate helper not found")
|
|
body = match.group(1)
|
|
if "Math.hypot" in body or "before" in body or body.count("markConstraintSpatialDirty") != 1:
|
|
fail("constraint Projection dirty determination is duplicated")
|
|
ok("unused collision reason and duplicate constraint Projection dirty determination 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 = {
|
|
"eat_food": "createConsumableActionSpec",
|
|
"drink_water": "createConsumableActionSpec",
|
|
"use_medicine": "createConsumableActionSpec",
|
|
"sleep_in_bed": "createSleepInBedActionSpec",
|
|
"sleep_build_grass_bed": "createSleepBuildGrassBedActionSpec",
|
|
"sleep_anywhere": "createSleepAnywhereActionSpec",
|
|
"rest_to_recover": "createRestActionSpec",
|
|
"sunbath": "createSunbathActionSpec",
|
|
"wander_lightly": "createWanderActionSpec",
|
|
}
|
|
for action_id, factory in required.items():
|
|
if factory not in source:
|
|
fail(f"basic ActionSpec factory missing for {action_id}: {factory}")
|
|
forbidden_late_ticks = [
|
|
'set("eat_food",',
|
|
'set("drink_water",',
|
|
'set("use_medicine",',
|
|
'set("sunbath",',
|
|
]
|
|
offenders = [token for token in forbidden_late_ticks if token in source]
|
|
if offenders:
|
|
fail("basic actions still patched in configureTarinaiNeedActions: " + ", ".join(offenders))
|
|
ok("basic need actions owned by ActionSpec factories")
|
|
|
|
|
|
|
|
def check_social_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_factories = [
|
|
"createPanicEscapeActionSpec",
|
|
"createApproachFriendActionSpec",
|
|
"createApproachFamilyActionSpec",
|
|
"createApproachMateActionSpec",
|
|
"createFightRivalActionSpec",
|
|
"createIntimidateEnemyActionSpec",
|
|
"createBirthRitualActionSpec",
|
|
]
|
|
for factory in required_factories:
|
|
if factory not in source:
|
|
fail(f"social/combat ActionSpec factory missing: {factory}")
|
|
definitions_match = re.search(r"const TARINAI_ACTION_DEFINITIONS = \[(.*?)\];", source, re.S)
|
|
if not definitions_match:
|
|
fail("TARINAI_ACTION_DEFINITIONS block missing")
|
|
definitions = definitions_match.group(1)
|
|
for factory in required_factories:
|
|
if f"{factory}()" not in definitions:
|
|
fail(f"{factory} is not registered in TARINAI_ACTION_DEFINITIONS")
|
|
if "function configureTarinaiNeedActions" in source:
|
|
fail("configureTarinaiNeedActions should not be needed after ActionSpec runtime consolidation")
|
|
forbidden_late_ticks = [
|
|
'set("panic_escape",',
|
|
'set("approach_friend",',
|
|
'set("approach_parent_or_child",',
|
|
'set("approach_mate",',
|
|
'set("fight_rival",',
|
|
'set("intimidate_enemy",',
|
|
'set("sunbath",',
|
|
"birthRitualAction",
|
|
"TARINAI_ACTIONS.push",
|
|
]
|
|
offenders = [token for token in forbidden_late_ticks if token in source]
|
|
if offenders:
|
|
fail("actions still patched late: " + ", ".join(offenders))
|
|
ok("social/fear/combat/mate/birth/sunbath actions owned by ActionSpec factories")
|
|
|
|
|
|
def check_action_spec_no_legacy_hooks() -> None:
|
|
source = read_text(ROOT / "js/tarinai_action_spec.js")
|
|
forbidden = ["condition", "originalRun", "originalUpdate", "Legacy hooks"]
|
|
offenders = [token for token in forbidden if token in source]
|
|
if offenders:
|
|
fail("ActionSpec legacy hook support still present: " + ", ".join(offenders))
|
|
needs = "\n".join(read_text(ROOT / rel) for rel in ["js/tarinai_action_definitions.js", "js/tarinai_consumable_behavior.js", "js/tarinai_social_action_runtime.js", "js/tarinai_building_behavior.js", "js/tarinai_needs_items.js"])
|
|
if ".condition" in needs or "action.condition" in needs:
|
|
fail("need action runtime still falls back to action.condition")
|
|
ok("ActionSpec legacy hooks removed")
|
|
|
|
def check_runtime_diagnostics_hooks() -> None:
|
|
spatial_budget = read_text(ROOT / "js/world_spatial_budget.js")
|
|
debug_tools = read_text(ROOT / "js/debug_tools.js")
|
|
world_update = read_text(ROOT / "js/world_update.js")
|
|
simulation_systems = read_text(ROOT / "js/simulation_systems.js")
|
|
if "runtimeDiagnostics()" not in spatial_budget:
|
|
fail("World.runtimeDiagnostics hook missing")
|
|
if "lastRuntimeDiagnostics" not in (world_update + simulation_systems):
|
|
fail("world update systems do not publish lastRuntimeDiagnostics")
|
|
if "spatial rebuild" not in debug_tools or "behavior forced" not in debug_tools:
|
|
fail("debug overlay is missing runtime diagnostic lines")
|
|
spatial_budget = read_text(ROOT / "js/world_spatial_budget.js")
|
|
debug_tools = read_text(ROOT / "js/debug_tools.js")
|
|
if "performance:" in spatial_budget or "diag.performance" in debug_tools or "load-check removed" in debug_tools:
|
|
fail("removed performance/load-check diagnostics residue remains")
|
|
ok("runtime diagnostics hooks present")
|
|
|
|
def check_social_balance_guards() -> None:
|
|
needs = "\n".join(read_text(ROOT / rel) for rel in [
|
|
"js/tarinai_needs_core.js",
|
|
"js/tarinai_behavior_text.js",
|
|
"js/tarinai_forced_behavior.js",
|
|
"js/tarinai_item_targeting.js",
|
|
"js/tarinai_action_definitions.js",
|
|
"js/tarinai_consumable_behavior.js",
|
|
"js/tarinai_social_action_runtime.js",
|
|
"js/tarinai_building_behavior.js",
|
|
"js/tarinai_needs_items.js",
|
|
])
|
|
world_social = read_text(ROOT / "js/world_family_social.js")
|
|
social_move = read_text(ROOT / "js/tarinai_social_move_life.js")
|
|
required_need_tokens = [
|
|
"fight_rival: 72",
|
|
"intimidate_enemy: 0",
|
|
"approach_mate: 72",
|
|
"approach_friend: 66",
|
|
"return ({ conflict: 18, mate: 24, family: 12, bond: 14",
|
|
'if (subNeed === "mate")',
|
|
"if (subValue >= threshold) return true",
|
|
"const relationDrive = Math.max(Number(parts.conflict || 0)",
|
|
"drugBoosted || relationDrive >= Math.min",
|
|
"social-fight-vs-mate-weighted",
|
|
]
|
|
for token in required_need_tokens:
|
|
if token not in needs:
|
|
fail(f"social balance guard token missing: {token}")
|
|
if "canStart: basicCanStartWithTarget,\n start(t, world, ctx = {}) {\n const rival" in needs:
|
|
fail("intimidate_enemy reverted to ungated basicCanStart")
|
|
required_world_tokens = [
|
|
"fightPairCooldowns",
|
|
"fightPairKey(a, b)",
|
|
"fightPairCooldownRemaining(a, b)",
|
|
"markFightPairCooldown(a, b",
|
|
"isAlreadyFightingPair(a, b)",
|
|
"if (this.isAlreadyFightingPair?.(a, b)) return true",
|
|
]
|
|
for token in required_world_tokens:
|
|
if token not in world_social:
|
|
fail(f"fight re-entry guard missing: {token}")
|
|
if "social-weighted-fight-mate-start" not in social_move or "pickSocialFightMate" not in social_move:
|
|
fail("ordinary proximity weighted fight/mate guard missing")
|
|
ok("social balance and fight re-entry guards present")
|
|
|
|
|
|
def check_item_bucket_polish() -> None:
|
|
spatial_budget = read_text(ROOT / "js/world_spatial_budget.js")
|
|
required_tokens = [
|
|
"markItemBucketsDirty(reason",
|
|
"ensureItemBuckets(reason",
|
|
"addItem(item",
|
|
"itemById(id)",
|
|
"itemsOfType(type)",
|
|
"this.itemIdMap = new Map()",
|
|
"bucketRebuildsTotal",
|
|
]
|
|
for token in required_tokens:
|
|
if token not in spatial_budget:
|
|
fail(f"item bucket polish helper missing: {token}")
|
|
if "return item && !item.dead ? item : null" not in spatial_budget:
|
|
fail("itemById must not return dead items")
|
|
debug_tools = read_text(ROOT / "js/debug_tools.js")
|
|
if "bucket" not in debug_tools.lower() and "id-map" not in debug_tools.lower():
|
|
fail("debug overlay is missing item bucket/id-map diagnostics")
|
|
routed_files = {
|
|
"js/world_placement_log.js": "addItem?.(item",
|
|
"js/simulation_ambient_system.js": "addItem?.(drop",
|
|
"js/world_combat_effects.js": "addItem?.(it",
|
|
"js/ants.js": "addItem?.(corpse",
|
|
"js/tarinai_social_move_life.js": "addItem?.(new Item(\"trace\"",
|
|
"js/tarinai_building_behavior.js": "addItem?.(structure",
|
|
}
|
|
for rel, token in routed_files.items():
|
|
if token not in read_text(ROOT / rel):
|
|
fail(f"runtime item creation path is not routed through addItem: {rel}")
|
|
ok("runtime item bucket/id-map polish present")
|
|
|
|
|
|
def check_current_save_format() -> None:
|
|
snapshot = read_text(ROOT / "js/snapshot_system.js")
|
|
save = read_text(ROOT / "js/save_system.js")
|
|
save_codec = read_text(ROOT / "js/save_codec.js")
|
|
save_storage = read_text(ROOT / "js/save_storage.js")
|
|
restore_coordinator = read_text(ROOT / "js/restore_coordinator.js")
|
|
required_snapshot = [
|
|
"const SNAPSHOT_VERSION = SaveSchema.SNAPSHOT_VERSION",
|
|
"a: \"tj1\"",
|
|
"function compactWorld",
|
|
"function compactTarinai",
|
|
"function compactItem",
|
|
"worldRef.logs = []",
|
|
"worldRef.family = {}",
|
|
"recordFamily",
|
|
]
|
|
for token in required_snapshot:
|
|
if token not in snapshot:
|
|
fail(f"binary save snapshot token missing: {token}")
|
|
forbidden_snapshot = [
|
|
"compactAnt",
|
|
"effects: (worldRef.effects",
|
|
"cameraX",
|
|
"cameraY",
|
|
"behavior:",
|
|
"deathReason",
|
|
"summary:",
|
|
"app: \"tarinai_colony_game\"",
|
|
]
|
|
offenders = [token for token in forbidden_snapshot if token in snapshot]
|
|
if offenders:
|
|
fail("broad snapshot fields still present after light-save conversion: " + ", ".join(offenders))
|
|
required_codec = [
|
|
'EXPORT_PREFIX = "\\u305f"',
|
|
'SAVE_TEXT_BITS = 15',
|
|
"writeStringTable",
|
|
"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")',
|
|
"function writeSlot",
|
|
"function readSlot",
|
|
"function requireSlot",
|
|
"function deleteSlot",
|
|
]
|
|
for token in required_storage:
|
|
if token not in save_storage:
|
|
fail(f"binary save storage token missing: {token}")
|
|
required_facade = [
|
|
"TarinaiSaveSystem",
|
|
"TarinaiSaveCodec",
|
|
"TarinaiSaveStorage",
|
|
"TarinaiRestoreCoordinator",
|
|
]
|
|
combined = save + save_codec + save_storage + restore_coordinator
|
|
for token in required_facade:
|
|
if token not in combined:
|
|
fail(f"save/restore split facade token missing: {token}")
|
|
for token in ("TN2!", "TN3!", "tarinai_save_slot_v2_", "tarinai_save_slot_v3_", "legacyForcedColumn"):
|
|
if token in combined or token in snapshot:
|
|
fail(f"save backward compatibility residue still present: {token}")
|
|
if "renderStats" in snapshot or "TarinaiFreezeSystem" in snapshot or "renderArchive" in snapshot:
|
|
fail("snapshot restore must not call UI or freeze subsystems directly")
|
|
effects = read_text(ROOT / "js/world_event_effects.js")
|
|
if "tool:placed" not in effects or "function playLogSoundForEntry" not in effects:
|
|
fail("event effects do not own tool/log side effects")
|
|
for rel in ("js/freeze_system.js", "js/freeze_store.js", "js/freeze_snapshot_adapter.js", "js/freeze_panel.js"):
|
|
if (ROOT / rel).exists():
|
|
fail(f"freezer subsystem file still exists after freezer removal: {rel}")
|
|
placement = read_text(ROOT / "js/world_placement_log.js")
|
|
if "playLogSoundForEntry" in placement:
|
|
fail("log sound mapping still lives in world_placement_log.js")
|
|
if "player_item_placement" in placement:
|
|
fail("player item placement should not create placement log entries")
|
|
ok("binary hash save snapshot, codec, storage, and restore coordinator present")
|
|
|
|
def check_unified_item_registry() -> None:
|
|
data = manifest()
|
|
js_files = data["js"]
|
|
required_files = [
|
|
"js/item_tool_definitions.js",
|
|
"js/item_visual_definitions.js",
|
|
"js/item_food_definitions.js",
|
|
"js/item_effect_definitions.js",
|
|
"js/item_registry.js",
|
|
]
|
|
missing = [rel for rel in required_files if rel not in js_files or not (ROOT / rel).exists()]
|
|
if missing:
|
|
fail("split item registry files missing: " + ", ".join(missing))
|
|
order = [js_files.index(rel) for rel in required_files]
|
|
if order != sorted(order):
|
|
fail("split item registry files are not loaded before the facade in manifest order")
|
|
stale_files = [rel for rel in ["js/food_registry.js", "js/effect_registry.js", "js/item_visual_registry.js"] if (ROOT / rel).exists() or rel in js_files]
|
|
if stale_files:
|
|
fail("obsolete item registry split files remain: " + ", ".join(stale_files))
|
|
|
|
tool_source = read_text(ROOT / "js/item_tool_definitions.js")
|
|
visual_source = read_text(ROOT / "js/item_visual_definitions.js")
|
|
food_source = read_text(ROOT / "js/item_food_definitions.js")
|
|
effect_source = read_text(ROOT / "js/item_effect_definitions.js")
|
|
facade_source = read_text(ROOT / "js/item_registry.js")
|
|
required_pairs = [
|
|
(tool_source, "const TOOL_DEFINITIONS"),
|
|
(visual_source, "function itemVisualDefinition"),
|
|
(food_source, "class FoodRegistry"),
|
|
(food_source, "global.TarinaiFoodRegistry"),
|
|
(effect_source, "class EffectRegistry"),
|
|
(effect_source, "global.TarinaiEffectRegistry"),
|
|
(facade_source, "global.TarinaiItemRegistry"),
|
|
(facade_source, "food: global.TarinaiFoodRegistry"),
|
|
(facade_source, "effect: global.TarinaiEffectRegistry"),
|
|
]
|
|
for source, token in required_pairs:
|
|
if token not in source:
|
|
fail(f"split item registry token missing: {token}")
|
|
data_source = read_text(ROOT / "js/data.js")
|
|
forbidden_data = ["const TOOL_DEFINITIONS", "const ITEM_TRAITS", "class FoodRegistry", "class EffectRegistry", "TARINAI_ITEM_VISUALS"]
|
|
offenders = [token for token in forbidden_data if token in data_source]
|
|
if offenders:
|
|
fail("item definitions still mixed into data.js: " + ", ".join(offenders))
|
|
ok("item registry responsibilities split behind facade")
|
|
|
|
def check_docs_removed() -> None:
|
|
docs = ROOT / "docs"
|
|
if docs.exists() and any(docs.iterdir()):
|
|
fail("non-runtime docs directory still contains files")
|
|
ok("non-runtime docs removed")
|
|
|
|
def check_phase_and_input_split() -> None:
|
|
data = manifest()
|
|
js_files = data["js"]
|
|
required = [
|
|
"js/simulation_runtime_helpers.js",
|
|
"js/simulation_environment_system.js",
|
|
"js/simulation_item_ant_system.js",
|
|
"js/simulation_effects_system.js",
|
|
"js/simulation_creature_system.js",
|
|
"js/simulation_maintenance_system.js",
|
|
"js/simulation_ambient_system.js",
|
|
"js/simulation_systems.js",
|
|
"js/ui_input_shared.js",
|
|
"js/ui_input_touch.js",
|
|
"js/ui_input_mouse.js",
|
|
]
|
|
for rel in required:
|
|
if rel not in js_files:
|
|
fail(f"split runtime module missing from manifest: {rel}")
|
|
world_update = read_text(ROOT / "js/world_update.js")
|
|
ui_bind = read_text(ROOT / "js/ui_bind.js")
|
|
touch = read_text(ROOT / "js/ui_input_touch.js")
|
|
mouse = read_text(ROOT / "js/ui_input_mouse.js")
|
|
shared = read_text(ROOT / "js/ui_input_shared.js")
|
|
systems = read_text(ROOT / "js/simulation_systems.js")
|
|
if "TarinaiSimulation" not in world_update:
|
|
fail("World.update is not delegated to TarinaiSimulation")
|
|
for token in ["updateClock", "updateEnvironment", "updateItemsAndAnts", "updateCreatures", "runMaintenance", "spawnRainWater"]:
|
|
if token not in systems:
|
|
fail(f"world update phase missing: {token}")
|
|
if "touchstart" in ui_bind or "mousedown" in ui_bind or "touchmove" in ui_bind:
|
|
fail("ui_bind.js still owns field pointer input handlers")
|
|
for token in ["TarinaiUIInputShared", "createInputContext"]:
|
|
if token not in shared:
|
|
fail(f"shared input helper missing: {token}")
|
|
if "touchstart" not in touch or "TarinaiTouchInput" not in touch:
|
|
fail("touch input module missing touch handlers")
|
|
if "mousedown" not in mouse or "TarinaiMouseInput" not in mouse:
|
|
fail("mouse input module missing mouse handlers")
|
|
if ui_bind.count("syncToolSizeBadges();") != 1:
|
|
fail("ui_bind.js should bind/sync tool size badges only once")
|
|
phase_birth_direct = False
|
|
placement_birth_direct = "audio.birth" in read_text(ROOT / "js/world_placement_log.js")
|
|
family_birth_direct = "audio.birth" in read_text(ROOT / "js/world_family_social.js")
|
|
if phase_birth_direct or placement_birth_direct or family_birth_direct:
|
|
fail("birth log sound can double-fire; direct audio.birth remains in log-emitting birth flows")
|
|
ok("world phase split and UI input split present")
|
|
|
|
|
|
def check_processing_split() -> None:
|
|
data = manifest()
|
|
js_files = data["js"]
|
|
required = [
|
|
"js/item_type_initializers.js",
|
|
"js/item_render_runtime.js",
|
|
"js/tarinai_item_targeting.js",
|
|
"js/tarinai_action_definitions.js",
|
|
"js/tarinai_consumable_behavior.js",
|
|
"js/tarinai_social_action_runtime.js",
|
|
"js/tarinai_building_behavior.js",
|
|
]
|
|
for rel in required:
|
|
if rel not in js_files:
|
|
fail(f"processing split module missing from manifest: {rel}")
|
|
if not (js_files.index("js/items.js") < js_files.index("js/item_type_initializers.js") < js_files.index("js/item_render_runtime.js") < js_files.index("js/ants.js")):
|
|
fail("item init/render split load order is invalid")
|
|
if not (js_files.index("js/tarinai_forced_behavior.js") < js_files.index("js/tarinai_item_targeting.js") < js_files.index("js/tarinai_consumable_behavior.js") < js_files.index("js/tarinai_social_action_runtime.js") < js_files.index("js/tarinai_building_behavior.js") < js_files.index("js/tarinai_action_definitions.js") < js_files.index("js/tarinai_needs_items.js")):
|
|
fail("tarinai behavior split load order is invalid")
|
|
items = read_text(ROOT / "js/items.js")
|
|
temperature_system = read_text(ROOT / "js/world_temperature_system.js")
|
|
init = read_text(ROOT / "js/item_type_initializers.js")
|
|
item_render = read_text(ROOT / "js/item_render_runtime.js")
|
|
if "class Item" not in items or "function initializeItemTypeState" not in init or "Object.assign(Item.prototype" not in item_render:
|
|
fail("item construction/init/render split is incomplete")
|
|
if "updateDuplicator" in items or "\ndraw(ctx" in items or "if (type === \"grass\")" in items:
|
|
fail("items.js still owns type init, runtime, or render methods")
|
|
defs = read_text(ROOT / "js/tarinai_action_definitions.js")
|
|
consumable = read_text(ROOT / "js/tarinai_consumable_behavior.js")
|
|
social = read_text(ROOT / "js/tarinai_social_action_runtime.js")
|
|
building = read_text(ROOT / "js/tarinai_building_behavior.js")
|
|
needs = read_text(ROOT / "js/tarinai_needs_items.js")
|
|
required_pairs = [
|
|
(defs, "const TARINAI_ACTION_DEFINITIONS"),
|
|
(defs, "const TARINAI_ACTIONS"),
|
|
(consumable, "function updateConsumableBehavior"),
|
|
(social, "function updateMateBehavior"),
|
|
(social, "function updatePanicBehavior"),
|
|
(building, "function continueBuildPlan"),
|
|
]
|
|
for source, token in required_pairs:
|
|
if token not in source:
|
|
fail(f"processing split token missing: {token}")
|
|
for token in ["createConsumableActionSpec", "function updateConsumableBehavior", "function continueBuildPlan"]:
|
|
if token in needs:
|
|
fail(f"tarinai_needs_items.js still owns extracted processing: {token}")
|
|
ok("item and Tarinai processing split present")
|
|
|
|
|
|
def command_dispatcher_smoke() -> None:
|
|
source = read_text(ROOT / "js/command_dispatcher.js")
|
|
js = f"""
|
|
const vm = require('vm');
|
|
const context = {{ console, globalThis: null }};
|
|
context.globalThis = context;
|
|
context.window = context;
|
|
context.World = class World {{}};
|
|
context.scalableToolIds = () => ['grass', 'sweet'];
|
|
context.normalizedToolSizeFor = (world, tool) => (world.toolSizes && world.toolSizes[tool]) || world.toolSize || 'medium';
|
|
vm.createContext(context);
|
|
vm.runInContext({source!r}, context, {{ filename: 'command_dispatcher.js' }});
|
|
const events = [];
|
|
const world = new context.World();
|
|
world.tool = 'observe';
|
|
world.toolSize = 'medium';
|
|
world.toolSizes = {{}};
|
|
world.speed = 1;
|
|
world.paused = false;
|
|
world.w = 1000;
|
|
world.h = 700;
|
|
world.viewportW = 500;
|
|
world.viewportH = 300;
|
|
world.clampCamera = () => {{ world.clamped = true; }};
|
|
world.emit = (type, payload) => events.push([type, payload]);
|
|
world.handleClick = (x, y) => {{ world.clicked = [x, y]; }};
|
|
const target = {{ x: 420, y: 260, dead: false }};
|
|
if (!context.TarinaiCommands.dispatch(world, {{ type: 'tool.select', toolId: 'grass' }}).ok || world.tool !== 'grass') throw new Error('tool select failed');
|
|
if (!context.TarinaiCommands.dispatch(world, {{ type: 'tool.size.cycle', toolId: 'grass', order: ['small', 'medium', 'large'] }}).ok || world.toolSize !== 'large') throw new Error('tool size failed');
|
|
if (!context.TarinaiCommands.dispatch(world, {{ type: 'selection.focus', target }}).ok || world.selected !== target || world.cameraX !== 170 || world.cameraY !== 110) throw new Error('focus failed');
|
|
if (!context.TarinaiCommands.dispatch(world, {{ type: 'selection.favorite.toggle' }}).ok || target.favorite !== true) throw new Error('favorite failed');
|
|
if (!context.TarinaiCommands.dispatch(world, {{ type: 'simulation.paused.toggle' }}).ok || world.paused !== true) throw new Error('pause failed');
|
|
if (!context.TarinaiCommands.dispatch(world, {{ type: 'simulation.speed.cycle', speeds: [1, 2, 4] }}).ok || world.speed !== 2) throw new Error('speed failed');
|
|
if (!context.TarinaiCommands.dispatch(world, {{ type: 'pointer.primary', x: 7, y: 9 }}).ok || world.clicked[0] !== 7 || world.clicked[1] !== 9) throw new Error('pointer failed');
|
|
if (typeof world['dispatch' + 'Command'] !== 'undefined') throw new Error('World facade should not be installed');
|
|
if (!context.TarinaiCommands.dispatch(world, {{ type: 'selection.clear' }}).ok || world.selected !== null) throw new Error('selection clear failed');
|
|
if (!events.some(([type]) => type === 'selection:changed')) throw new Error('selection event 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("command dispatcher smoke test failed")
|
|
ok("command dispatcher smoke test passed")
|
|
|
|
|
|
def check_system_order_boundary() -> None:
|
|
data = manifest()
|
|
js_files = data["js"]
|
|
required = [
|
|
"js/simulation_runtime_helpers.js",
|
|
"js/simulation_environment_system.js",
|
|
"js/simulation_item_ant_system.js",
|
|
"js/simulation_effects_system.js",
|
|
"js/simulation_creature_system.js",
|
|
"js/simulation_maintenance_system.js",
|
|
"js/simulation_ambient_system.js",
|
|
"js/simulation_systems.js",
|
|
"js/system_order.js",
|
|
"js/simulation.js",
|
|
"js/world_update.js",
|
|
]
|
|
for rel in required:
|
|
if rel not in js_files:
|
|
fail(f"simulation boundary module missing from manifest: {rel}")
|
|
split_order = [
|
|
"js/simulation_runtime_helpers.js",
|
|
"js/simulation_environment_system.js",
|
|
"js/simulation_item_ant_system.js",
|
|
"js/simulation_effects_system.js",
|
|
"js/simulation_creature_system.js",
|
|
"js/simulation_maintenance_system.js",
|
|
"js/simulation_ambient_system.js",
|
|
"js/simulation_systems.js",
|
|
"js/system_order.js",
|
|
"js/simulation.js",
|
|
"js/world_update.js",
|
|
]
|
|
if [rel for rel in js_files if rel in split_order] != split_order:
|
|
fail("simulation boundary load order is invalid")
|
|
system_order = read_text(ROOT / "js/system_order.js")
|
|
simulation = read_text(ROOT / "js/simulation.js")
|
|
systems_source = read_text(ROOT / "js/simulation_systems.js")
|
|
if "TarinaiSimulationSystems" not in systems_source or "const phases = Object.freeze" not in systems_source:
|
|
fail("concrete simulation systems facade missing exported phase registry")
|
|
concrete_modules = {
|
|
"js/simulation_runtime_helpers.js": "TarinaiSimulationRuntime",
|
|
"js/simulation_environment_system.js": "TarinaiEnvironmentSimulationSystem",
|
|
"js/simulation_item_ant_system.js": "TarinaiItemAntSimulationSystem",
|
|
"js/simulation_effects_system.js": "TarinaiEffectsSimulationSystem",
|
|
"js/simulation_creature_system.js": "TarinaiCreatureSimulationSystem",
|
|
"js/simulation_maintenance_system.js": "TarinaiMaintenanceSimulationSystem",
|
|
"js/simulation_ambient_system.js": "TarinaiAmbientSimulationSystem",
|
|
}
|
|
for rel, token in concrete_modules.items():
|
|
if token not in read_text(ROOT / rel):
|
|
fail(f"concrete simulation split module missing export: {rel}")
|
|
for token in ["const SYSTEM_ORDER", "clock", "environment", "items_and_ants", "creatures", "maintenance", "diagnostics"]:
|
|
if token not in system_order:
|
|
fail(f"system order token missing: {token}")
|
|
if "TarinaiSystemOrder" not in simulation or "Object.freeze({ update })" not in simulation:
|
|
fail("simulation.js does not expose the update boundary")
|
|
ok("named simulation system order present")
|
|
|
|
|
|
def simulation_boundary_smoke() -> None:
|
|
sources = {rel: read_text(ROOT / rel) for rel in [
|
|
"js/simulation_runtime_helpers.js",
|
|
"js/simulation_environment_system.js",
|
|
"js/simulation_item_ant_system.js",
|
|
"js/simulation_effects_system.js",
|
|
"js/simulation_creature_system.js",
|
|
"js/simulation_maintenance_system.js",
|
|
"js/simulation_ambient_system.js",
|
|
"js/simulation_systems.js",
|
|
"js/system_order.js",
|
|
"js/simulation.js",
|
|
]}
|
|
js = f"""
|
|
const vm = require('vm');
|
|
const context = {{ console, globalThis: null }};
|
|
context.globalThis = context;
|
|
context.window = context;
|
|
vm.createContext(context);
|
|
for (const [filename, source] of Object.entries({sources!r})) {{
|
|
vm.runInContext(source, context, {{ filename }});
|
|
}}
|
|
if (!context.TarinaiSimulationSystems?.phases?.updateClock) throw new Error('simulation systems missing');
|
|
if (!context.TarinaiSystemOrder?.update) throw new Error('system order missing');
|
|
if (!context.TarinaiSimulation?.update) throw new Error('simulation facade 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("simulation boundary smoke test failed")
|
|
ok("simulation boundary smoke test passed")
|
|
|
|
|
|
|
|
def check_update_policy_boundaries() -> None:
|
|
data = manifest()
|
|
js_files = data["js"]
|
|
required = ["js/item_update_policy.js", "js/tarinai_update_policy.js"]
|
|
for rel in required:
|
|
if rel not in js_files:
|
|
fail(f"update policy boundary module missing from manifest: {rel}")
|
|
if not (js_files.index("js/item_lifecycle_support.js") < js_files.index("js/item_update_policy.js") < js_files.index("js/item_runtime.js")):
|
|
fail("item update policy load order is invalid")
|
|
if not (js_files.index("js/simulation_runtime_helpers.js") < js_files.index("js/tarinai_update_policy.js") < js_files.index("js/simulation_creature_system.js")):
|
|
fail("Tarinai update policy load order is invalid")
|
|
item_policy = read_text(ROOT / "js/item_update_policy.js")
|
|
tarinai_policy = read_text(ROOT / "js/tarinai_update_policy.js")
|
|
scheduler = read_text(ROOT / "js/item_update_scheduler.js")
|
|
sim_helpers = read_text(ROOT / "js/simulation_runtime_helpers.js")
|
|
creature_system = read_text(ROOT / "js/simulation_creature_system.js")
|
|
required_tokens = [
|
|
(item_policy, "global.TarinaiItemUpdatePolicy"),
|
|
(item_policy, "itemUpdateInterval"),
|
|
(item_policy, "forScheduledItems"),
|
|
(tarinai_policy, "global.TarinaiCreatureUpdatePolicy"),
|
|
(tarinai_policy, "updateCadence"),
|
|
(scheduler, "TarinaiItemUpdatePolicy"),
|
|
(sim_helpers, "TarinaiItemUpdatePolicy"),
|
|
(creature_system, "TarinaiCreatureUpdatePolicy"),
|
|
]
|
|
for source, token in required_tokens:
|
|
if token not in source:
|
|
fail(f"update policy boundary token missing: {token}")
|
|
duplicated_item_policy_sources = [scheduler, sim_helpers]
|
|
for source in duplicated_item_policy_sources:
|
|
if "const REALTIME_ITEM_TYPES" in source or "const PIN_ITEM_TYPES" in source or "SCHEDULED_ITEM_TYPES =" in source:
|
|
fail("item update policy constants are duplicated outside item_update_policy.js")
|
|
if "function tarinaiUpdateInterval" in creature_system:
|
|
fail("Tarinai update cadence still lives inside simulation_creature_system.js")
|
|
js = f"""
|
|
const vm = require('vm');
|
|
const fs = require('fs');
|
|
const context = {{ console, globalThis: null }};
|
|
context.globalThis = context;
|
|
context.window = context;
|
|
context.isServingFoodType = (type) => type === 'food';
|
|
context.TarinaiItemRuntime = {{
|
|
updateOne(item, dt) {{ item.runs = (item.runs || 0) + 1; item.lastDt = dt; return true; }}
|
|
}};
|
|
context.TarinaiPerf = {{ begin() {{ return () => undefined; }}, renderQualityTier() {{ return "normal"; }} }};
|
|
context.TarinaiPhysicsWorldSystem = {{ updateWorld() {{ return {{ mechanical: 0, constraintRan: 0, postConstraintPairs: 0 }}; }} }};
|
|
context.TarinaiInputMode = {{ snapshot() {{ return {{ touchFirst: false }}; }} }};
|
|
vm.createContext(context);
|
|
for (const file of ['js/item_update_policy.js', 'js/item_update_scheduler.js']) {{
|
|
vm.runInContext(fs.readFileSync(file, 'utf8'), context, {{ filename: file }});
|
|
}}
|
|
const world = {{ time: 0, items: [{{ type: 'ball' }}, {{ type: 'food' }}, {{ type: 'grass' }}], ensureItemBuckets() {{}}, itemBucketRebuildsTotal: 0 }};
|
|
if (context.TarinaiItemUpdateScheduler.run(world, 0.1) < 1) throw new Error('realtime item did not run');
|
|
world.time = 5.1;
|
|
if (context.TarinaiItemUpdateScheduler.run(world, 0.1) < 1) throw new Error('scheduled item did not run');
|
|
"""
|
|
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("update policy scheduler smoke test failed")
|
|
ok("update policy boundaries present")
|
|
|
|
|
|
def check_entity_runtime_boundary() -> None:
|
|
data = manifest()
|
|
js_files = data["js"]
|
|
required = ["js/item_runtime.js", "js/tarinai_runtime.js"]
|
|
for rel in required:
|
|
if rel not in js_files:
|
|
fail(f"entity runtime boundary module missing from manifest: {rel}")
|
|
if not (js_files.index("js/item_lifecycle_pipeline.js") < js_files.index("js/item_runtime.js") < js_files.index("js/structure_lifecycle.js")):
|
|
fail("item runtime boundary load order is invalid")
|
|
if not (js_files.index("js/tarinai_needs_items.js") < js_files.index("js/tarinai_social_move_life.js") < js_files.index("js/tarinai_runtime.js") < js_files.index("js/tarinai_render.js")):
|
|
fail("Tarinai runtime boundary load order is invalid")
|
|
item_runtime = read_text(ROOT / "js/item_runtime.js")
|
|
tarinai_runtime = read_text(ROOT / "js/tarinai_runtime.js")
|
|
item_scheduler = read_text(ROOT / "js/item_update_scheduler.js")
|
|
sim_helpers = read_text(ROOT / "js/simulation_runtime_helpers.js")
|
|
creature_system = read_text(ROOT / "js/simulation_creature_system.js")
|
|
required_tokens = [
|
|
(item_runtime, "global.TarinaiItemRuntime"),
|
|
(item_runtime, "installPrototypeBoundary"),
|
|
(item_runtime, "updateScheduled"),
|
|
(tarinai_runtime, "global.TarinaiCreatureRuntime"),
|
|
(item_scheduler, "TarinaiItemRuntime.updateOne"),
|
|
(sim_helpers, "TarinaiItemRuntime.updateScheduled"),
|
|
(creature_system, "TarinaiCreatureRuntime.updateOne"),
|
|
]
|
|
for source, token in required_tokens:
|
|
if token not in source:
|
|
fail(f"entity runtime boundary token missing: {token}")
|
|
ok("entity update runtime boundary present")
|
|
|
|
|
|
def check_tarinai_update_pipeline_boundary() -> None:
|
|
data = manifest()
|
|
js_files = data["js"]
|
|
required = [
|
|
"js/tarinai_sunbath_system.js",
|
|
"js/tarinai_cursor_care_system.js",
|
|
"js/tarinai_local_environment_system.js",
|
|
"js/tarinai_need_planner_system.js",
|
|
"js/tarinai_item_interaction_context.js",
|
|
"js/tarinai_food_interaction_system.js",
|
|
"js/tarinai_contact_item_system.js",
|
|
"js/tarinai_item_interaction_system.js",
|
|
"js/tarinai_update_step_frame.js",
|
|
"js/tarinai_update_step_ai.js",
|
|
"js/tarinai_update_step_environment.js",
|
|
"js/tarinai_update_step_movement.js",
|
|
"js/tarinai_update_step_health.js",
|
|
"js/update_step_pipeline_runner.js",
|
|
"js/tarinai_update_pipeline.js",
|
|
]
|
|
for rel in required:
|
|
if rel not in js_files:
|
|
fail(f"Tarinai update pipeline module missing from manifest: {rel}")
|
|
expected_order = [
|
|
"js/tarinai_needs_items.js",
|
|
"js/tarinai_need_planner_system.js",
|
|
"js/tarinai_item_interaction_context.js",
|
|
"js/tarinai_food_interaction_system.js",
|
|
"js/tarinai_contact_item_system.js",
|
|
"js/tarinai_item_interaction_system.js",
|
|
"js/tarinai_sunbath_system.js",
|
|
"js/tarinai_cursor_care_system.js",
|
|
"js/tarinai_local_environment_system.js",
|
|
"js/tarinai_social_move_life.js",
|
|
"js/tarinai_update_step_frame.js",
|
|
"js/tarinai_update_step_ai.js",
|
|
"js/tarinai_update_step_environment.js",
|
|
"js/tarinai_update_step_movement.js",
|
|
"js/tarinai_update_step_health.js",
|
|
"js/tarinai_update_pipeline.js",
|
|
"js/tarinai_runtime.js",
|
|
]
|
|
if [rel for rel in js_files if rel in expected_order] != expected_order:
|
|
fail("Tarinai update pipeline load order is invalid")
|
|
exports = {
|
|
"js/tarinai_sunbath_system.js": "global.TarinaiSunbathSystem",
|
|
"js/tarinai_cursor_care_system.js": "global.TarinaiCursorCareSystem",
|
|
"js/tarinai_local_environment_system.js": "global.TarinaiLocalEnvironmentSystem",
|
|
"js/tarinai_need_planner_system.js": "global.TarinaiNeedPlannerSystem",
|
|
"js/tarinai_item_interaction_context.js": "global.TarinaiItemInteractionContext",
|
|
"js/tarinai_food_interaction_system.js": "global.TarinaiFoodInteractionSystem",
|
|
"js/tarinai_contact_item_system.js": "global.TarinaiContactItemSystem",
|
|
"js/tarinai_item_interaction_system.js": "global.TarinaiItemInteractionSystem",
|
|
"js/tarinai_update_step_frame.js": "global.TarinaiFrameUpdateStep",
|
|
"js/tarinai_update_step_ai.js": "global.TarinaiAiUpdateStep",
|
|
"js/tarinai_update_step_environment.js": "global.TarinaiEnvironmentUpdateStep",
|
|
"js/tarinai_update_step_movement.js": "global.TarinaiMovementUpdateStep",
|
|
"js/tarinai_update_step_health.js": "global.TarinaiHealthUpdateStep",
|
|
"js/update_step_pipeline_runner.js": "global.TarinaiUpdateStepPipelineRunner",
|
|
"js/tarinai_update_pipeline.js": "global.TarinaiUpdatePipeline",
|
|
}
|
|
for rel, token in exports.items():
|
|
if token not in read_text(ROOT / rel):
|
|
fail(f"Tarinai update pipeline export missing: {rel}")
|
|
runtime = read_text(ROOT / "js/tarinai_runtime.js")
|
|
if "TarinaiUpdatePipeline?.updateOne" not in runtime and "TarinaiUpdatePipeline.updateOne" not in runtime:
|
|
fail("Tarinai runtime does not route through TarinaiUpdatePipeline")
|
|
|
|
needs_items = read_text(ROOT / "js/tarinai_needs_items.js")
|
|
sunbath_system = read_text(ROOT / "js/tarinai_sunbath_system.js")
|
|
cursor_system = read_text(ROOT / "js/tarinai_cursor_care_system.js")
|
|
local_env_system = read_text(ROOT / "js/tarinai_local_environment_system.js")
|
|
interaction_system = read_text(ROOT / "js/tarinai_item_interaction_system.js")
|
|
if " update(dt)" in needs_items or "const minute = dt / 60" in needs_items:
|
|
fail("Tarinai.prototype.update residue remains in needs mixin")
|
|
if "TarinaiItemInteractionSystem.updateOne" not in needs_items or "const foodNeed = Number(this.needRaw?.food" in needs_items:
|
|
fail("Tarinai.prototype.interactWithItems is not a thin facade over the interaction system")
|
|
# These subsystems are now routed by the split update steps rather than the prototype needs mixin.
|
|
interaction_context = read_text(ROOT / "js/tarinai_item_interaction_context.js")
|
|
food_interaction = read_text(ROOT / "js/tarinai_food_interaction_system.js")
|
|
contact_interaction = read_text(ROOT / "js/tarinai_contact_item_system.js")
|
|
for source, token in [
|
|
(sunbath_system, "this.sunbathFrameTimer"),
|
|
(cursor_system, "this.nextCursorHeartAt"),
|
|
(local_env_system, "grassComfort"),
|
|
(interaction_context, "const foodNeed = Number(tarinai.needRaw?.food"),
|
|
(food_interaction, "tarinai.applyParamItemEffect"),
|
|
(contact_interaction, "tarinai.applyWaterEffect(dt, it.type)"),
|
|
]:
|
|
if token not in source:
|
|
fail(f"Tarinai update subsystem missing extracted token: {token}")
|
|
if "TarinaiItemInteractionContext.create" not in interaction_system or "TarinaiFoodInteractionSystem.updateCandidate" not in interaction_system or "TarinaiContactItemSystem.updateCandidate" not in interaction_system:
|
|
fail("Tarinai item interaction orchestrator does not delegate to context/food/contact candidate subsystems")
|
|
if "for (const item of ctx.foodCandidates" not in interaction_system:
|
|
fail("Tarinai item interaction orchestrator does not preserve per-candidate interaction order")
|
|
if "TarinaiNeedPlannerSystem.updateOne" not in needs_items or "function resolveNeedsCore" not in needs_items:
|
|
fail("Tarinai resolveNeeds is not routed through planner system")
|
|
if "TarinaiNeedPlannerCoreSystem" not in needs_items:
|
|
fail("Tarinai need planner core system export missing")
|
|
if "TarinaiItemInteractionSystem.updateOne" not in read_text(ROOT / "js/tarinai_update_step_ai.js"):
|
|
fail("Tarinai AI step does not delegate item interactions to subsystem")
|
|
cursor_care_frame = read_text(ROOT / "js/tarinai_update_step_frame.js")
|
|
cursor_care_sim = read_text(ROOT / "js/simulation_creature_system.js")
|
|
if "TarinaiCursorCareSystem.updateOne" not in cursor_care_sim:
|
|
fail("Creature simulation does not delegate cursor care to subsystem")
|
|
if "TarinaiCursorCareSystem.updateOne" in cursor_care_frame:
|
|
fail("Cursor care is still duplicated inside the cadence-throttled Tarinai frame step")
|
|
if "TarinaiLocalEnvironmentSystem.updateOne" not in read_text(ROOT / "js/tarinai_update_step_environment.js"):
|
|
fail("Tarinai environment step does not delegate local environment to subsystem")
|
|
if "TarinaiSunbathSystem.updateOne" not in read_text(ROOT / "js/tarinai_update_step_movement.js"):
|
|
fail("Tarinai movement step does not delegate sunbath to subsystem")
|
|
|
|
pipeline = read_text(ROOT / "js/tarinai_update_pipeline.js")
|
|
for token in ["TarinaiFrameUpdateStep", "TarinaiAiUpdateStep", "TarinaiEnvironmentUpdateStep", "TarinaiMovementUpdateStep", "TarinaiHealthUpdateStep"]:
|
|
if token not in pipeline:
|
|
fail(f"Tarinai update pipeline missing step token: {token}")
|
|
js = f"""
|
|
const vm = require('vm');
|
|
const fs = require('fs');
|
|
const context = {{ console, globalThis: null }};
|
|
context.globalThis = context;
|
|
context.window = context;
|
|
context.CONFIG = {{ hungerPerMinute: 60, lonelinessPerMinute: 60, energyPerMinute: 60 }};
|
|
context.clamp = (v, a, b) => Math.max(a, Math.min(b, v));
|
|
context.rand = (a, b) => (a + b) / 2;
|
|
context.pick = (arr) => arr[0];
|
|
context.stableUnit = () => 0.5;
|
|
context.deterministicRange = (world, salt, min, max) => (min + max) / 2;
|
|
context.deterministicSigned = () => 0;
|
|
context.deterministicChance = () => false;
|
|
context.applyNeedShock = () => undefined;
|
|
context.applyNeedRelief = () => undefined;
|
|
context.calculateStressFromNeeds = () => 0;
|
|
context.applyGroundStressModifier = (t, v) => v;
|
|
context.createDefaultNeeds = () => ({{ food: 0, sleep: 0, health: 0, safety: 0, social: 0, fulfill: 0 }});
|
|
context.tarinaiMaxEnergy = () => 100;
|
|
context.tarinaiMetabolicHungerScale = () => 1;
|
|
vm.createContext(context);
|
|
for (const file of {required!r}) {{
|
|
vm.runInContext(fs.readFileSync(file, 'utf8'), context, {{ filename: file }});
|
|
}}
|
|
const calls = [];
|
|
const world = {{
|
|
time: 1,
|
|
groundType: 'soil',
|
|
weather: 'cloudy',
|
|
colonyMood: {{ id: 'relaxed' }},
|
|
selected: null,
|
|
spendScheduledWork() {{ return true; }},
|
|
temperatureAt() {{ return 0.62; }},
|
|
lightLevel() {{ return 0.5; }},
|
|
spawnBleedEffect() {{ calls.push('bleed'); }},
|
|
resolveFenceCollision() {{ calls.push('fence'); }},
|
|
}};
|
|
const t = {{
|
|
world, dead: false, x: 10, y: 12, vx: 0, vy: 0, radius: 20,
|
|
age: 0, reproductionTimer: 10, eatTimer: 0, eatCooldown: 0, foodReactTimer: 0,
|
|
surpriseTimer: 0, fearTimer: 0, intimidateTimer: 0, intimidatedTimer: 0, stress: 0,
|
|
hpBarTimer: 0, stressBarTimer: 0, loveMochiTimer: 0, fightMochiTimer: 0,
|
|
birthRitualTimer: 0, pokeFlashTimer: 0, cursorPetting: 0, pinchThoughtTimer: 0,
|
|
sunbathCooldown: 0, fightTimer: 0, fightCooldown: 0, defeatedTimer: 0, stretchTimer: 0,
|
|
grassEatTimer: 0, hurtTimer: 0, fallTimer: 0, tempTimer: 0, tempComfort: 0.62,
|
|
hunger: 10, loneliness: 0, energy: 100, trait: {{ hunger: 1, social: 1, sleep: 1 }},
|
|
state: 'idle', aiTimer: 0, envTimer: 0, needs: context.createDefaultNeeds(), needRaw: context.createDefaultNeeds(),
|
|
updateZunchiDisease() {{ calls.push('zunchiDisease'); }},
|
|
updateSpecialDiseases() {{ calls.push('specialDisease'); }},
|
|
freezeSleepDiseaseMotion() {{ return false; }},
|
|
personalityProfile() {{ return {{ fear: 1 }}; }},
|
|
updateFacing() {{ calls.push('facing'); }},
|
|
maintainTargetProgress() {{ calls.push('targetProgress'); }},
|
|
move() {{ calls.push('move'); }},
|
|
updateNestBoxPresence() {{ calls.push('nestBox'); }},
|
|
checkLife() {{ calls.push('checkLife'); }},
|
|
}};
|
|
if (!context.TarinaiUpdatePipeline.updateOne(t, 0.016, {{ legacyUpdate() {{ throw new Error('legacy should not run'); }} }})) throw new Error('pipeline update failed');
|
|
for (const expected of ['zunchiDisease', 'specialDisease', 'facing', 'targetProgress', 'move', 'nestBox', 'fence', 'checkLife']) {{
|
|
if (!calls.includes(expected)) throw new Error('missing pipeline call: ' + expected);
|
|
}}
|
|
"""
|
|
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("Tarinai update pipeline smoke test failed")
|
|
ok("Tarinai update pipeline boundary present")
|
|
|
|
|
|
|
|
def check_item_lifecycle_pipeline_boundary() -> None:
|
|
data = manifest()
|
|
js_files = data["js"]
|
|
dynamic_required = [
|
|
"js/item_dynamic_tool_system.js",
|
|
"js/item_dynamic_ball_system.js",
|
|
"js/item_dynamic_duplicator_system.js",
|
|
"js/item_dynamic_pin_system.js",
|
|
"js/item_dynamic_zunchi_system.js",
|
|
"js/item_dynamic_system.js",
|
|
]
|
|
required = [
|
|
"js/item_lifecycle_support.js",
|
|
"js/physics_helpers.js",
|
|
"js/pin_attachment_system.js",
|
|
*dynamic_required,
|
|
"js/item_lifecycle_decay_system.js",
|
|
"js/item_lifecycle_growth_system.js",
|
|
"js/item_lifecycle_step_frame.js",
|
|
"js/item_lifecycle_step_decay.js",
|
|
"js/item_lifecycle_step_dynamic.js",
|
|
"js/item_lifecycle_step_growth.js",
|
|
"js/update_step_pipeline_runner.js",
|
|
"js/item_lifecycle_pipeline.js",
|
|
]
|
|
for rel in required:
|
|
if rel not in js_files:
|
|
fail(f"Item lifecycle pipeline module missing from manifest: {rel}")
|
|
expected_order = [
|
|
"js/physics_helpers.js",
|
|
"js/pin_attachment_system.js",
|
|
"js/item_lifecycle_support.js",
|
|
"js/item_update_policy.js",
|
|
*dynamic_required,
|
|
"js/item_lifecycle_decay_system.js",
|
|
"js/item_lifecycle_growth_system.js",
|
|
"js/item_lifecycle_step_frame.js",
|
|
"js/item_lifecycle_step_decay.js",
|
|
"js/item_lifecycle_step_dynamic.js",
|
|
"js/item_lifecycle_step_growth.js",
|
|
"js/update_step_pipeline_runner.js",
|
|
"js/item_lifecycle_pipeline.js",
|
|
"js/item_runtime.js",
|
|
]
|
|
if [rel for rel in js_files if rel in expected_order] != expected_order:
|
|
fail("Item lifecycle pipeline load order is invalid")
|
|
exports = {
|
|
"js/item_lifecycle_support.js": "global.TarinaiItemLifecycleRuntimeSupport",
|
|
"js/item_dynamic_tool_system.js": "global.TarinaiItemDynamicToolSystem",
|
|
"js/item_dynamic_ball_system.js": "global.TarinaiItemDynamicBallSystem",
|
|
"js/item_dynamic_duplicator_system.js": "global.TarinaiItemDynamicDuplicatorSystem",
|
|
"js/item_dynamic_pin_system.js": "global.TarinaiItemDynamicPinSystem",
|
|
"js/item_dynamic_zunchi_system.js": "global.TarinaiItemDynamicZunchiSystem",
|
|
"js/item_dynamic_system.js": "global.TarinaiItemDynamicSystem",
|
|
"js/item_lifecycle_decay_system.js": "global.TarinaiItemDecaySystem",
|
|
"js/item_lifecycle_growth_system.js": "global.TarinaiItemGrowthSystem",
|
|
"js/item_lifecycle_step_frame.js": "global.TarinaiItemFrameLifecycleStep",
|
|
"js/item_lifecycle_step_decay.js": "global.TarinaiItemDecayLifecycleStep",
|
|
"js/item_lifecycle_step_dynamic.js": "global.TarinaiItemDynamicLifecycleStep",
|
|
"js/item_lifecycle_step_growth.js": "global.TarinaiItemGrowthLifecycleStep",
|
|
"js/update_step_pipeline_runner.js": "global.TarinaiUpdateStepPipelineRunner",
|
|
"js/item_lifecycle_pipeline.js": "global.TarinaiItemLifecyclePipeline",
|
|
}
|
|
for rel, token in exports.items():
|
|
if token not in read_text(ROOT / rel):
|
|
fail(f"Item lifecycle pipeline export missing: {rel}")
|
|
runtime = read_text(ROOT / "js/item_runtime.js")
|
|
if "TarinaiItemLifecyclePipeline?.updateOne" not in runtime and "TarinaiItemLifecyclePipeline.updateOne" not in runtime:
|
|
fail("item runtime does not route through TarinaiItemLifecyclePipeline")
|
|
pipeline = read_text(ROOT / "js/item_lifecycle_pipeline.js")
|
|
for token in ["TarinaiItemFrameLifecycleStep", "TarinaiItemDecayLifecycleStep", "TarinaiItemDynamicLifecycleStep", "TarinaiItemGrowthLifecycleStep"]:
|
|
if token not in pipeline:
|
|
fail(f"Item lifecycle pipeline missing step token: {token}")
|
|
dynamic_step = read_text(ROOT / "js/item_lifecycle_step_dynamic.js")
|
|
dynamic_system = read_text(ROOT / "js/item_dynamic_system.js")
|
|
if "TarinaiItemDynamicSystem.update" not in dynamic_step:
|
|
fail("Item dynamic lifecycle step does not delegate to TarinaiItemDynamicSystem")
|
|
for token in ["TarinaiItemDynamicToolSystem", "TarinaiItemDynamicBallSystem", "TarinaiItemDynamicDuplicatorSystem", "TarinaiItemDynamicPinSystem", "TarinaiItemDynamicZunchiSystem"]:
|
|
if token not in dynamic_system:
|
|
fail(f"Item dynamic system missing subsystem token: {token}")
|
|
for rel in ["js/item_dynamic_tool_system.js", "js/item_dynamic_ball_system.js", "js/item_dynamic_duplicator_system.js", "js/item_dynamic_pin_system.js", "js/item_dynamic_zunchi_system.js"]:
|
|
body = read_text(ROOT / rel)
|
|
for token in ["item.updateFan", "item.updateRotator", "item.updateBall", "item.updateDuplicator", "item.updatePushpin", "item.updateZunchiMotion", "item.updateZunchi"]:
|
|
if token in body:
|
|
fail(f"dynamic subsystem still delegates to prototype method {token} in {rel}")
|
|
lifecycle_support = read_text(ROOT / "js/item_lifecycle_support.js")
|
|
lifecycle_decay = read_text(ROOT / "js/item_lifecycle_step_decay.js")
|
|
lifecycle_growth = read_text(ROOT / "js/item_lifecycle_step_growth.js")
|
|
if "TarinaiItemLifecycleRuntimeSupport" not in lifecycle_support:
|
|
fail("item lifecycle support export missing")
|
|
if "TarinaiDuplicatorRuntime" in lifecycle_support:
|
|
fail("duplicate duplicator runtime export still present")
|
|
if "TarinaiItemDecaySystem.update" not in lifecycle_decay or "TarinaiItemGrowthSystem.update" not in lifecycle_growth:
|
|
fail("item lifecycle decay/growth steps are not thin system adapters")
|
|
js = f"""
|
|
const vm = require('vm');
|
|
const fs = require('fs');
|
|
const context = {{ console, globalThis: null }};
|
|
context.globalThis = context;
|
|
context.window = context;
|
|
context.isServingFoodType = (type) => type === 'food';
|
|
context.passiveFoodDecayInterval = () => 0.5;
|
|
context.passiveFoodDecayRate = () => 0.1;
|
|
context.updateServingFoodVisualSize = (item) => {{ item.r = (item.r || 12) - 0.1; return true; }};
|
|
context.TarinaiItemRegistry = {{ food: {{ }} }};
|
|
context.isPinType = (type) => type === 'pushpin';
|
|
context.normalizeGrassStage = (item) => {{ item.normalized = true; }};
|
|
context.stableUnit = () => 0.5;
|
|
vm.createContext(context);
|
|
for (const file of {required!r}) {{
|
|
vm.runInContext(fs.readFileSync(file, 'utf8'), context, {{ filename: file }});
|
|
}}
|
|
const events = [];
|
|
const world = {{
|
|
itemDropImpact(item) {{ events.push(['drop', item.type]); }},
|
|
markTerrainDirty(reason) {{ events.push(['terrain', reason]); }},
|
|
markItemBucketsDirty(reason) {{ events.push(['buckets', reason]); }},
|
|
markSpatialDirty(reason) {{ events.push(['spatial', reason]); }},
|
|
emit(type, payload) {{ events.push(['emit', type, payload.type]); }},
|
|
}};
|
|
const food = {{ type: 'food', dead: false, age: 0, dropTimer: 0.1, dropImpactDone: false, foodServingsRemaining: 1, amount: 1, passiveFoodDecayTimer: 0 }};
|
|
if (!context.TarinaiItemLifecyclePipeline.updateOne(food, 0.6, world, {{ legacyUpdate() {{ throw new Error('legacy should not run'); }} }})) throw new Error('food update failed');
|
|
if (food.age !== 0.6 || !food.dropImpactDone || !(food.foodServingsRemaining < 1) || !events.some(([kind, value]) => kind === 'buckets' && value === 'food-passive-resize') || !events.some(([kind, value]) => kind === 'spatial' && value === 'food-passive-resize')) throw new Error('frame/decay step did not run');
|
|
context.CONFIG = {{ worldPadding: 30 }};
|
|
context.clamp = (v, min, max) => Math.max(min, Math.min(max, v));
|
|
context.distXY = (ax, ay, bx, by) => Math.hypot(ax - bx, ay - by);
|
|
const dynamic = {{
|
|
type: 'ball', dead: false, age: 0, x: 80, y: 80, prevX: 80, prevY: 80,
|
|
vx: 12, vy: 0, r: 18, spin: 0, spinVelocity: 0
|
|
}};
|
|
const ballWorld = {{ ...world, w: 500, h: 500, nearbyItems() {{ return []; }} }};
|
|
context.TarinaiItemLifecyclePipeline.updateOne(dynamic, 0.25, ballWorld, {{ legacyUpdate() {{ throw new Error('legacy should not run'); }} }});
|
|
if (!(dynamic.x > 80) || !(dynamic.spin > 0)) throw new Error('dynamic step did not run');
|
|
const grass = {{ type: 'grass', dead: false, age: 0 }};
|
|
context.TarinaiItemLifecyclePipeline.updateOne(grass, 0.1, world, {{ legacyUpdate() {{ throw new Error('legacy should not run'); }} }});
|
|
if (grass.normalized) throw new Error('grass should not be normalized by per-item growth step');
|
|
"""
|
|
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("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;
|
|
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/placement_preview_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'); }}
|
|
}}
|
|
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);
|
|
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`);
|
|
}}
|
|
|
|
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)
|
|
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("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")
|
|
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 *= 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.82" 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}")
|
|
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: 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: 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');
|
|
}})().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 = 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:
|
|
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; }},
|
|
syncPoseFromItem() {{ 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 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 check_electrocution_gate_chart_and_ascii() -> None:
|
|
health = read_text(ROOT / "js/health.js")
|
|
combat_effects = read_text(ROOT / "js/world_combat_effects.js")
|
|
action_state = read_text(ROOT / "js/tarinai_action_state.js")
|
|
signal = read_text(ROOT / "js/signal_system.js")
|
|
charts = read_text(ROOT / "js/ui_charts.js")
|
|
bind = read_text(ROOT / "js/ui_bind.js")
|
|
panel = read_text(ROOT / "css/panel.css")
|
|
|
|
if '\\u611f\\u96fb' not in health or '\\u901a\\u96fb\\u4e2d\\u306e\\u96fb\\u7dda' not in health:
|
|
fail("electrocution cause normalization is missing")
|
|
if '"gate_fence"' not in signal or 'node.gateOpen = nextOpen' not in signal or 'signal-gate-toggle' not in signal:
|
|
fail("gate fence signal output handling is missing")
|
|
if '<button type="button" class="chart-series-toggle' not in charts or 'data-series-key="${s.key}"' not in charts:
|
|
fail("chart legend text and icon are not wrapped by a shared button")
|
|
if 'closest?.("[data-series-key]")' not in bind or 'i[data-series-key]' in bind:
|
|
fail("chart legend click delegation still targets only the icon")
|
|
if '.colony-chart-legend .chart-series-toggle' not in panel:
|
|
fail("chart legend button styling is missing")
|
|
|
|
runtime = f"""
|
|
const vm = require('vm');
|
|
const context = {{
|
|
console, globalThis: null, window: null,
|
|
clamp: (v, lo, hi) => Math.max(lo, Math.min(hi, Number(v) || 0)),
|
|
TarinaiGeometry: {{
|
|
pointSegmentDistance(px, py, ax, ay, bx, by) {{
|
|
const sx = bx - ax, sy = by - ay;
|
|
const len2 = sx * sx + sy * sy;
|
|
if (len2 <= 1e-12) return Math.hypot(px - ax, py - ay);
|
|
const t = Math.max(0, Math.min(1, ((px - ax) * sx + (py - ay) * sy) / len2));
|
|
return Math.hypot(px - (ax + sx * t), py - (ay + sy * t));
|
|
}},
|
|
}},
|
|
audio: {{ damage() {{}}, play() {{}}, death() {{}} }},
|
|
}};
|
|
context.globalThis = context;
|
|
context.window = context;
|
|
vm.createContext(context);
|
|
vm.runInContext({health!r}, context, {{ filename: 'health.js' }});
|
|
const victim = {{
|
|
energy: 2, maxEnergy: 100, world: {{ time: 10, items: [], effects: [], effectCounts: {{}}, ownedItemsFor() {{ return []; }} }},
|
|
recentDamageWindow: [], nextDamageZunchiAt: Infinity, hunger: 0, stress: 0, mood: 100,
|
|
showHpBar() {{}},
|
|
die(reason, opts) {{ this.deathReason = context.HEALTH.normalizeDeathReason(this, reason, opts); }},
|
|
}};
|
|
context.HEALTH.applyDamage(victim, 2.4, '\u901a\u96fb\u4e2d\u306e\u96fb\u7dda\u306b\u89e6\u308c\u305f');
|
|
if (victim.deathReason !== '\u611f\u96fb') throw new Error(`electrocution death cause mismatch: ${{victim.deathReason}}`);
|
|
|
|
context.TarinaiPhysicsBodySystem = {{ endpoint(item, index) {{ return item.physicsConstraint.endpoints[index]; }} }};
|
|
context.TarinaiLinkRuntime = {{ endpointWorld(endpoint) {{ return {{ x: endpoint.x || 0, y: endpoint.y || 0 }}; }} }};
|
|
context.TarinaiEffect = function(type, x, y, opts) {{ Object.assign(this, {{ type, x, y }}, opts); }};
|
|
vm.runInContext({signal!r}, context, {{ filename: 'signal_system.js' }});
|
|
const pressure = {{ id: 'pressure', type: 'pressure_switch', x: 0, y: 0, pressureThreshold: 1, pressureWidth: 100, pressureHeight: 100 }};
|
|
const gate = {{ id: 'gate', type: 'gate_fence', x: 80, y: 0, gateOpen: false }};
|
|
const wire = {{
|
|
id: 'wire', type: 'wire', x: 40, y: 0, r: 40,
|
|
physicsConstraint: {{ endpoints: [
|
|
{{ kind: 'item', id: 'pressure', x: 0, y: 0 }},
|
|
{{ kind: 'item', id: 'gate', x: 80, y: 0 }},
|
|
] }},
|
|
}};
|
|
let occupied = true;
|
|
const world = {{
|
|
items: [pressure, gate, wire], tarinai: [], ants: [], effects: [], time: 1, spatialMarks: 0, drawListDirty: false,
|
|
itemsOfType(type) {{ return this.items.filter(item => item.type === type); }},
|
|
itemById(id) {{ return this.items.find(item => item.id === id) || null; }},
|
|
nearbyTarinai() {{ return occupied ? [{{ x: 0, y: 0, radius: 10, dead: false }}] : []; }},
|
|
nearbyAnts() {{ return []; }},
|
|
isTarinaiHiddenInNestBox() {{ return false; }},
|
|
markSpatialDirty(reason) {{ if (reason === 'signal-gate-toggle') this.spatialMarks += 1; }},
|
|
}};
|
|
context.TarinaiSignalSystem.updateWorld(world);
|
|
if (!gate.gateOpen || !gate._signalDriven || !gate._signalOn || world.spatialMarks !== 1) throw new Error('powered gate did not open');
|
|
occupied = false;
|
|
world.time += 1;
|
|
context.TarinaiSignalSystem.updateWorld(world);
|
|
if (gate.gateOpen || !gate._signalDriven || gate._signalOn || world.spatialMarks !== 2) throw new Error('unpowered gate did not close');
|
|
context.TarinaiSignalSystem.updateWorld(world);
|
|
if (world.spatialMarks !== 2) throw new Error('unchanged gate state dirtied spatial cache again');
|
|
"""
|
|
with tempfile.NamedTemporaryFile("w", encoding="utf-8", suffix=".js", delete=False) as f:
|
|
f.write(runtime)
|
|
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("electrocution/gate signal runtime smoke test failed")
|
|
|
|
ascii_paths = [*(ROOT / "js").glob("*.js"), ROOT / "service-worker.js", ROOT / "index.html"]
|
|
raw = []
|
|
for path in ascii_paths:
|
|
body = read_text(path)
|
|
if any(ord(ch) > 127 for ch in body):
|
|
raw.append(path.relative_to(ROOT).as_posix())
|
|
if raw:
|
|
fail("unescaped non-ASCII characters remain in application source: " + ", ".join(raw))
|
|
ok("electrocution cause, powered gate, chart legend buttons, and ASCII-only application source verified")
|
|
|
|
|
|
def check_connection_overlay_and_time_detector() -> None:
|
|
placement = read_text(ROOT / "js/world_placement_log.js")
|
|
signal = read_text(ROOT / "js/signal_system.js")
|
|
snapshot = read_text(ROOT / "js/snapshot_system.js")
|
|
styles = read_text(ROOT / "css/base.css")
|
|
render = read_text(ROOT / "js/render.js")
|
|
|
|
if 'const CONNECTION_ITEM_TYPES = new Set(["rope", "rod", "spring", "wire", "insulated_wire"])' not in placement:
|
|
fail("connection item grab exclusion set is missing")
|
|
if 'CONNECTION_ITEM_TYPES.has(it.type)' not in placement:
|
|
fail("connection items can still become pinch hover/grab targets")
|
|
if '<option value="time">\\u6642\\u523b</option>' not in placement:
|
|
fail("detector editor time target option is missing")
|
|
for control_id in ("pressureSwitchTimeStart", "pressureSwitchTimeEnd", "pressureSwitchTimeTrack"):
|
|
if control_id not in placement:
|
|
fail(f"detector dual time slider is missing {control_id}")
|
|
if '.pressure-time-thumb-start' not in styles or '.pressure-time-thumb-end' not in styles:
|
|
fail("detector dual-thumb slider styling is missing")
|
|
if 'const targets = ["tarinai", "item", "time", "hunger_avg"' not in snapshot or 'item.pressureTimeStart' not in snapshot or 'item.pressureTimeEnd' not in snapshot:
|
|
fail("expanded detector snapshot persistence is missing")
|
|
if 'data.pressureTarget = item.pressureTarget || "tarinai"' not in placement or 'targets.has(data.pressureTarget)' not in placement or 'data.pressureMin' not in placement or 'data.pressureMax' not in placement:
|
|
fail("expanded detector copy persistence is missing")
|
|
if 'item.pressureTarget === "time" || item.pressureTarget === "temperature"' not in render:
|
|
fail("non-spatial detectors still render a detection rectangle")
|
|
removed = ["bio_sensor", "power_relay", "automationItemEditor"]
|
|
for token in removed:
|
|
if token in placement or token in snapshot or token in signal:
|
|
fail(f"removed automation item remains in runtime source: {token}")
|
|
|
|
runtime = f"""
|
|
const vm = require('vm');
|
|
const context = {{ console, globalThis: null, window: null, CONFIG: {{ dayLength: 120 }} }};
|
|
context.globalThis = context;
|
|
context.window = context;
|
|
context.TarinaiEffect = function() {{}};
|
|
vm.createContext(context);
|
|
vm.runInContext({signal!r}, context, {{ filename: 'signal_system.js' }});
|
|
const detector = {{
|
|
id: 'clock', type: 'pressure_switch', pressureTarget: 'time',
|
|
pressureTimeStart: 1320, pressureTimeEnd: 360,
|
|
pressureThreshold: 1, pressureWidth: 220, pressureHeight: 160,
|
|
signalActive: false,
|
|
}};
|
|
let minute = 1380;
|
|
const world = {{
|
|
items: [detector], tarinai: [], ants: [], effects: [], drawListDirty: false,
|
|
itemsOfType(type) {{ return this.items.filter(item => item.type === type); }},
|
|
dayProgress() {{ return minute / 1440; }},
|
|
nearbyTarinai() {{ return []; }}, nearbyAnts() {{ return []; }},
|
|
isTarinaiHiddenInNestBox() {{ return false; }}, markSpatialDirty() {{}},
|
|
}};
|
|
context.TarinaiSignalSystem.updateWorld(world);
|
|
if (!detector.signalActive) throw new Error('overnight range inactive at 23:00');
|
|
minute = 355;
|
|
context.TarinaiSignalSystem.updateWorld(world);
|
|
if (!detector.signalActive) throw new Error('overnight range inactive before 06:00');
|
|
minute = 720;
|
|
context.TarinaiSignalSystem.updateWorld(world);
|
|
if (detector.signalActive) throw new Error('overnight range active at noon');
|
|
detector.pressureTimeStart = 480;
|
|
detector.pressureTimeEnd = 480;
|
|
context.TarinaiSignalSystem.updateWorld(world);
|
|
if (!detector.signalActive) throw new Error('equal time handles must mean all day');
|
|
if (!context.TarinaiSignalSystem.isMinuteInRange(600, 480, 720)) throw new Error('daytime range helper rejected inside time');
|
|
if (context.TarinaiSignalSystem.isMinuteInRange(800, 480, 720)) throw new Error('daytime range helper accepted outside time');
|
|
"""
|
|
with tempfile.NamedTemporaryFile("w", encoding="utf-8", suffix=".js", delete=False) as f:
|
|
f.write(runtime)
|
|
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("time detector runtime smoke test failed")
|
|
ok("connection pinch overlay exclusion and dual-range time detector verified")
|
|
|
|
|
|
|
|
def check_link_dependency_and_plushie_recovery() -> None:
|
|
placement = read_text(ROOT / "js/world_placement_log.js")
|
|
tools = read_text(ROOT / "js/item_tool_definitions.js")
|
|
lifecycle = read_text(ROOT / "js/structure_lifecycle.js")
|
|
maintenance = read_text(ROOT / "js/simulation_maintenance_system.js")
|
|
styles = read_text(ROOT / "css/base.css")
|
|
|
|
if 'pressure-time-hint' in placement or 'pressure-time-hint' in styles or '\\u958b\\u59cb\\u304c\\u7d42\\u4e86\\u3088\\u308a\\u5f8c' in placement:
|
|
fail("detector overnight/all-day explanatory hint still exists")
|
|
expected_tooltip = 'tooltip: "\\u69d8\\u3005\\u306a\\u72b6\\u6cc1\\u3092\\u691c\\u77e5\\u3057\\u3066\\u96fb\\u7dda\\u306b\\u4fe1\\u53f7\\u3092\\u9001\\u308b\\u3002\\u30af\\u30ea\\u30c3\\u30af\\u3067\\u8a73\\u7d30\\u8a2d\\u5b9a\\u3002"'
|
|
if expected_tooltip not in tools:
|
|
fail("detector tooltip does not match the requested description")
|
|
if 'maintainDependencies?.(worldRef)' not in maintenance:
|
|
fail("link/plushie dependency maintenance is not called each simulation frame")
|
|
for token in ('function removeOrphanedLinks', 'function reconcilePlushies', 'maintainDependencies,'):
|
|
if token not in lifecycle:
|
|
fail(f"dependency lifecycle implementation missing: {token}")
|
|
|
|
runtime = f"""
|
|
const vm = require('vm');
|
|
const context = {{ console, globalThis: null, window: null }};
|
|
context.globalThis = context;
|
|
context.window = context;
|
|
context.TarinaiPhysicsBodySystem = {{ endpoint(item, index) {{ return item.physicsConstraint?.endpoints?.[index] || null; }} }};
|
|
vm.createContext(context);
|
|
vm.runInContext({lifecycle!r}, context, {{ filename: 'structure_lifecycle.js' }});
|
|
const item = (id, type = 'stone') => ({{ id, type, amount: 1, hp: 1, dead: false, x: 0, y: 0 }});
|
|
const link = (id, type, a, b) => ({{ id, type, amount: 999, hp: 999, dead: false, physicsConstraint: {{ endpoints: [a, b] }} }});
|
|
const supportA = item('support-a');
|
|
const supportB = item('support-b');
|
|
const supportC = item('support-c');
|
|
const intact = link('intact', 'rod', {{ kind: 'item', id: 'support-b' }}, {{ kind: 'item', id: 'support-c' }});
|
|
const orphan = link('orphan', 'rope', {{ kind: 'item', id: 'support-a' }}, {{ kind: 'item', id: 'support-b' }});
|
|
const dependent = link('dependent', 'wire', {{ kind: 'item', id: 'orphan' }}, {{ kind: 'item', id: 'support-c' }});
|
|
const deadTarinai = {{ id: 'dead-t', liveToken: 9, dead: true, x: 0, y: 0 }};
|
|
const tarinaiLink = link('tarinai-link', 'spring', {{ kind: 'tarinai', id: 'dead-t', liveToken: 9 }}, {{ kind: 'item', id: 'support-b' }});
|
|
const deadAnt = {{ id: 'dead-ant', dead: true, x: 0, y: 0 }};
|
|
const antLink = link('ant-link', 'insulated_wire', {{ kind: 'ant', id: 'dead-ant' }}, {{ kind: 'item', id: 'support-b' }});
|
|
const owner = {{ id: 'owner', dead: false, x: 120, y: 90, radius: 20 }};
|
|
const restoredPlushie = {{ id: 'plush-restored', type: 'plushie', isStructure: true, ownerId: 'owner', carriedById: '', onHead: false, amount: 1, hp: 1, dead: false, x: 10, y: 20, vx: 8, vy: 4, spinVelocity: 3 }};
|
|
const flyingPlushie = {{ id: 'plush-flying', type: 'plushie', isStructure: true, ownerId: 'owner', carriedById: '', onHead: false, amount: 1, hp: 1, dead: false, x: 30, y: 40, plushieFlingTimer: 1.2 }};
|
|
const orphanPlushie = {{ id: 'plush-orphan', type: 'plushie', isStructure: true, ownerId: 'missing-owner', carriedById: '', onHead: false, amount: 1, hp: 1, dead: false, x: 50, y: 60 }};
|
|
const world = {{
|
|
items: [supportA, supportB, supportC, intact, orphan, dependent, tarinaiLink, antLink, restoredPlushie, flyingPlushie, orphanPlushie],
|
|
tarinai: [owner, deadTarinai], ants: [deadAnt], pendingLinkEndpoint: {{ kind: 'item', id: 'support-a' }},
|
|
itemById(id) {{ return this.items.find(value => value.id === id) || null; }},
|
|
itemsOfType(type) {{ return this.items.filter(value => value.type === type); }},
|
|
liveTarinaiById(id, token = null) {{ return this.tarinai.find(value => !value.dead && value.id === id && (token == null || value.liveToken === token)) || null; }},
|
|
markItemBucketsDirty() {{ this.bucketDirty = true; }}, markSpatialDirty() {{ this.spatialDirty = true; }},
|
|
}};
|
|
supportA.amount = 0;
|
|
const result = context.TarinaiStructureLifecycle.maintainDependencies(world);
|
|
if (result.linksRemoved !== 4) throw new Error(`unexpected removed link count: ${{result.linksRemoved}}`);
|
|
if (orphan.amount !== 0 || dependent.amount !== 0 || tarinaiLink.amount !== 0 || antLink.amount !== 0) throw new Error('orphaned links were not removed');
|
|
if (intact.amount <= 0) throw new Error('intact link was removed');
|
|
if (world.pendingLinkEndpoint !== null) throw new Error('pending endpoint survived support removal');
|
|
if (restoredPlushie.carriedById !== owner.id || !restoredPlushie.onHead) throw new Error('detached owned plushie was not restored');
|
|
if (restoredPlushie.x !== owner.x || restoredPlushie.y !== owner.y - 22 || restoredPlushie.vx !== 0 || restoredPlushie.vy !== 0) throw new Error('restored plushie pose was not normalized');
|
|
if (flyingPlushie.carriedById || flyingPlushie.onHead || flyingPlushie.dead) throw new Error('active flying plushie was incorrectly reclaimed');
|
|
if (!orphanPlushie.dead || orphanPlushie.amount !== 0) throw new Error('ownerless plushie remained on the ground');
|
|
if (!world.bucketDirty || !world.spatialDirty) throw new Error('dependency repair did not invalidate world caches');
|
|
"""
|
|
with tempfile.NamedTemporaryFile("w", encoding="utf-8", suffix=".js", delete=False) as f:
|
|
f.write(runtime)
|
|
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("link dependency/plushie recovery runtime smoke test failed")
|
|
ok("detector copy, orphan-link cascade, and plushie recovery verified")
|
|
|
|
|
|
def check_splat_link_target_and_appetite_label() -> None:
|
|
tools = read_text(ROOT / "js/world_tool_actions.js")
|
|
needs = read_text(ROOT / "js/tarinai_needs_core.js")
|
|
charts = read_text(ROOT / "js/ui_charts.js")
|
|
|
|
if 'if (!it || it.dead || it.type === "splat") continue;' not in tools:
|
|
fail("transient splat items are still eligible as connection endpoints")
|
|
appetite = "\\u98df\\u6b32"
|
|
feeding = "\\u6442\\u990c"
|
|
if f'food: "{appetite}"' not in needs:
|
|
fail("food need label was not changed to appetite")
|
|
if f'key: "need_food", label: "{appetite}"' not in charts:
|
|
fail("food need chart label was not changed to appetite")
|
|
if f'food: "{feeding}"' in needs or f'key: "need_food", label: "{feeding}"' in charts:
|
|
fail("legacy feeding need label remains in a user-facing need label")
|
|
|
|
runtime = f"""
|
|
const vm = require('vm');
|
|
class World {{}}
|
|
const context = {{
|
|
console, World, globalThis: null, window: null,
|
|
distXY(x1, y1, x2, y2) {{ return Math.hypot(x2 - x1, y2 - y1); }},
|
|
itemAngleFor() {{ return 0; }},
|
|
toolLabel(type) {{ return type; }},
|
|
TarinaiPhysicsSoftClear: {{ isSoftPlacementType() {{ return false; }} }},
|
|
TarinaiMechanicalSystem: {{ isMechanicalType() {{ return false; }} }},
|
|
TarinaiCollisionFootprints: {{ hitTestItem(world, item, x, y) {{ return {{ hit: true, distance: 0 }}; }} }},
|
|
TarinaiGeometry: {{ pointSegmentDistance() {{ return 0; }} }},
|
|
}};
|
|
context.globalThis = context;
|
|
context.window = context;
|
|
vm.createContext(context);
|
|
vm.runInContext({tools!r}, context, {{ filename: 'world_tool_actions.js' }});
|
|
const world = new context.World();
|
|
world.tool = 'rope';
|
|
world.tarinai = [];
|
|
world.items = [{{ id: 'splat-1', type: 'splat', x: 40, y: 50, r: 26, dead: false }}];
|
|
world.nearbyItems = () => world.items;
|
|
world.isFenceType = () => false;
|
|
const endpoint = world.findLinkEndpointAt(40, 50);
|
|
if (endpoint !== null) throw new Error('splat was returned as a connection endpoint');
|
|
"""
|
|
with tempfile.NamedTemporaryFile("w", encoding="utf-8", suffix=".js", delete=False) as f:
|
|
f.write(runtime)
|
|
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("splat connection endpoint runtime smoke test failed")
|
|
ok("splat endpoint exclusion and appetite labels verified")
|
|
|
|
|
|
def check_habitat_food_medicine_additions():
|
|
tool_defs = read_text(ROOT / "js/item_tool_definitions.js")
|
|
metadata = read_text(ROOT / "js/item_tool_metadata.js")
|
|
food_defs = read_text(ROOT / "js/item_food_definitions.js")
|
|
effect_defs = read_text(ROOT / "js/item_effect_definitions.js")
|
|
needs_items = read_text(ROOT / "js/tarinai_needs_items.js")
|
|
local_env = read_text(ROOT / "js/tarinai_local_environment_system.js")
|
|
update_frame = read_text(ROOT / "js/tarinai_update_step_frame.js")
|
|
disease_nest = read_text(ROOT / "js/tarinai_disease_nest.js")
|
|
item_effects = read_text(ROOT / "js/tarinai_item_effects.js")
|
|
consumable = read_text(ROOT / "js/tarinai_consumable_behavior.js")
|
|
interaction = read_text(ROOT / "js/tarinai_food_interaction_system.js")
|
|
render_runtime = read_text(ROOT / "js/item_render_runtime.js")
|
|
render_source = read_text(ROOT / "js/render.js")
|
|
ball_system = read_text(ROOT / "js/item_dynamic_ball_system.js")
|
|
initializers = read_text(ROOT / "js/item_type_initializers.js")
|
|
items = read_text(ROOT / "js/items.js")
|
|
temperature_system = read_text(ROOT / "js/world_temperature_system.js")
|
|
health = read_text(ROOT / "js/health.js")
|
|
combat_effects = read_text(ROOT / "js/world_combat_effects.js")
|
|
action_state = read_text(ROOT / "js/tarinai_action_state.js")
|
|
|
|
def contains(text: str, needle: str, message: str) -> None:
|
|
if needle not in text:
|
|
fail(message)
|
|
|
|
def excludes(text: str, needle: str, message: str) -> None:
|
|
if needle in text:
|
|
fail(message)
|
|
|
|
required_tools = ["toilet", "rain_shelter", "food", "first_aid", "sedative"]
|
|
for key in required_tools:
|
|
contains(tool_defs, f'{key}: {{ id: "{key}"', f"missing tool definition for {key}")
|
|
contains(metadata, 'toolIds: ["food", "sweet"', "pet food must be exposed in food category")
|
|
contains(metadata, 'toolIds: ["first_aid", "sedative", "sleep_drug"', "new medicines must be exposed before legacy medicines")
|
|
contains(metadata, 'toolIds: ["zunchi", "toilet", "rain_shelter"', "new habitat tools must be exposed")
|
|
contains(food_defs, 'food: { label: "\\u30da\\u30c3\\u30c8\\u30d5\\u30fc\\u30c9"', "pet food label must replace generic food label")
|
|
contains(food_defs, 'first_aid: { label: "\\u6551\\u6025\\u85ac", nutrition: 1.0, hungerRelief: 0', "first aid must not provide nutrition or hunger relief")
|
|
contains(food_defs, 'sedative: { label: "\\u93ae\\u9759\\u85ac"', "sedative food definition missing")
|
|
contains(effect_defs, 'Object.assign(rawDefinitions.first_aid', "first aid effect behavior missing")
|
|
contains(effect_defs, 'tarinai.recoverHealth?.(amount);', "first aid must immediately recover HP")
|
|
excludes(effect_defs, 'applyNeedRelief(tarinai, { health: -22, safety: -4 });', "first aid must not modify needs")
|
|
contains(effect_defs, 'tarinai.setTimedItemEffect?.("sedative", duration);', "sedative timed effect missing")
|
|
contains(effect_defs, 'speedMultiplier: 0.72', "sedative speed modifier missing")
|
|
contains(item_effects, 'this.hasItemEffect("sedative")', "sedative speed modifier is not applied")
|
|
contains(consumable, 'const hpOnlyMedicine = foodType === "first_aid";', "behavior consumption lacks first-aid isolation")
|
|
contains(interaction, 'const hpOnlyMedicine = foodType === "first_aid";', "contact consumption lacks first-aid isolation")
|
|
excludes(needs_items, 'nearestUsableToilet', "toilet sand must not be sought as an action target")
|
|
excludes(needs_items, 'beginToiletUrge', "toilet waiting behavior must be removed")
|
|
excludes(needs_items, 'tryUseToiletForPoop', "toilet-specific defecation routing must be removed")
|
|
excludes(action_state, 'seek_toilet: "use_toilet"', "legacy toilet action mapping remains active")
|
|
contains(needs_items, 'if (this.state === "seek_toilet" || this.toiletUrgeTargetId)', "legacy saved toilet behavior is not cleared")
|
|
contains(combat_effects, 'toiletSandAt(x, y)', "toilet sand footprint lookup missing")
|
|
contains(combat_effects, 'const sand = this.toiletSandAt?.(spawnX, spawnY) || null;', "zunchi spawn is not intercepted by toilet sand")
|
|
contains(combat_effects, 'return null;', "toilet sand must suppress zunchi creation")
|
|
excludes(metadata, '"stove", "toilet", "rain_shelter"', "toilet sand must be walkable")
|
|
contains(tool_defs, 'label: \"\\u30c8\\u30a4\\u30ec\\u306e\\u7802\"', "toilet was not renamed to toilet sand")
|
|
contains(tool_defs, 'tooltip: \"\\u4e0a\\u306b\\u6392\\u6cc4\\u3055\\u308c\\u305f\\u305a\\u3093\\u3061\\u304c\\u76f4\\u3061\\u306b\\u6d88\\u6ec5\\u3059\\u308b\\u3002\"', "toilet sand description mismatch")
|
|
contains(initializers, 'if (type === "toilet")', "toilet sand initializer missing")
|
|
contains(initializers, 'if (type === "rain_shelter")', "rain shelter initializer missing")
|
|
if initializers.index('if (type === "toilet")') > initializers.index('if (type === "bed")'):
|
|
fail("toilet initializer is incorrectly nested after bed initialization")
|
|
contains(local_env, 'global.TarinaiParasolSystem', "parasol geometry system missing")
|
|
contains(local_env, 'global.TarinaiRainShelterSystem = parasolSystem', "parasol compatibility alias missing")
|
|
contains(tool_defs, 'label: "\\u30d1\\u30e9\\u30bd\\u30eb"', "rain shelter was not renamed to parasol")
|
|
contains(tool_defs, 'tooltip: "\\u96e8\\u3068\\u65e5\\u5dee\\u3057\\u3092\\u9632\\u304e\\u3001\\u4f53\\u611f\\u6c17\\u6e29\\u3092\\u7a4f\\u3084\\u304b\\u306b\\u3059\\u308b\\u3002"', "parasol description mismatch")
|
|
contains(temperature_system, 'if (weather === "sunny") return 3.5;', "sunny felt-temperature increase missing")
|
|
contains(temperature_system, 'if (weather === "light_rain") return -3.5;', "rain felt-temperature decrease missing")
|
|
contains(temperature_system, 'weatherBlocked ? 0', "parasol does not neutralize weather felt-temperature correction")
|
|
contains(temperature_system, 'types.push("rain_shelter")', "parasol is not a temperature-comfort target")
|
|
contains(temperature_system, 'c.tempOverride != null && Number.isFinite', "null temperature override still collapses candidates to zero degrees")
|
|
contains(update_frame, 'shelteredFromRain', "rain exposure is not blocked by shelter")
|
|
contains(disease_nest, '&& !shelteredFromRain', "rain washing still occurs under shelter")
|
|
contains(render_runtime, 'this.type === "toilet"', "toilet renderer missing")
|
|
contains(render_runtime, 'this.type === "rain_shelter"', "rain shelter renderer missing")
|
|
contains(initializers, 'globalThis.TarinaiToiletSandSystem', "shared toilet-sand geometry system missing")
|
|
contains(combat_effects, 'TarinaiToiletSandSystem?.contains?.(item, x, y)', "toilet absorption does not use the visible sand footprint")
|
|
contains(render_runtime, 'TarinaiToiletSandSystem?.footprint?.(this)', "toilet renderer does not use the absorption footprint")
|
|
contains(render_source, 'it.type === "grass_bed" || it.type === "toilet"', "toilet sand is not layered with the grass bed")
|
|
contains(local_env, 'centerY: Number(item.y || 0) + r * 0.84', "parasol shade is not centered on the pole foot")
|
|
contains(local_env, 'radiusX: r * 1.58', "parasol shade footprint width mismatch")
|
|
contains(ball_system, 'fanWindAt,', "fan wind geometry is not exported for temperature calculations")
|
|
contains(temperature_system, 'fanWindAt?.(it, x, y, { range: fanRange })', "fan felt-temperature effect does not use the visible wind cone")
|
|
contains(render_runtime, 'ctx.ellipse(footprint.centerX - this.x, footprint.centerY - this.y, footprint.radiusX, footprint.radiusY', "parasol ground shadow missing")
|
|
contains(items, 'if (key === "food")', "pet food bowl renderer missing")
|
|
|
|
# New item types must be appended after all pre-existing item IDs so current-schema saves keep their old indices.
|
|
ant_pos = tool_defs.index('ant_corpse: { id: "ant_corpse"')
|
|
for key in ("toilet", "rain_shelter", "first_aid", "sedative"):
|
|
if tool_defs.index(f'{key}: {{ id: "{key}"') < ant_pos:
|
|
fail(f"{key} was inserted into the middle of the save item-type ordering")
|
|
|
|
runtime = f"""
|
|
const vm = require('vm');
|
|
const context = {{ console, globalThis: null, window: null, Math, Object }};
|
|
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.rand = (a, b) => (a + b) / 2;
|
|
context.setGrassStage = () => false;
|
|
context.isServingFoodType = () => false;
|
|
context.isParamEffectItemType = () => false;
|
|
context.isPinType = () => false;
|
|
context.isPinType = () => false;
|
|
context.isRotatableItemType = () => false;
|
|
context.defaultItemAngle = () => 0;
|
|
context.foodServingsForSize = () => 3;
|
|
context.passiveFoodDecayInterval = () => 60;
|
|
context.globalThis.TarinaiItemDynamicToolSystem = {{ robotCleanerDefaultMask() {{ return 3; }}, robotCleanerMoveSpeed: 60 }};
|
|
context.globalThis.TarinaiPhysicsBodySystem = {{ isPhysicsType() {{ return false; }}, invalidateItem() {{}} }};
|
|
vm.createContext(context);
|
|
vm.runInContext({initializers!r}, context, {{ filename: 'item_type_initializers.js' }});
|
|
const toilet = {{ roles: {{}}, type: 'toilet', x: 0, y: 0, r: 48, dead: false }};
|
|
context.initializeItemTypeState(toilet, 'toilet', 0, 0);
|
|
if (toilet.amount !== 999) throw new Error('toilet sand initializer failed');
|
|
const shelter = {{ roles: {{}}, r: 50 }};
|
|
context.initializeItemTypeState(shelter, 'rain_shelter', 0, 0);
|
|
if (shelter.amount !== 999 || !(shelter.rainShelterRange >= 76)) throw new Error('rain shelter initializer failed');
|
|
|
|
const localContext = {{ console, globalThis: null, window: null, Math, Object }};
|
|
localContext.globalThis = localContext;
|
|
localContext.window = localContext;
|
|
localContext.distXY = context.distXY;
|
|
localContext.clamp = context.clamp;
|
|
localContext.applyNeedRelief = () => {{}};
|
|
vm.createContext(localContext);
|
|
vm.runInContext({local_env!r}, localContext, {{ filename: 'tarinai_local_environment_system.js' }});
|
|
const shelterItem = {{ type: 'rain_shelter', x: 100, y: 100, r: 50, dead: false }};
|
|
const world = {{ weather: 'light_rain', itemsOfType(type) {{ return type === 'rain_shelter' ? [shelterItem] : []; }} }};
|
|
const shelterFootprint = localContext.TarinaiParasolSystem.parasolFootprint(shelterItem);
|
|
if (!localContext.TarinaiRainShelterSystem.isSheltered(world, shelterFootprint.centerX, shelterFootprint.centerY)) throw new Error('parasol did not cover its shadow center');
|
|
if (localContext.TarinaiRainShelterSystem.isSheltered(world, shelterItem.x, shelterItem.y - 10)) throw new Error('parasol covered a point outside the visible shadow');
|
|
if (localContext.TarinaiRainShelterSystem.isSheltered(world, 500, 500)) throw new Error('parasol covered a distant point');
|
|
if (!localContext.TarinaiParasolSystem.blocksWeatherAt(world, shelterFootprint.centerX, shelterFootprint.centerY)) throw new Error('parasol did not block weather in its shadow');
|
|
|
|
const tempContext = {{ console, globalThis: null, window: null, Math, Object }};
|
|
tempContext.globalThis = tempContext;
|
|
tempContext.window = tempContext;
|
|
tempContext.World = class World {{}};
|
|
tempContext.CONFIG = {{ standardTemperature: 15, temperatureItemRange: 190, climateTemperatureMin: -20, climateTemperatureMax: 50 }};
|
|
tempContext.clamp = context.clamp;
|
|
tempContext.distXY = context.distXY;
|
|
tempContext.itemAngleFor = () => 0;
|
|
tempContext.TarinaiParasolSystem = localContext.TarinaiParasolSystem;
|
|
tempContext.TarinaiRainShelterSystem = localContext.TarinaiRainShelterSystem;
|
|
tempContext.TarinaiItemDynamicBallSystem = {{ fanWindAt(fan, x, y, opts = {{}}) {{
|
|
const range = opts.range || 320; const dx = x - fan.x; const dy = y - fan.y; const a = fan.angle || 0;
|
|
const along = dx * Math.cos(a) + dy * Math.sin(a); if (along < 8 || along > range) return null;
|
|
const lateral = Math.abs(-dx * Math.sin(a) + dy * Math.cos(a)); const halfWidth = 26 + along * Math.tan(0.62);
|
|
if (lateral > halfWidth) return null; return {{ falloff: Math.pow(Math.max(0, 1 - along / range), 0.64) * Math.pow(Math.max(0, 1 - lateral / halfWidth), 0.34) }};
|
|
}} }};
|
|
vm.createContext(tempContext);
|
|
vm.runInContext({temperature_system!r}, tempContext, {{ filename: 'world_temperature_system.js' }});
|
|
const tempWorld = new tempContext.World();
|
|
tempWorld.currentTemperature = 15;
|
|
tempWorld.temperatureAuto = true;
|
|
tempWorld.groundType = 'soil';
|
|
tempWorld.weather = 'sunny';
|
|
tempWorld.nearbyItems = () => [];
|
|
tempWorld.itemsOfType = () => [];
|
|
const actor = {{ x: 0, y: 0, state: 'idle', sunbathTimer: 0 }};
|
|
if (Math.abs(tempWorld.temperatureAt(0, 0, actor) - 18.5) > 0.001) throw new Error('sunny felt temperature did not rise');
|
|
tempWorld.weather = 'light_rain';
|
|
if (Math.abs(tempWorld.temperatureAt(0, 0, actor) - 11.5) > 0.001) throw new Error('rain felt temperature did not fall');
|
|
const localShelter = {{ type: 'rain_shelter', x: 0, y: 0, r: 50, dead: false }};
|
|
const localShade = localContext.TarinaiParasolSystem.parasolFootprint(localShelter);
|
|
tempWorld.itemsOfType = type => type === 'rain_shelter' ? [localShelter] : [];
|
|
if (Math.abs(tempWorld.temperatureAt(localShade.centerX, localShade.centerY, actor) - 15) > 0.001) throw new Error('parasol did not neutralize rain felt-temperature correction');
|
|
tempWorld.weather = 'sunny';
|
|
if (Math.abs(tempWorld.temperatureAt(localShade.centerX, localShade.centerY, actor) - 15) > 0.001) throw new Error('parasol did not neutralize sunny felt-temperature correction');
|
|
tempWorld.weather = 'cloudy';
|
|
tempWorld.currentTemperature = 30;
|
|
const fan = {{ type: 'fan', x: 0, y: 0, r: 26, angle: 0, dead: false }};
|
|
tempWorld.itemsOfType = () => [];
|
|
tempWorld.nearbyItems = (x, y, radius) => Math.hypot(fan.x - x, fan.y - y) <= radius ? [fan] : [];
|
|
const fanNear = tempWorld.temperatureAt(100, 0, actor);
|
|
const fanFar = tempWorld.temperatureAt(280, 0, actor);
|
|
const fanOutside = tempWorld.temperatureAt(330, 0, actor);
|
|
if (!(fanNear < 30 && fanFar < 30 && Math.abs(fanOutside - 30) < 0.001)) throw new Error('fan felt-temperature cone does not match the 320px wind range');
|
|
tempWorld.nearbyItems = () => [];
|
|
tempWorld.w = 1000;
|
|
tempWorld.h = 720;
|
|
tempWorld.currentTemperature = 24;
|
|
tempWorld.weather = 'sunny';
|
|
tempWorld.itemsOfType = type => type === 'rain_shelter' ? [{{ type: 'rain_shelter', x: 300, y: 300, r: 50, dead: false }}] : [];
|
|
tempWorld.pointBlockedByObstacle = () => false;
|
|
tempWorld.pathBlockedByFence = () => false;
|
|
const hotActor = {{ x: 100, y: 300, radius: 20, dead: false, state: 'idle', sunbathTimer: 0, genetics: {{ temperatureOffset: 0 }} }};
|
|
const sunnyTarget = tempWorld.findComfortTemperatureSpot(hotActor, {{ force: true, requireComfort: false }});
|
|
if (!sunnyTarget || !localContext.TarinaiParasolSystem.isSheltered(tempWorld, sunnyTarget.x, sunnyTarget.y)) throw new Error('hot sunny actor did not choose parasol shade');
|
|
if (sunnyTarget.temperature === 0) throw new Error('null temperature override was interpreted as zero');
|
|
tempWorld.currentTemperature = 9;
|
|
tempWorld.weather = 'light_rain';
|
|
const coldTarget = tempWorld.findComfortTemperatureSpot(hotActor, {{ force: true, requireComfort: false }});
|
|
if (!coldTarget || !localContext.TarinaiParasolSystem.isSheltered(tempWorld, coldTarget.x, coldTarget.y)) throw new Error('cold rainy actor did not choose parasol cover');
|
|
"""
|
|
with tempfile.NamedTemporaryFile("w", encoding="utf-8", suffix=".js", delete=False) as f:
|
|
f.write(runtime)
|
|
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("new habitat item runtime smoke test failed")
|
|
|
|
method_start = needs_items.index(" dropPoopNow(source = \"normal\") {")
|
|
method_end = needs_items.index(" interactWithItems(dt) {", method_start)
|
|
poop_methods = needs_items[method_start:method_end]
|
|
toilet_runtime = f"""
|
|
const vm = require('vm');
|
|
const context = {{ console, globalThis: null, window: null, Math, Object }};
|
|
context.globalThis = context;
|
|
context.window = context;
|
|
context.NORMAL_POOP_MEAL_THRESHOLD = 4;
|
|
context.distXY = (x1, y1, x2, y2) => Math.hypot(x2 - x1, y2 - y1);
|
|
context.rand = (a, b) => (a + b) / 2;
|
|
context.clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
|
|
context.applyNeedShock = () => false;
|
|
vm.createContext(context);
|
|
const methods = vm.runInContext('({{' + {poop_methods!r} + '}})', context);
|
|
let spawned = 0;
|
|
const actor = {{
|
|
...methods,
|
|
world: {{ spawnZunchi() {{ spawned += 1; }}, log() {{}} }},
|
|
name: 'A', x: 100, y: 100, radius: 20, facing: 1, digest: 0, poopCount: 0, state: 'idle', target: null,
|
|
facingDir() {{ return 1; }}, bubble() {{}}, hasLodgedPinEffect() {{ return false; }},
|
|
}};
|
|
if (!actor.makePoop(4)) throw new Error('ordinary poop did not occur');
|
|
if (spawned !== 1 || actor.digest !== 0 || actor.poopCount !== 1) throw new Error('ordinary poop accounting failed');
|
|
if (actor.state !== 'idle' || actor.target !== null) throw new Error('toilet sand caused special movement behavior');
|
|
|
|
const combatContext = {{ console, globalThis: null, window: null, Math, Object }};
|
|
combatContext.globalThis = combatContext;
|
|
combatContext.window = combatContext;
|
|
combatContext.World = class World {{}};
|
|
combatContext.Item = class Item {{ constructor(type, x, y) {{ this.type = type; this.x = x; this.y = y; }} }};
|
|
combatContext.Effect = class Effect {{}};
|
|
combatContext.audio = {{ place() {{}} }};
|
|
combatContext.clamp = context.clamp;
|
|
combatContext.distXY = context.distXY;
|
|
combatContext.TarinaiToiletSandSystem = {{ contains(item, x, y) {{
|
|
const r = Math.max(24, Number(item.r || 48) || 48); const cx = item.x; const cy = item.y + r * 0.08;
|
|
const lx = x - cx; const ly = y - cy; const hw = r * 1.55; const hh = r * 0.72; const cr = r * 0.18;
|
|
const qx = Math.max(0, Math.abs(lx) - (hw - cr)); const qy = Math.max(0, Math.abs(ly) - (hh - cr));
|
|
return qx * qx + qy * qy <= cr * cr;
|
|
}} }};
|
|
vm.createContext(combatContext);
|
|
vm.runInContext({combat_effects!r}, combatContext, {{ filename: 'world_combat_effects.js' }});
|
|
const world = new combatContext.World();
|
|
world.w = 800; world.h = 600; world.time = 12;
|
|
const sand = {{ id: 'sand-1', type: 'toilet', x: 200, y: 200, r: 48, dead: false, angle: 0, toiletFlash: 0 }};
|
|
world.itemsOfType = type => type === 'toilet' ? [sand] : [];
|
|
let added = 0;
|
|
world.addItem = item => {{ added += 1; return item; }};
|
|
world.markTerrainDirtyAt = () => {{}};
|
|
if (world.spawnZunchi(200, 200, null) !== null || added !== 0 || sand.toiletFlash !== 1) throw new Error('toilet sand did not immediately remove poop');
|
|
const insideEdge = world.spawnZunchi(270, 200, null);
|
|
if (insideEdge !== null || added !== 0) throw new Error('toilet sand visible rectangle did not absorb poop near its edge');
|
|
const outside = world.spawnZunchi(278, 200, null);
|
|
if (!outside || added !== 1) throw new Error('toilet sand removed poop outside its footprint');
|
|
"""
|
|
with tempfile.NamedTemporaryFile("w", encoding="utf-8", suffix=".js", delete=False) as f:
|
|
f.write(toilet_runtime)
|
|
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("toilet sand runtime smoke test failed")
|
|
ok("toilet sand footprint/layering, parasol shade geometry, fan felt-temperature, weather targeting, pet food, first aid, and sedative additions verified")
|
|
|
|
|
|
def check_parasol_rain_and_preset_updates() -> None:
|
|
placement = read_text(ROOT / "js/placement_preview_system.js")
|
|
render = read_text(ROOT / "js/render.js")
|
|
ambient = read_text(ROOT / "js/simulation_ambient_system.js")
|
|
weather = read_text(ROOT / "js/weather_system.js")
|
|
presets = read_text(ROOT / "js/world_reset_presets.js")
|
|
|
|
required = [
|
|
(placement, 'overlay: overlayForItem(tmp)', "parasol placement preview overlay is not exported"),
|
|
(placement, 'shape: "roundedRect", centerX: footprint.centerX, centerY: footprint.centerY', "parasol preview does not reuse shade geometry"),
|
|
(render, 'overlay?.shape === "roundedRect"', "shape-matched placement overlays are not rendered"),
|
|
(weather, 'drop.rainDrop = opts?.rainDrop === true || (!hasX && !hasY);', "rain-created water is not tagged"),
|
|
(ambient, 'const sand = worldRef.toiletSandAt?.(drop.x, drop.y) || null;', "toilet sand does not intercept rain water"),
|
|
(ambient, '"toilet-sand-rain-absorb"', "rain absorption does not invalidate toilet sand rendering"),
|
|
(presets, 'segments: [[-66, 0, 66, 0]]', "athletic rotator was not simplified"),
|
|
(presets, 'rotationEnvelope: true', "athletic rotator does not reserve its full rotation envelope"),
|
|
(presets, '"preset-athletic-water-balloon"', "athletic preset lacks water balloons"),
|
|
(presets, '"preset-happy-parasol"', "happy preset lacks parasols"),
|
|
(presets, '"preset-happy-water-balloon"', "happy preset lacks water balloons"),
|
|
(presets, '"preset-happy-toilet-sand"', "happy preset lacks toilet sand"),
|
|
(presets, '"preset-war-rotating-fan"', "war preset lacks rotating fans"),
|
|
(presets, 'fanSwingOn: true', "war preset fans do not rotate/swing"),
|
|
]
|
|
for text, needle, message in required:
|
|
if needle not in text:
|
|
fail(message)
|
|
|
|
js = f"""
|
|
const vm = require('vm');
|
|
const fs = require('fs');
|
|
|
|
const overlayContext = {{ console, globalThis: null, window: null, Math, Object }};
|
|
overlayContext.globalThis = overlayContext; overlayContext.window = overlayContext;
|
|
overlayContext.clamp = (v, a, b) => Math.max(a, Math.min(b, v));
|
|
overlayContext.distXY = (a, b, x, y) => Math.hypot(x - a, y - b);
|
|
overlayContext.applyNeedRelief = () => {{}};
|
|
overlayContext.itemRadiusFor = (type, fallback) => type === 'rain_shelter' ? 50 : fallback;
|
|
overlayContext.toolItemType = tool => tool;
|
|
overlayContext.isRotatableItemType = () => false;
|
|
overlayContext.defaultItemAngle = () => 0;
|
|
overlayContext.foodServingsForSize = () => 3;
|
|
overlayContext.isServingFoodType = () => false;
|
|
overlayContext.CONFIG = {{ worldPadding: 30 }};
|
|
overlayContext.TarinaiCollisionFootprints = {{ placementRectsFor() {{ return []; }}, itemFootprintRadius(_world, item) {{ return item.r; }} }};
|
|
overlayContext.TarinaiMechanicalSystem = {{ isMechanicalType() {{ return false; }} }};
|
|
overlayContext.TarinaiPhysicsSoftClear = {{ collectForPlacement() {{ return []; }} }};
|
|
vm.createContext(overlayContext);
|
|
vm.runInContext({read_text(ROOT / 'js/tarinai_local_environment_system.js')!r}, overlayContext, {{ filename: 'tarinai_local_environment_system.js' }});
|
|
vm.runInContext({placement!r}, overlayContext, {{ filename: 'placement_preview_system.js' }});
|
|
const previewWorld = {{ tool: 'rain_shelter', pointer: {{ inside: true, x: 100, y: 120 }}, toolSizeScale() {{ return 1; }}, toolSizeFor() {{ return 'medium'; }}, placementBlocked() {{ return false; }}, w: 800, h: 600 }};
|
|
const preview = overlayContext.TarinaiPlacementPreviewSystem.forWorld(previewWorld);
|
|
const shade = overlayContext.TarinaiParasolSystem.parasolFootprint(preview.item);
|
|
if (!preview.overlay || preview.overlay.centerX !== shade.centerX || preview.overlay.centerY !== shade.centerY || preview.overlay.halfWidth !== shade.radiusX || preview.overlay.halfHeight !== shade.radiusY || preview.overlay.shape !== 'roundedRect') throw new Error('parasol preview overlay and shade diverged');
|
|
|
|
const ambientContext = {{ console, globalThis: null, window: null, Math, Object }};
|
|
ambientContext.globalThis = ambientContext; ambientContext.window = ambientContext;
|
|
ambientContext.TarinaiWeatherSystem = {{ shouldDropRainWater() {{ return true; }}, createRainWaterItem() {{ return {{ type: 'water', x: 20, y: 30, rainDrop: true }}; }} }};
|
|
vm.createContext(ambientContext);
|
|
vm.runInContext({ambient!r}, ambientContext, {{ filename: 'simulation_ambient_system.js' }});
|
|
let rainAdded = 0, rainDirty = 0;
|
|
const sand = {{ type: 'toilet', x: 20, y: 30, r: 48, toiletFlash: 0 }};
|
|
ambientContext.TarinaiAmbientSimulationSystem.phases.spawnRainWater({{ time: 1, toiletSandAt() {{ return sand; }}, addItem() {{ rainAdded += 1; }}, markTerrainDirtyAt() {{ rainDirty += 1; }} }}, 1);
|
|
if (rainAdded !== 0 || rainDirty !== 1 || sand.toiletFlash !== 1) throw new Error('toilet sand did not absorb ambient rain water');
|
|
|
|
const presetContext = {{ console, globalThis: null, window: null, Math, Object }};
|
|
presetContext.globalThis = presetContext; presetContext.window = presetContext;
|
|
let serial = 0;
|
|
presetContext.rand = (a, b) => {{ serial = (serial + 0.371) % 1; return a + (b - a) * serial; }};
|
|
presetContext.clamp = (v, a, b) => Math.max(a, Math.min(b, v));
|
|
presetContext.CONFIG = {{ initialPopulation: 8 }};
|
|
presetContext.toolLabel = type => type;
|
|
presetContext.stableUnit = () => 0.42;
|
|
presetContext.TarinaiGrass = {{ setStage() {{}} }};
|
|
presetContext.Item = class Item {{
|
|
constructor(type, x, y) {{
|
|
this.id = `i${{++serial}}`; this.type = type; this.x = x; this.y = y; this.amount = 999; this.dead = false; this.roles = {{}}; this.angle = 0;
|
|
this.r = type === 'rain_shelter' ? 50 : (type === 'toilet' ? 48 : (type === 'fan' ? 26 : 20));
|
|
if (type === 'rotator' || type === 'reciprocator') this.physicsBody = {{ pose: {{ x, y, angle: 0 }}, velocity: {{}}, shape: {{}}, motor: {{}}, rail: {{}} }};
|
|
}}
|
|
}};
|
|
presetContext.TarinaiPhysicsBodySystem = {{ invalidateItem() {{}} }};
|
|
presetContext.TarinaiMechanicalSystem = {{ invalidateGeometry() {{}}, reach(item) {{ let reach = 0; for (const seg of item.physicsBody?.shape?.segments || []) reach = Math.max(reach, Math.hypot(seg[0], seg[1]), Math.hypot(seg[2], seg[3])); return reach; }} }};
|
|
presetContext.TarinaiCollisionFootprints = {{ itemFootprintRadius(_world, item) {{ return item.r || 20; }}, placementRectsFor() {{ return []; }}, rectCircleOverlap() {{ return false; }}, rectsOverlap() {{ return false; }} }};
|
|
function makeWorld() {{ return {{ w: 1000, h: 720, items: [], tarinai: [], addItem(item) {{ this.items.push(item); return item; }}, addTarinai(t) {{ this.tarinai.push(t); return t; }}, nearbyItems() {{ return []; }}, placementBlocked() {{ return false; }}, solidObstacleRects() {{ return []; }}, isFenceType(type) {{ return String(type).includes('fence'); }}, placementClampPointFor(_item, x, y) {{ return {{ x, y }}; }} }}; }}
|
|
vm.createContext(presetContext);
|
|
vm.runInContext({presets!r}, presetContext, {{ filename: 'world_reset_presets.js' }});
|
|
let presetWorld = makeWorld();
|
|
presetContext.TarinaiResetPresets.generatePresetWorld(presetWorld, {{ id: 'garden' }}, 'happy');
|
|
const duplicators = presetWorld.items.filter(item => item.type === 'duplicator');
|
|
const toilets = presetWorld.items.filter(item => item.type === 'toilet');
|
|
if (!duplicators.length || toilets.length !== duplicators.length || !duplicators.every(dup => toilets.some(sandItem => sandItem.x === dup.x && sandItem.y === dup.y))) throw new Error('happy toilet sand is not colocated with duplicators');
|
|
if (!presetWorld.items.some(item => item.type === 'rain_shelter') || !presetWorld.items.some(item => item.type === 'balloon')) throw new Error('happy preset additions missing');
|
|
presetWorld = makeWorld();
|
|
presetContext.TarinaiResetPresets.generatePresetWorld(presetWorld, {{ id: 'garden' }}, 'athletic');
|
|
const rotators = presetWorld.items.filter(item => item.type === 'rotator');
|
|
if (!rotators.length || rotators.some(item => item.physicsBody?.shape?.segments?.length !== 1) || !presetWorld.items.some(item => item.type === 'balloon')) throw new Error('athletic preset shape/balloon additions missing');
|
|
presetWorld = makeWorld();
|
|
presetContext.TarinaiResetPresets.generatePresetWorld(presetWorld, {{ id: 'garden' }}, 'war');
|
|
const fans = presetWorld.items.filter(item => item.type === 'fan');
|
|
if (!fans.length || fans.some(item => !item.fanSwingOn || !(item.fanSwingRange > 0) || !(item.fanSwingSpeed > 0))) throw new Error('war preset rotating fans missing');
|
|
const warBounceFences = presetWorld.items.filter(item => item.type === 'bounce_fence' || item.type === 'bounce_fence_v');
|
|
if (warBounceFences.length < 2) throw new Error('war preset bounce fences 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("parasol/rain/preset runtime smoke test failed")
|
|
ok("parasol preview alignment, rain absorption, and happy/athletic/war preset additions verified")
|
|
|
|
def check_war_fence_sleep_placement_delete_fixes() -> None:
|
|
def contains_local(text: str, needle: str, message: str) -> None:
|
|
if needle not in text:
|
|
fail(message)
|
|
|
|
metadata = read_text(ROOT / "js/item_tool_metadata.js")
|
|
footprints = read_text(ROOT / "js/collision_footprint_system.js")
|
|
placement = read_text(ROOT / "js/world_placement_log.js")
|
|
environment = read_text(ROOT / "js/world_environment.js")
|
|
collisions = read_text(ROOT / "js/collision_response_system.js")
|
|
render = read_text(ROOT / "js/render.js")
|
|
tools = read_text(ROOT / "js/world_tool_actions.js")
|
|
presets = read_text(ROOT / "js/world_reset_presets.js")
|
|
|
|
contains_local(metadata, 'freeStackingMovable: new Set(["ball", "balloon", "pushpin", "oshibyo"', "movable placement stacking trait missing")
|
|
contains_local(footprints, 'freeStacking?.has(item.type) && freeStacking.has(other.type)', "movable-vs-movable placement overlap is not ignored")
|
|
if "function ballPlacementBlocked" in placement or "ballPlacementBlocked(this, item)" in placement:
|
|
fail("legacy ball-on-ball placement rejection remains")
|
|
contains_local(environment, "const ORDINARY_FENCE_RESTITUTION = 0.38;", "ordinary fence restitution was not reduced")
|
|
contains_local(environment, "restitution: bounce ? BOUNCE_FENCE_RESTITUTION : ORDINARY_FENCE_RESTITUTION", "ordinary fence restitution is not exported through fence geometry")
|
|
contains_local(collisions, 'const sleepingContact = t.state === "sleep" || t.sleeping;', "sleeping Tarinai are still excluded from ball collision candidates")
|
|
contains_local(collisions, 'markSleepExternalMotion?.(t, frameDt, "ball-contact")', "sleeping ball collision does not enter passive physical motion")
|
|
contains_local(render, 'const targetGapScale = 1.45;', "world tooltips do not share the closer offset")
|
|
contains_local(presets, 'reason: "preset-war-bounce-fence"', "war preset bounce fences missing")
|
|
delete_section = tools[tools.find("findDeleteToolTargetAt"):tools.find("deleteItemAt")]
|
|
if "wireTool" in delete_section:
|
|
fail("delete target lookup still references undefined wireTool")
|
|
|
|
js = f"""
|
|
const fs = require('fs');
|
|
const vm = require('vm');
|
|
|
|
const collisionContext = {{ console, Math, Object, globalThis: null, window: null }};
|
|
collisionContext.globalThis = collisionContext; collisionContext.window = collisionContext;
|
|
collisionContext.TarinaiImpactCoreSystem = {{ PHYSICAL_DAMAGE_SPEED_THRESHOLD: 165, physicalDamageFromImpactSpeed() {{ return 5; }}, itemHasAttachedLink() {{ return false; }} }};
|
|
collisionContext.clamp = (v, a, b) => Math.max(a, Math.min(b, v));
|
|
collisionContext.distXY = (ax, ay, bx, by) => Math.hypot(ax - bx, ay - by);
|
|
collisionContext.deterministicRange = () => 0;
|
|
collisionContext.deterministicAngle = () => 0;
|
|
collisionContext.pick = values => values[0];
|
|
collisionContext.CONFIG = {{ worldPadding: 0 }};
|
|
collisionContext.Effect = class Effect {{ constructor(...args) {{ this.args = args; }} }};
|
|
collisionContext.tarinaiMaxEnergy = () => 100;
|
|
collisionContext.applyNeedRelief = () => {{}};
|
|
collisionContext.applyNeedShock = () => {{}};
|
|
let sleepMotionMarks = 0;
|
|
collisionContext.TarinaiMovementUpdateStep = {{ markSleepExternalMotion() {{ sleepMotionMarks += 1; }} }};
|
|
collisionContext.TarinaiItemDynamicPinSystem = {{ transferBallPinsToTarinai() {{ return 0; }} }};
|
|
vm.createContext(collisionContext);
|
|
vm.runInContext({collisions!r}, collisionContext, {{ filename: 'collision_response_system.js' }});
|
|
const ball = {{ id: 'ball', type: 'ball', x: 9, y: 0, prevX: -9, prevY: 0, r: 10, vx: 100, vy: 0, spinVelocity: 0, dead: false }};
|
|
const sleeper = {{ id: 'sleeper', name: 'sleeper', x: 20, y: 0, radius: 20, vx: 0, vy: 0, state: 'sleep', sleeping: true, dead: false, energy: 100 }};
|
|
const collisionWorld = {{ time: 1, w: 500, h: 500, itemCounts: {{ ball: 1, balloon: 0 }}, items: [ball], effects: [], spawnEffect() {{ return null; }}, itemsOfType(type) {{ return type === 'ball' ? [ball] : []; }}, nearbyTarinai() {{ return [sleeper]; }}, isTarinaiHiddenInNestBox() {{ return false; }}, markSpatialDirty() {{}}, relationNotice() {{ return false; }}, applyImpulse() {{}}, applyImpactDamage() {{}} }};
|
|
collisionContext.TarinaiCollisionResponseSystem.resolveBallInteractions(collisionWorld, 0.016);
|
|
if (!(ball.vx < 0) || !(sleeper.vx > 0) || sleepMotionMarks < 1) throw new Error('sleeping Tarinai did not physically collide with ball');
|
|
|
|
const placementContext = {{ console, Math, Object, globalThis: null, window: null }};
|
|
placementContext.globalThis = placementContext; placementContext.window = placementContext;
|
|
placementContext.TarinaiGeometry = {{ num(v, fallback = 0) {{ const n = Number(v); return Number.isFinite(n) ? n : fallback; }}, rectLocalPoint() {{}}, rectWorldVector() {{}}, rectCorners() {{ return []; }}, rectAxes() {{ return []; }}, projectPoints() {{}}, aabbOverlap() {{ return false; }}, rectOverlapInfo() {{}}, rectsOverlap() {{ return false; }} }};
|
|
placementContext.TarinaiMechanicalSystem = {{ isMechanicalType() {{ return false; }} }};
|
|
placementContext.isFenceItemType = () => false;
|
|
placementContext.itemRadiusFor = () => 14;
|
|
placementContext.TarinaiItemToolMetadata = {{ ITEM_TRAITS: {{ freeStackingMovable: new Set(['ball', 'balloon', 'pushpin', 'oshibyo', 'zunchi']) }} }};
|
|
vm.createContext(placementContext);
|
|
vm.runInContext({footprints!r}, placementContext, {{ filename: 'collision_footprint_system.js' }});
|
|
const stackedWorld = {{ nearbyItems() {{ return [{{ type: 'zunchi', x: 0, y: 0, r: 14, dead: false }}]; }}, solidObstacleRects() {{ return []; }} }};
|
|
if (placementContext.TarinaiCollisionFootprints.itemPlacementOverlapBlocked(stackedWorld, {{ type: 'zunchi', x: 0, y: 0, r: 14, dead: false }})) throw new Error('movable items still block movable placement');
|
|
|
|
const toolContext = {{ console, Math, Object, globalThis: null, window: null }};
|
|
toolContext.globalThis = toolContext; toolContext.window = toolContext;
|
|
toolContext.World = class World {{ constructor() {{ this.items = [{{ id: 'i1', type: 'ball', x: 10, y: 10, r: 18, dead: false }}]; }} nearbyItems() {{ return this.items; }} compactItems() {{}} updateItemCounts() {{}} rebuildSpatial() {{}} log() {{}} }};
|
|
toolContext.distXY = (ax, ay, bx, by) => Math.hypot(ax - bx, ay - by);
|
|
toolContext.TarinaiCollisionFootprints = {{ hitTestItem(_world, item, x, y) {{ return {{ hit: Math.hypot(item.x - x, item.y - y) < 30, distance: Math.hypot(item.x - x, item.y - y) }}; }} }};
|
|
toolContext.TarinaiGeometry = {{ pointSegmentDistance() {{ return 999; }} }};
|
|
toolContext.TarinaiStructureLifecycle = {{ deleteItem(_world, item) {{ item.dead = true; return true; }} }};
|
|
toolContext.showToast = () => {{}};
|
|
toolContext.toolLabel = type => type;
|
|
toolContext.isPinType = () => false;
|
|
toolContext.itemRadiusFor = () => 18;
|
|
toolContext.audio = {{ delete() {{}} }};
|
|
toolContext.render = () => {{}};
|
|
vm.createContext(toolContext);
|
|
vm.runInContext({tools!r}, toolContext, {{ filename: 'world_tool_actions.js' }});
|
|
const deleteWorld = new toolContext.World();
|
|
const found = deleteWorld.findDeleteToolTargetAt(10, 10);
|
|
if (!found || found.type !== 'ball') throw new Error('delete tool target lookup failed');
|
|
deleteWorld.deleteItemAt(10, 10);
|
|
if (!deleteWorld.items[0].dead) throw new Error('delete tool did not delete target item');
|
|
"""
|
|
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("war/fence/sleep/placement/delete runtime smoke test failed")
|
|
ok("war bounce fences, modest ordinary restitution, sleeping ball collision, closer nest tooltip, movable stacking, and delete tool verified")
|
|
|
|
|
|
def check_hair_trigger_tooltips_and_physics_icons() -> None:
|
|
def contains_local(text: str, needle: str, message: str) -> None:
|
|
if needle not in text:
|
|
fail(message)
|
|
|
|
render = read_text(ROOT / "js/render.js")
|
|
presets = read_text(ROOT / "js/world_reset_presets.js")
|
|
layout = read_text(ROOT / "js/ui_layout_dialogs.js")
|
|
tools = read_text(ROOT / "js/ui_tools.js")
|
|
index = read_text(ROOT / "index.html")
|
|
|
|
contains_local(render, 'const targetGapScale = 1.45;', "all world tooltips were not moved to the nest-box offset")
|
|
if 'info?.kind === "nest_box" ?' in render:
|
|
fail("nest-box-only tooltip offset remains")
|
|
contains_local(presets, 'hair_trigger: { label: "\\u4E00\\u89E6\\u5373\\u767A" }', "hair-trigger preset definition missing")
|
|
contains_local(presets, 'function addPresetBounceFenceBox', "hair-trigger bounce enclosure helper missing")
|
|
contains_local(presets, 'storedFoodType: "food"', "hair-trigger duplicators are not loaded with pet food")
|
|
contains_local(layout, '"hair_trigger"', "field dialog preset fallback omits hair-trigger preset")
|
|
contains_local(index, 'data-reset-preset="hair_trigger"', "hair-trigger preset button missing")
|
|
contains_local(tools, 'function drawPhysicsToolIcon(canvas, toolId)', "dedicated physics icon renderer missing")
|
|
contains_local(tools, 'const hasPhysicsIcon = attachPhysicsToolIcon(btn, toolId);', "dedicated item silhouettes are not attached across categories")
|
|
for tool_id in ["rope", "rod", "spring", "wire", "insulated_wire", "pressure_switch", "glass_wall", "bounce_fence", "gate_fence", "rotator", "poison_block", "reciprocator", "one_way_fence", "fence_h"]:
|
|
contains_local(tools, f'toolId === "{tool_id}"', f"distinct physics icon branch missing: {tool_id}")
|
|
|
|
js = f"""
|
|
const fs = require('fs');
|
|
const vm = require('vm');
|
|
const source = {presets!r};
|
|
let idCounter = 0;
|
|
const ctx = {{ console, Math, Object, globalThis: null, window: null }};
|
|
ctx.globalThis = ctx; ctx.window = ctx;
|
|
ctx.CONFIG = {{ initialPopulation: 16 }};
|
|
ctx.clamp = (v, a, b) => Math.max(a, Math.min(b, v));
|
|
ctx.rand = (a, b) => (Number(a) + Number(b)) / 2;
|
|
ctx.Item = class Item {{ constructor(type, x, y) {{ this.id = `i${{++idCounter}}`; this.type = type; this.x = x; this.y = y; this.r = type === 'bounce_fence' ? 42 : type === 'ball' ? 18 : 22; this.amount = 999; this.roles = {{}}; }} }};
|
|
ctx.toolLabel = id => id;
|
|
ctx.TarinaiGrass = {{ setStage() {{}} }};
|
|
ctx.TarinaiPhysicsBodySystem = {{ invalidateItem() {{}} }};
|
|
ctx.TarinaiMechanicalSystem = {{ invalidateGeometry() {{}}, reach() {{ return 0; }} }};
|
|
vm.createContext(ctx);
|
|
vm.runInContext(source, ctx, {{ filename: 'world_reset_presets.js' }});
|
|
const world = {{
|
|
w: 1000, h: 700, items: [], tarinai: [],
|
|
addItem(item) {{ this.items.push(item); return item; }},
|
|
addTarinai(data) {{ const t = {{ ...data, id: `t${{this.tarinai.length + 1}}` }}; this.tarinai.push(t); return t; }},
|
|
nearbyItems() {{ return []; }},
|
|
placementClampPointFor(_item, x, y) {{ return {{ x, y }}; }},
|
|
placementBlocked() {{ return false; }},
|
|
isFenceType(type) {{ return type.includes('fence'); }},
|
|
}};
|
|
ctx.TarinaiResetPresets.generatePresetWorld(world, {{ id: 'garden' }}, 'hair_trigger', 16);
|
|
const cx = world.w * 0.5, cy = world.h * 0.5;
|
|
const halfW = Math.max(132, Math.min(238, world.w * 0.18));
|
|
const halfH = Math.max(104, Math.min(182, world.h * 0.18));
|
|
const fences = world.items.filter(item => item.type === 'bounce_fence');
|
|
const balls = world.items.filter(item => item.type === 'ball');
|
|
const duplicators = world.items.filter(item => item.type === 'duplicator');
|
|
if (fences.length < 8) throw new Error('hair-trigger enclosure has too few bounce fences');
|
|
if (balls.length < 4 || !balls.every(item => Math.abs(item.x - cx) < halfW && Math.abs(item.y - cy) < halfH)) throw new Error('hair-trigger balls are not inside enclosure');
|
|
if (!world.tarinai.length || !world.tarinai.every(t => Math.abs(t.x - cx) > halfW + 20 || Math.abs(t.y - cy) > halfH + 20)) throw new Error('hair-trigger Tarinai are not outside enclosure');
|
|
if (!duplicators.length || !duplicators.every(item => item.storedFoodType === 'food' && (Math.abs(item.x - cx) > halfW + 20 || Math.abs(item.y - cy) > halfH + 20))) throw new Error('hair-trigger pet-food duplicators are not outside enclosure');
|
|
"""
|
|
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("hair-trigger preset runtime smoke test failed")
|
|
ok("uniform tooltip spacing, hair-trigger preset, and distinct physics icons verified")
|
|
|
|
def main() -> None:
|
|
data = manifest()
|
|
check_files_exist(data)
|
|
check_unified_item_registry()
|
|
check_docs_removed()
|
|
check_versions(data)
|
|
check_index_order(data)
|
|
check_service_worker_order(data)
|
|
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()
|
|
champion_inheritance_smoke()
|
|
one_way_routing_smoke()
|
|
colony_limits_and_defaults_smoke()
|
|
check_basic_action_spec_runtime()
|
|
check_social_action_spec_runtime()
|
|
check_action_spec_no_legacy_hooks()
|
|
check_runtime_diagnostics_hooks()
|
|
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()
|
|
check_entity_runtime_boundary()
|
|
check_item_lifecycle_pipeline_boundary()
|
|
check_tarinai_update_pipeline_boundary()
|
|
check_update_policy_boundaries()
|
|
check_processing_split()
|
|
command_dispatcher_smoke()
|
|
check_legacy_behavior_refs()
|
|
check_legacy_physics_props_removed()
|
|
check_redundant_correction_paths_removed()
|
|
check_collision_reason_and_projection_dirty_removed()
|
|
direct_feeding_smoke()
|
|
check_electrocution_gate_chart_and_ascii()
|
|
check_connection_overlay_and_time_detector()
|
|
check_link_dependency_and_plushie_recovery()
|
|
check_splat_link_target_and_appetite_label()
|
|
check_habitat_food_medicine_additions()
|
|
check_parasol_rain_and_preset_updates()
|
|
check_war_fence_sleep_placement_delete_fixes()
|
|
check_hair_trigger_tooltips_and_physics_icons()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|