#!/usr/bin/env node "use strict"; const fs = require("fs"); const vm = require("vm"); const path = require("path"); function assert(condition, message) { if (!condition) throw new Error(message); } function createHarness({ owner = true, lethal = true, neverStopped = true, clearedOwner = true } = {}) { let unlocks = 0; let damageCalls = 0; const ownerId = "t-owner"; const tarinai = { id: owner ? ownerId : "t-other", releasedLiveId: "", dead: false, x: 100, y: 100, radius: 20, vx: 0, vy: 0, fallTimer: 0, fallMax: 0, enterPanic() {}, }; const item = { type: "zunchi", dead: false, x: 100, y: 100, prevX: 100, prevY: 100, r: 14, amount: 240, vx: 200, vy: 0, achievementBurstOwnerId: ownerId, achievementBurstNeverStopped: neverStopped, achievementBurstClearedOwner: clearedOwner, }; const world = { time: 10, w: 1000, h: 1000, tarinai: [tarinai], drawListDirty: false, liveTarinaiById(id) { return tarinai.id === id && !tarinai.dead ? tarinai : null; }, nearbyTarinai() { return [tarinai]; }, isTarinaiHiddenInNestBox() { return false; }, applyImpactDamage(target) { damageCalls += 1; if (!lethal) return true; target.dead = true; target.releasedLiveId = target.id; target.id = ""; return true; }, spawnFallEffect() {}, effects: [], }; const context = { console, globalThis: null, window: undefined, CONFIG: { worldPadding: 30 }, clamp: (value, min, max) => Math.max(min, Math.min(max, value)), distXY: (ax, ay, bx, by) => Math.hypot(ax - bx, ay - by), rand: () => 0, Effect: function Effect() {}, TarinaiPhysics: { applyKinematicItemMotion() {} }, TarinaiItemDynamicBallSystem: { resolveObstacleCollisions() {} }, TarinaiAchievements: { recordSelfZunchiDeath() { unlocks += 1; }, }, }; context.globalThis = context; vm.createContext(context); const source = fs.readFileSync(path.join(__dirname, "..", "js", "item_dynamic_zunchi_system.js"), "utf8"); vm.runInContext(source, context, { filename: "item_dynamic_zunchi_system.js" }); return { context, world, tarinai, item, get unlocks() { return unlocks; }, get damageCalls() { return damageCalls; } }; } function runOne(options = {}) { const h = createHarness(options); h.context.TarinaiItemDynamicZunchiSystem.updateMotion(h.item, 0.016, h.world); return h; } let result = runOne({ owner: true, lethal: true, neverStopped: true, clearedOwner: true }); assert(result.unlocks === 1, "fatal owner return collision should unlock even after live ID release"); result = runOne({ owner: true, lethal: false, neverStopped: true, clearedOwner: true }); assert(result.unlocks === 0, "nonfatal owner collision must not unlock"); result = runOne({ owner: false, lethal: true, neverStopped: true, clearedOwner: true }); assert(result.unlocks === 0, "fatal collision with another tarinai must not unlock"); result = runOne({ owner: true, lethal: true, neverStopped: false, clearedOwner: true }); assert(result.unlocks === 0, "stopped or player-held burst zunchi must remain disqualified"); // Exact route prerequisite: the emitted zunchi begins inside its owner, but that // spawn overlap must neither damage the owner nor consume the later return hit. result = runOne({ owner: true, lethal: true, neverStopped: true, clearedOwner: false }); assert(result.unlocks === 0, "spawn overlap must not unlock"); assert(result.damageCalls === 0, "spawn overlap must not damage or slow the owner collision path"); assert(result.item.achievementBurstClearedOwner === false, "spawn overlap must remain uncleared while still inside owner"); // Move outside once (the route's outbound leg / bounce-fence approach), then // return at lethal speed. This must unlock. result = createHarness({ owner: true, lethal: true, neverStopped: true, clearedOwner: false }); result.item.x = 180; result.item.prevX = 100; result.item.vx = 320; result.context.TarinaiItemDynamicZunchiSystem.updateMotion(result.item, 0.016, result.world); assert(result.item.achievementBurstClearedOwner === true, "burst zunchi must arm after clearing its owner"); assert(result.damageCalls === 0, "outbound leg must not damage the owner"); result.item.x = 100; result.item.prevX = 180; result.item.vx = -900; // representative accelerated return after a bounce fence result.item.vy = 0; result.world.time += 0.2; result.context.TarinaiItemDynamicZunchiSystem.updateMotion(result.item, 0.016, result.world); assert(result.unlocks === 1, "cleared, accelerated return collision with owner must unlock when fatal"); console.log("[OK] self-zunchi spawn-clear-return regression cases passed");