feat(total-annihilation): add unit/building repair system

Allow builders to repair damaged friendly units and buildings by
right-clicking them. Repair cost and duration are proportional to the
damage healed (X% HP restoration costs X% of build cost and takes X%
of build time).

Key changes:
- New 'repair' order type with validation (own units only, must be
  damaged, requires a builder)
- Separate _repairPower accumulator so repairing a damaged factory
  doesn't silently speed up its production queue
- Repair drains energy/mass through the same economy machinery as
  construction, including brownout throttling
- HUD hints guide players to right-click damaged allies to repair
- Repair order auto-clears when complete; unqueued orders interrupt it
  while queued orders wait behind it
- Visual waypoint color for repair orders
- Comprehensive test coverage in verifyTotalAnnihilation.js
This commit is contained in:
Brian Fertig 2026-07-24 21:50:34 -06:00
parent 1f4f8d1b30
commit 4b86a85358
5 changed files with 241 additions and 23 deletions

View File

@ -27,7 +27,7 @@ const BTN_W = 88, BTN_H = 74; // build-menu button size
// CTRL is the queue modifier (as in the original game); SHIFT adds to the selection and // CTRL is the queue modifier (as in the original game); SHIFT adds to the selection and
// buys x5 from a factory. Kept in one place so the hint and the bindings cannot drift. // buys x5 from a factory. Kept in one place so the hint and the bindings cannot drift.
const HINT = 'LMB select · drag box · RMB order · CTRL+RMB queue · A attack-move · X stop · H hold · WASD pan'; const HINT = 'LMB select · drag box · RMB order · RMB a damaged ally to repair · CTRL+RMB queue · A attack-move · X stop · H hold';
/** Build-button captions have ~7 characters of room; "Vehicle Plant" needs shortening. */ /** Build-button captions have ~7 characters of room; "Vehicle Plant" needs shortening. */
function shortName(name) { function shortName(name) {
@ -350,6 +350,13 @@ export default class TAHud {
lines.push(`HP ${Math.ceil(lead.hp)} / ${lead.maxHp}`); lines.push(`HP ${Math.ceil(lead.hp)} / ${lead.maxHp}`);
if (lead.site) lines.push(`Under construction — ${Math.round((lead.progress ?? 0) * 100)}%`); if (lead.site) lines.push(`Under construction — ${Math.round((lead.progress ?? 0) * 100)}%`);
if (lead.orders?.length > 1) lines.push(`${lead.orders.length} orders queued`); if (lead.orders?.length > 1) lines.push(`${lead.orders.length} orders queued`);
const job = lead.orders?.[0];
if (job?.type === 'repair') lines.push('Repairing');
else if (job?.type === 'build' || job?.type === 'assist') lines.push('Building');
// A builder is the only thing that can repair, so say so where the player is looking.
if (!lead.site && (this.rules.defById[lead.defId].builds ?? []).length && !lead.isBuilding) {
lines.push('Right-click a damaged ally to repair it');
}
if (!lead.site && lead.hasRally) lines.push('Rally point set'); if (!lead.site && lead.hasRally) lines.push('Rally point set');
for (const w of def.weaponDefs ?? []) lines.push(`${w.name}${w.manual ? ' — manual (attack order)' : ''}`); for (const w of def.weaponDefs ?? []) lines.push(`${w.name}${w.manual ? ' — manual (attack order)' : ''}`);
} else { } else {

View File

@ -148,7 +148,7 @@ function baseEntity(state, army, def) {
burstLeft: (def.weaponDefs ?? []).map(() => 0), burstLeft: (def.weaponDefs ?? []).map(() => 0),
buildTargetId: 0, buildTargetId: 0,
queue: [], jobProgress: 0, rallyX: 0, rallyY: 0, hasRally: false, queue: [], jobProgress: 0, rallyX: 0, rallyY: 0, hasRally: false,
_power: 0, _power: 0, _repairPower: 0,
produceE: 0, produceM: 0, upkeepE: 0, storeE: 0, storeM: 0, produceE: 0, produceM: 0, upkeepE: 0, storeE: 0, storeM: 0,
}; };
} }
@ -238,7 +238,7 @@ function defOf(rules, e) { return rules.defById[e.defId]; }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const ORDER_TYPES = new Set([ const ORDER_TYPES = new Set([
'move', 'attackMove', 'attack', 'stop', 'hold', 'patrol', 'guard', 'build', 'assist', 'move', 'attackMove', 'attack', 'stop', 'hold', 'patrol', 'guard', 'build', 'assist', 'repair',
]); ]);
/** /**
@ -278,6 +278,13 @@ export function issueOrder(state, rules, cmd) {
if (!target || target.army !== army) return { ok: false, error: 'can only assist your own' }; if (!target || target.army !== army) return { ok: false, error: 'can only assist your own' };
if (!canBuild(rules, units[0]) ) return { ok: false, error: 'unit cannot assist' }; if (!canBuild(rules, units[0]) ) return { ok: false, error: 'unit cannot assist' };
} }
if (order.type === 'repair') {
const target = entityById(state, order.targetId);
if (!target || target.army !== army) return { ok: false, error: 'can only repair your own' };
if (target.site) return { ok: false, error: 'use assist on a building under construction' };
if (!units.some((u) => canBuild(rules, u))) return { ok: false, error: 'no builder selected' };
if (target.hp >= target.maxHp) return { ok: false, error: 'already at full health' };
}
if (order.type === 'guard') { if (order.type === 'guard') {
const target = entityById(state, order.targetId); const target = entityById(state, order.targetId);
if (!target || target.army !== army) return { ok: false, error: 'can only guard your own' }; if (!target || target.army !== army) return { ok: false, error: 'can only guard your own' };
@ -306,6 +313,10 @@ function pushOrder(e, order, queue) {
e.path = null; e.path = null;
e.wantPath = false; e.wantPath = false;
e.noPath = false; e.noPath = false;
// Drop the nanolathe link here rather than waiting for stepOrders to notice. Economy runs
// before orders within a tick, so leaving it set would bill the player for one more tick
// of a build or repair they just cancelled.
e.buildTargetId = 0;
} }
e.orders.push(order); e.orders.push(order);
} }
@ -417,7 +428,7 @@ function stepEconomy(state, rules) {
a.eIncome = 0; a.mIncome = 0; a.eUpkeep = 0; a.eDrain = 0; a.mDrain = 0; a.eIncome = 0; a.mIncome = 0; a.eUpkeep = 0; a.eDrain = 0; a.mDrain = 0;
a._mFree = 0; a._mGated = 0; a.upkeepFactor = 1; a._mFree = 0; a._mGated = 0; a.upkeepFactor = 1;
} }
for (const e of state.entities) e._power = 0; for (const e of state.entities) { e._power = 0; e._repairPower = 0; }
// 1. Income and upkeep from completed entities. Mass production is split into what runs // 1. Income and upkeep from completed entities. Mass production is split into what runs
// for free and what is gated behind an energy upkeep (the Mass Generator), because // for free and what is gated behind an energy upkeep (the Mass Generator), because
@ -443,7 +454,12 @@ function stepEconomy(state, rules) {
const target = entityById(state, e.buildTargetId); const target = entityById(state, e.buildTargetId);
if (target && target.army === e.army) { if (target && target.army === e.army) {
const d = Math.hypot(target.x - e.x, target.y - e.y); const d = Math.hypot(target.x - e.x, target.y - e.y);
if (d <= def.buildRange + target.radius) target._power += def.buildPower; if (d > def.buildRange + target.radius) continue;
// Repair power is kept in its OWN accumulator. A damaged factory has build power of
// its own for the unit it is producing; folding an incoming repair into the same
// number would silently speed up that unit's production.
if (target.site) target._power += def.buildPower;
else if (target.hp < target.maxHp) target._repairPower += def.buildPower;
} }
} }
} }
@ -451,19 +467,37 @@ function stepEconomy(state, rules) {
// 3. Demand. // 3. Demand.
const jobs = []; const jobs = [];
for (const e of state.entities) { for (const e of state.entities) {
if (e.dead || e._power <= 0) continue; const a = state.armies[e.army];
if (e.dead || !a) continue;
if (e._power > 0) {
let jobDef = null; let jobDef = null;
if (e.site) jobDef = defOf(rules, e); if (e.site) jobDef = defOf(rules, e);
else if (e.isBuilding && e.queue.length) jobDef = rules.unitById[e.queue[0].defId]; else if (e.isBuilding && e.queue.length) jobDef = rules.unitById[e.queue[0].defId];
if (!jobDef || !(jobDef.buildTime > 0)) continue; if (jobDef && jobDef.buildTime > 0) {
const rate = (e._power / nominal) / jobDef.buildTime; // progress fraction per second const rate = (e._power / nominal) / jobDef.buildTime; // progress fraction per second
const a = state.armies[e.army]; a.eDrain += (jobDef.cost.energy ?? 0) * rate;
if (!a) continue; a.mDrain += (jobDef.cost.mass ?? 0) * rate;
const dE = (jobDef.cost.energy ?? 0) * rate; jobs.push({ e, rate, army: e.army, kind: e.site ? 'site' : 'factory' });
const dM = (jobDef.cost.mass ?? 0) * rate; }
a.eDrain += dE; }
a.mDrain += dM;
jobs.push({ e, rate, army: e.army }); // Repair is priced as a fraction of a fresh build: restoring X% of a unit's HP costs X%
// of its build cost and takes X% of its build time. It therefore runs through exactly the
// same rate, drain and stall machinery as construction — including being throttled in a
// brownout rather than proceeding for free.
if (e._repairPower > 0 && !e.site && e.hp < e.maxHp) {
const def = defOf(rules, e);
if (def.buildTime > 0) {
const full = (e._repairPower / nominal) / def.buildTime;
// Never bill for more than the damage actually outstanding.
const remaining = (e.maxHp - e.hp) / e.maxHp;
const rate = Math.min(full, remaining / dt);
a.eDrain += (def.cost.energy ?? 0) * rate;
a.mDrain += (def.cost.mass ?? 0) * rate;
jobs.push({ e, rate, army: e.army, kind: 'repair' });
}
}
} }
// 4. Stall factors, then apply. // 4. Stall factors, then apply.
@ -499,7 +533,7 @@ function stepEconomy(state, rules) {
for (const j of jobs) { for (const j of jobs) {
const a = state.armies[j.army]; const a = state.armies[j.army];
j.e.__adv = j.rate * a.buildEff * dt; j.adv = j.rate * a.buildEff * dt;
} }
for (const a of state.armies) { for (const a of state.armies) {
@ -513,12 +547,24 @@ function stepEconomy(state, rules) {
a.mass = Math.max(0, Math.min(a.massCap, mNext)); a.mass = Math.max(0, Math.min(a.massCap, mNext));
} }
// 5. Advance construction. // 5. Advance construction and repair.
for (const j of jobs) { for (const j of jobs) {
const e = j.e; const e = j.e;
const adv = e.__adv ?? 0; const adv = j.adv ?? 0;
e.__adv = 0;
if (adv <= 0) continue; if (adv <= 0) continue;
if (j.kind === 'repair') {
e.hp = Math.min(e.maxHp, e.hp + e.maxHp * adv);
state.events.push({ t: 'nanolathe', id: e.id, x: e.x, y: e.y, army: e.army });
if (e.hp >= e.maxHp) {
// Release the builders so they fall through to whatever they were told to do next.
for (const b of state.entities) {
if (b.buildTargetId !== e.id) continue;
b.buildTargetId = 0;
if (b.orders[0]?.type === 'repair' && b.orders[0].targetId === e.id) b.orders.shift();
}
}
continue;
}
if (e.site) { if (e.site) {
const def = defOf(rules, e); const def = defOf(rules, e);
e.progress = Math.min(1, e.progress + adv); e.progress = Math.min(1, e.progress + adv);
@ -627,6 +673,14 @@ function stepOrders(state, rules) {
continue; continue;
} }
// A builder only holds a nanolathe target while its CURRENT order is a construction one.
// Without this, giving a repairing Commander a move order left buildTargetId pointing at
// the patient, and the economy kept pouring resources into the repair — for as long as
// the builder happened to stay in range — even though the player had cancelled it.
if (order.type !== 'build' && order.type !== 'assist' && order.type !== 'repair') {
e.buildTargetId = 0;
}
switch (order.type) { switch (order.type) {
case 'stop': case 'stop':
e.orders.length = 0; e.orders.length = 0;
@ -699,12 +753,18 @@ function stepOrders(state, rules) {
} }
case 'build': case 'build':
case 'assist': { case 'assist':
case 'repair': {
const target = entityById(state, order.targetId); const target = entityById(state, order.targetId);
if (!target) { e.orders.shift(); e.buildTargetId = 0; e.path = null; break; } if (!target) { e.orders.shift(); e.buildTargetId = 0; e.path = null; break; }
if (order.type === 'assist' && !target.site && !target.queue?.length) { if (order.type === 'assist' && !target.site && !target.queue?.length) {
e.orders.shift(); e.buildTargetId = 0; break; e.orders.shift(); e.buildTargetId = 0; break;
} }
// Nothing left to mend — either it was topped up by someone else or it was never
// damaged by the time we arrived.
if (order.type === 'repair' && (target.hp >= target.maxHp || target.site)) {
e.orders.shift(); e.buildTargetId = 0; break;
}
const reach = (def.buildRange ?? 0) + target.radius; const reach = (def.buildRange ?? 0) + target.radius;
const d = Math.hypot(target.x - e.x, target.y - e.y); const d = Math.hypot(target.x - e.x, target.y - e.y);
if (d > reach * 0.9) { if (d > reach * 0.9) {

View File

@ -23,7 +23,7 @@ const MAX_QUEUE_LINES = 24; // selected units whose order queue is drawn
/** Waypoint colour per order type, so a queue reads at a glance. */ /** Waypoint colour per order type, so a queue reads at a glance. */
const ORDER_COLORS = { const ORDER_COLORS = {
move: 0x7dff9b, attackMove: 0xff8a5a, attack: 0xff5a5a, patrol: 0x8ad4ff, move: 0x7dff9b, attackMove: 0xff8a5a, attack: 0xff5a5a, patrol: 0x8ad4ff,
guard: 0xffd27a, assist: 0x8affd0, build: 0x8ad4ff, guard: 0xffd27a, assist: 0x8affd0, build: 0x8ad4ff, repair: 0x8affd0,
}; };
export const DEPTHS = { export const DEPTHS = {

View File

@ -550,6 +550,13 @@ export default class TotalAnnihilationGame extends Phaser.Scene {
this._order({ type: 'assist', targetId: target.id }, queue); this._order({ type: 'assist', targetId: target.id }, queue);
return; return;
} }
// Any unit that can build can also repair, so a future construction unit picks this up
// with no code change — the capability comes from its def, not from being the Commander.
if (target && target.army === this.playerArmy && target.hp < target.maxHp
&& this._selectedEntities().some((e) => this.rules.defById[e.defId].builds?.length && !e.isBuilding)) {
this._order({ type: 'repair', targetId: target.id }, queue);
return;
}
this._order({ type: 'move', x: w.x, y: w.y }, queue); this._order({ type: 'move', x: w.x, y: w.y }, queue);
} }

