diff --git a/src/games/totalannihilation/TALogic.js b/src/games/totalannihilation/TALogic.js index c0e037b..fb1335e 100644 --- a/src/games/totalannihilation/TALogic.js +++ b/src/games/totalannihilation/TALogic.js @@ -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, })), diff --git a/tools/verifyTotalAnnihilation.js b/tools/verifyTotalAnnihilation.js index ec46cdf..d296b1f 100644 --- a/tools/verifyTotalAnnihilation.js +++ b/tools/verifyTotalAnnihilation.js @@ -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'); // ---------------------------------------------------------------------------