fix(totalannihilation): ensure self-heal damage clock survives serialization
- Initialize lastDamagedTick to -1e9 for fresh units (finite sentinel to avoid JSON.stringify null) - Add lastDamagedTick to serialize/deserialize so the pause timer persists across save/load - Add comprehensive tests for self-heal behavior: - Rate verification (~33% max HP/min) - 30s pause after damage and clock restart on new hits - Sustained fire suppression - No overhealing - Non-selfHeal units don't regenerate - Damage clock survives save/load round-trip
This commit is contained in:
parent
23c558a5d4
commit
1f4f8d1b30
|
|
@ -141,6 +141,9 @@ function baseEntity(state, army, def) {
|
|||
destX: 0, destY: 0, slotX: 0, slotY: 0,
|
||||
wantPath: false, noPath: false,
|
||||
stuckTicks: 0, blockedTicks: 0,
|
||||
// Far enough in the past that a fresh unit is eligible to regenerate immediately.
|
||||
// A finite sentinel rather than -Infinity, which JSON.stringify turns into null.
|
||||
lastDamagedTick: -1e9,
|
||||
targetId: 0, reload: (def.weaponDefs ?? []).map(() => 0),
|
||||
burstLeft: (def.weaponDefs ?? []).map(() => 0),
|
||||
buildTargetId: 0,
|
||||
|
|
@ -1412,6 +1415,7 @@ export function serialize(state) {
|
|||
orders: e.orders, queue: e.queue, jobProgress: Math.round(e.jobProgress * 1000) / 1000,
|
||||
rallyX: e.rallyX, rallyY: e.rallyY, hasRally: e.hasRally,
|
||||
targetId: e.targetId, buildTargetId: e.buildTargetId,
|
||||
lastDamagedTick: e.lastDamagedTick,
|
||||
reload: e.reload, burstLeft: e.burstLeft,
|
||||
terrainBonus: e.terrainBonus,
|
||||
})),
|
||||
|
|
|
|||
|
|
@ -671,6 +671,91 @@ section('6. Combat');
|
|||
check('commander has a death explosion', dth && dth.radius > 200 && dth.damage > 500);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
section('6b. Commander self-repair');
|
||||
// ---------------------------------------------------------------------------
|
||||
{
|
||||
const raw = JSON.parse(readFileSync(join(ROOT, 'data/totalannihilation-rules.json'), 'utf8'));
|
||||
raw.constants.eliminateWhenUnrecoverable = false; // a lone Commander must not end the match
|
||||
const hr = compileRules(raw);
|
||||
const map = generateMap(hr, { seed: 71, size: 'small', symmetry: 'mirror-x' });
|
||||
const cdef = hr.unitById.commander;
|
||||
check('the Commander declares self-repair', !!cdef.selfHeal);
|
||||
check('only the Commander regenerates by default',
|
||||
hr.units.filter((u) => u.selfHeal).length === 1);
|
||||
|
||||
const fresh = () => {
|
||||
const st = L.createMatch(hr, { seed: 71, map, armies: [{ armyId: 'arm' }, { armyId: 'core' }] });
|
||||
st.over = null;
|
||||
for (const a of st.armies) a.alive = true;
|
||||
return st;
|
||||
};
|
||||
const run = (st, secs) => { for (let i = 0; i < secs * HZ; i++) L.tick(st, hr); };
|
||||
|
||||
// Rate: about a third of max HP per minute.
|
||||
const st = fresh();
|
||||
const cmd = st.entities.find((e) => e.army === 0);
|
||||
cmd.hp = cmd.maxHp * 0.2;
|
||||
const startHp = cmd.hp;
|
||||
run(st, 60);
|
||||
const gained = (cmd.hp - startHp) / cmd.maxHp;
|
||||
check('regenerates ~33% of max HP per minute', Math.abs(gained - 0.33) < 0.02,
|
||||
`${(gained * 100).toFixed(1)}% in 60s`);
|
||||
|
||||
// Damage pauses regeneration for the full 30 seconds, then it resumes.
|
||||
const st2 = fresh();
|
||||
const c2 = st2.entities.find((e) => e.army === 0);
|
||||
c2.hp = c2.maxHp * 0.5;
|
||||
L.applyDamage(st2, hr, c2, 100, { army: 1, id: 0 });
|
||||
const afterHit = c2.hp;
|
||||
run(st2, 25);
|
||||
check('no regeneration within 30s of taking damage', c2.hp === afterHit,
|
||||
`healed ${(c2.hp - afterHit).toFixed(1)} in 25s`);
|
||||
run(st2, 10); // now 35s since the hit
|
||||
check('regeneration resumes after the pause', c2.hp > afterHit,
|
||||
`healed ${(c2.hp - afterHit).toFixed(1)} by 35s`);
|
||||
|
||||
// Every fresh hit restarts the clock, so sustained fire suppresses it entirely.
|
||||
const st3 = fresh();
|
||||
const c3 = st3.entities.find((e) => e.army === 0);
|
||||
c3.hp = c3.maxHp * 0.5;
|
||||
let expected = c3.hp;
|
||||
for (let s2 = 0; s2 < 60; s2++) {
|
||||
L.applyDamage(st3, hr, c3, 10, { army: 1, id: 0 });
|
||||
expected -= 10;
|
||||
run(st3, 1);
|
||||
}
|
||||
check('being hit every second suppresses regeneration entirely',
|
||||
Math.abs(c3.hp - expected) < 1e-6, `${c3.hp.toFixed(1)} vs ${expected.toFixed(1)}`);
|
||||
|
||||
// Never overheals, and a unit with no selfHeal never recovers at all.
|
||||
const st4 = fresh();
|
||||
const c4 = st4.entities.find((e) => e.army === 0);
|
||||
c4.hp = c4.maxHp - 5;
|
||||
run(st4, 120);
|
||||
check('regeneration stops at full health', c4.hp === c4.maxHp, `${c4.hp}/${c4.maxHp}`);
|
||||
|
||||
const st5 = fresh();
|
||||
const tank = L.spawnUnit(st5, hr, 0, 'tank', c4.x + 200, c4.y);
|
||||
tank.hp = tank.maxHp * 0.5;
|
||||
const tankHp = tank.hp;
|
||||
run(st5, 60);
|
||||
check('units without selfHeal do not regenerate', tank.hp === tankHp, `${tank.hp} vs ${tankHp}`);
|
||||
|
||||
// The pause must survive a save/load, or reloading mid-fight grants a free heal.
|
||||
const st6 = fresh();
|
||||
const c6 = st6.entities.find((e) => e.army === 0);
|
||||
c6.hp = c6.maxHp * 0.5;
|
||||
L.applyDamage(st6, hr, c6, 50, { army: 1, id: 0 });
|
||||
const back = L.deserialize(hr, L.serialize(st6));
|
||||
const c6b = back.entities.find((e) => e.army === 0 && e.defId === 'commander');
|
||||
check('the damage clock round-trips through a save',
|
||||
c6b.lastDamagedTick === c6.lastDamagedTick, `${c6b.lastDamagedTick} vs ${c6.lastDamagedTick}`);
|
||||
const hpAfterLoad = c6b.hp;
|
||||
for (let i = 0; i < 25 * HZ; i++) L.tick(back, hr);
|
||||
check('a reload does not hand back a free heal', c6b.hp === hpAfterLoad);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
section('7. Fog of war');
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
Loading…
Reference in New Issue