View File

@ -756,6 +756,150 @@ section('6b. Commander self-repair');
check('a reload does not hand back a free heal', c6b.hp === hpAfterLoad); check('a reload does not hand back a free heal', c6b.hp === hpAfterLoad);
} }
// ---------------------------------------------------------------------------
section('6c. Repair');
// ---------------------------------------------------------------------------
{
const raw = JSON.parse(readFileSync(join(ROOT, 'data/totalannihilation-rules.json'), 'utf8'));
raw.constants.eliminateWhenUnrecoverable = false;
// Self-repair would confound the cost measurements below, so it is off for this fixture.
for (const u of raw.units) delete u.selfHeal;
const rr = compileRules(raw);
const map = generateMap(rr, { seed: 81, size: 'small', symmetry: 'mirror-x' });
const setup = (defId, hpFrac) => {
const st = L.createMatch(rr, { seed: 81, map, armies: [{ armyId: 'arm' }, { armyId: 'core' }] });
st.over = null;
for (const a of st.armies) a.alive = true;
const builder = st.entities.find((e) => e.army === 0);
const patient = L.spawnUnit(st, rr, 0, defId, builder.x + 80, builder.y);
patient.hp = patient.maxHp * hpFrac;
const army = st.armies[0];
army.mass = 99999; army.energy = 99999; army.massCap = 99999; army.energyCap = 99999;
// Silence the builder's own income, so a drop in stored resources IS the repair bill and
// nothing else. Measuring the net change instead would net off the Commander's output.
builder.produceE = 0; builder.produceM = 0;
return { st, builder, patient, army };
};
// A damaged friendly is a legal repair target; a healthy one is not.
{
const { st, builder, patient } = setup('tank', 0.5);
const ok = L.issueOrder(st, rr, { army: 0, unitIds: [builder.id], order: { type: 'repair', targetId: patient.id } });
check('repair order accepted on a damaged friendly', ok.ok, ok.error);
patient.hp = patient.maxHp;
const full = L.issueOrder(st, rr, { army: 0, unitIds: [builder.id], order: { type: 'repair', targetId: patient.id } });
check('repair is refused at full health', !full.ok, full.error);
const foe = st.entities.find((e) => e.army === 1);
const enemy = L.issueOrder(st, rr, { army: 0, unitIds: [builder.id], order: { type: 'repair', targetId: foe.id } });
check('repair is refused on an enemy', !enemy.ok, enemy.error);
}
// Cost and duration are both proportional to the damage healed.
for (const [defId, frac] of [['tank', 0.5], ['tank', 0.25], ['rockettank', 0.5]]) {
const { st, builder, patient, army } = setup(defId, frac);
const def = rr.unitById[defId];
const missing = 1 - frac;
const m0 = army.mass, e0 = army.energy;
L.issueOrder(st, rr, { army: 0, unitIds: [builder.id], order: { type: 'repair', targetId: patient.id } });
let ticks = 0;
const cap = 400 * HZ;
while (ticks < cap && patient.hp < patient.maxHp) { L.tick(st, rr); ticks++; }
check(`${defId} at ${frac * 100}% is repaired to full`, patient.hp >= patient.maxHp - 1e-6,
`${patient.hp.toFixed(0)}/${patient.maxHp}`);
const spentM = m0 - army.mass, spentE = e0 - army.energy;
const wantM = def.cost.mass * missing, wantE = def.cost.energy * missing;
check(`${defId} repair mass cost is ~${(missing * 100) | 0}% of build cost`,
Math.abs(spentM - wantM) < wantM * 0.06 + 1, `${spentM.toFixed(0)} vs ${wantM.toFixed(0)}`);
check(`${defId} repair energy cost is ~${(missing * 100) | 0}% of build cost`,
Math.abs(spentE - wantE) < wantE * 0.06 + 1, `${spentE.toFixed(0)} vs ${wantE.toFixed(0)}`);
// Time: same build power, so healing X% takes X% of the build time.
const bp = rr.unitById.commander.buildPower / rr.constants.buildPowerNominal;
const wantSecs = (def.buildTime * missing) / bp;
const gotSecs = ticks / HZ;
check(`${defId} repair takes ~${(missing * 100) | 0}% of build time`,
Math.abs(gotSecs - wantSecs) < wantSecs * 0.25 + 1.5, `${gotSecs.toFixed(1)}s vs ${wantSecs.toFixed(1)}s`);
}
// Buildings repair too.
{
const { st, builder, army } = setup('tank', 0.99);
const gen = rr.buildingById.energygen;
let site = null;
for (let d = 2; d < 20 && !site; d++) {
const tx = worldToTileX(st.nav, builder.x) + d, ty = worldToTileY(st.nav, builder.y);
if (L.canPlaceAt(st, rr, tx, ty, gen).ok) site = L.placeBuilding(st, rr, 0, 'energygen', tx, ty);
}
check('found room for a building to repair', !!site);
site.site = false; site.progress = 1; site.hp = gen.hp * 0.4;
const before = site.hp;
L.issueOrder(st, rr, { army: 0, unitIds: [builder.id], order: { type: 'repair', targetId: site.id } });
for (let i = 0; i < 200 * HZ && site.hp < site.maxHp; i++) { army.mass = 99999; army.energy = 99999; L.tick(st, rr); }
check('a damaged building can be repaired', site.hp > before && site.hp >= site.maxHp - 1e-6,
`${site.hp.toFixed(0)}/${site.maxHp}`);
}
// The order ends by itself once the patient is whole, and an unqueued order breaks it.
{
const { st, builder, patient } = setup('tank', 0.5);
L.issueOrder(st, rr, { army: 0, unitIds: [builder.id], order: { type: 'repair', targetId: patient.id } });
for (let i = 0; i < 400 * HZ && builder.orders.length; i++) L.tick(st, rr);
check('the repair order clears itself when done', builder.orders.length === 0);
check('the builder releases its build target', builder.buildTargetId === 0);
}
{
const { st, builder, patient } = setup('tank', 0.5);
L.issueOrder(st, rr, { army: 0, unitIds: [builder.id], order: { type: 'repair', targetId: patient.id } });
for (let i = 0; i < 3 * HZ; i++) L.tick(st, rr);
const mid = patient.hp;
check('repair is under way', mid > patient.maxHp * 0.5);
// A plain (unqueued) order must break the heal outright.
L.issueOrder(st, rr, { army: 0, unitIds: [builder.id], order: { type: 'move', x: builder.x + 600, y: builder.y } });
check('a new unqueued order replaces the repair', builder.orders.length === 1
&& builder.orders[0].type === 'move');
for (let i = 0; i < 5 * HZ; i++) L.tick(st, rr);
check('the interrupted patient stops healing', Math.abs(patient.hp - mid) < 1e-6,
`${patient.hp.toFixed(1)} vs ${mid.toFixed(1)}`);
}
// A QUEUED order must not break it — the repair stays at the head of the queue.
{
const { st, builder, patient } = setup('tank', 0.5);
L.issueOrder(st, rr, { army: 0, unitIds: [builder.id], order: { type: 'repair', targetId: patient.id } });
for (let i = 0; i < 2 * HZ; i++) L.tick(st, rr);
L.issueOrder(st, rr, { army: 0, unitIds: [builder.id],
order: { type: 'move', x: builder.x + 600, y: builder.y }, queue: true });
const mid = patient.hp;
for (let i = 0; i < 3 * HZ; i++) L.tick(st, rr);
check('a queued order leaves the repair running', patient.hp > mid,
`${patient.hp.toFixed(1)} vs ${mid.toFixed(1)}`);
check('the queued order is still waiting behind it', builder.orders.length === 2
&& builder.orders[0].type === 'repair');
}
// A damaged factory being repaired must not have its production accelerated.
{
const { st, builder, army } = setup('tank', 0.99);
const plant = L.placeBuilding(st, rr, 0, 'vehicleplant',
worldToTileX(st.nav, builder.x) + 6, worldToTileY(st.nav, builder.y));
check('placed a factory for the mixing test', !!plant);
if (plant) {
plant.site = false; plant.progress = 1;
plant.hp = rr.buildingById.vehicleplant.hp * 0.5;
L.issueOrder(st, rr, { army: 0, order: { type: 'factoryEnqueue', factoryId: plant.id, defId: 'tank', count: 1 } });
L.issueOrder(st, rr, { army: 0, unitIds: [builder.id], order: { type: 'repair', targetId: plant.id } });
let t = 0;
for (; t < 30 * HZ; t++) { army.mass = 99999; army.energy = 99999; L.tick(st, rr); }
const bp = rr.buildingById.vehicleplant.buildPower / rr.constants.buildPowerNominal;
const expected = Math.min(1, (t / HZ) * bp / rr.unitById.tank.buildTime);
check('repairing a factory does not speed up its production',
Math.abs(plant.jobProgress - expected) < 0.08 || plant.jobProgress < expected + 0.08,
`progress ${plant.jobProgress.toFixed(2)} vs expected ${expected.toFixed(2)}`);
}
}
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
section('7. Fog of war'); section('7. Fog of war');
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------