This commit is contained in:
33333-33333 2026-07-17 12:25:23 +09:00
commit 61718e2981
18 changed files with 572 additions and 190 deletions

View file

@ -35,7 +35,8 @@ checks = {
'direct feed is food-only': 'if (foodGift) {' in read('js/tarinai_direct_feeding_system.js') and 'recordDirectFeed?.' in read('js/tarinai_direct_feeding_system.js'),
'ritual ignition checks state before mutation': 'const ignitedDuringBirthRitual = !already' in read('js/tarinai_disease_nest.js'),
'soccer unlock follows fatal ball impact': 'if (t.dead && ball.type === "ball")' in read('js/collision_response_system.js'),
'self-zunchi unlock checks owner and never-stopped': 'item.achievementBurstOwnerId === t.id && item.achievementBurstNeverStopped !== false' in read('js/item_dynamic_zunchi_system.js'),
'self-zunchi return collision is armed only after owner clearance': 'achievementBurstClearedOwner = false' in read('js/world_combat_effects.js') and 'updateBurstOwnerClearance(item, worldRef)' in read('js/item_dynamic_zunchi_system.js') and 'if (isBurstOwner && item.achievementBurstClearedOwner !== true) continue' in read('js/item_dynamic_zunchi_system.js'),
'self-zunchi unlock survives fatal ID release': 'const hitTarinaiIdBeforeDamage = String(t.id || t.releasedLiveId || "")' in read('js/item_dynamic_zunchi_system.js') and 'const eligibleSelfZunchiDeath = Boolean(' in read('js/item_dynamic_zunchi_system.js') and 'if (t.dead && eligibleSelfZunchiDeath)' in read('js/item_dynamic_zunchi_system.js'),
'held launched zunchi is disqualified': 'if (item.playerHeld || item._heldByPlayer)' in read('js/item_dynamic_zunchi_system.js') and 'target.achievementBurstNeverStopped = false' in read('js/ui_input_shared.js'),
'generation runs persist in saves': 'achievementNaturalSlaveGenerations' in read('js/snapshot_system.js') and 'achievementNaturalKingGenerations' in read('js/snapshot_system.js'),
'direct-feed field progress persists in saves': 'achievementDirectFeedCount' in read('js/snapshot_system.js'),

View file

@ -161,6 +161,12 @@ function findCard(h,id){const stack=[h.elements.get("achievementList")];while(st
for(let i=0;i<5;i++){const ant={world:ew2};h.api.recordAntDamageSource(ant,{type:"firecracker"},{world:ew2});h.api.recordAntKilled({world:ew2,ant});}
ew2.tarinai[0].dead=true;const resetAnt={world:ew2};h.api.recordAntDamageSource(resetAnt,{type:"firecracker"},{world:ew2});h.api.recordAntKilled({world:ew2,ant:resetAnt});
assert((ew2.achievementEnemyAntKills||0)===0,"population below 30 did not reset enemy streak");
h=harness();const ewDip={time:20,tarinai:alive(30)};
for(let i=0;i<5;i++){const ant={world:ewDip};h.api.recordAntDamageSource(ant,{type:"firecracker"},{world:ewDip});h.api.recordAntKilled({world:ewDip,ant});}
ewDip.tarinai[0].dead=true;h.api.recordDeath({world:ewDip,tarinai:ewDip.tarinai[0],reason:"天寿を全うした"});
assert((ewDip.achievementEnemyAntKills||0)===0,"population dip without an ant kill preserved the enemy streak");
ewDip.tarinai.push({dead:false});const afterDip={world:ewDip};h.api.recordAntDamageSource(afterDip,{type:"firecracker"},{world:ewDip});h.api.recordAntKilled({world:ewDip,ant:afterDip});
assert((ewDip.achievementEnemyAntKills||0)===1,"enemy streak did not restart from one after population recovery");
const ordinary={world:ew2};h.api.recordAntDamageSource(ordinary,{type:"food"},{world:ew2});h.api.recordAntKilled({world:ew2,ant:ordinary});assert((ew2.achievementEnemyAntKills||0)===0,"non-danger ant kill counted");
h=harness();const ew3={time:20,tarinai:alive(30)};const overwritten={world:ew3};h.api.recordAntDamageSource(overwritten,{type:"firecracker"},{world:ew3});h.api.recordAntDamageSource(overwritten,{id:"t0"},{world:ew3});h.api.recordAntKilled({world:ew3,ant:overwritten});assert((ew3.achievementEnemyAntKills||0)===0,"stale danger attribution survived later non-danger damage");

View file

@ -2016,6 +2016,15 @@ 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;

View file

@ -0,0 +1,129 @@
#!/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");