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:
Brian Fertig 2026-07-24 21:34:27 -06:00
parent 23c558a5d4
commit 1f4f8d1b30
2 changed files with 89 additions and 0 deletions

View File

@ -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,
})),

View File

@ -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');
// ---------------------------------------------------------------------------