From 4b86a853589b48754b85e089b1357026e937ed2b Mon Sep 17 00:00:00 2001 From: Brian Fertig Date: Fri, 24 Jul 2026 21:50:34 -0600 Subject: [PATCH] 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 --- src/games/totalannihilation/TAHud.js | 9 +- src/games/totalannihilation/TALogic.js | 102 ++++++++++--- src/games/totalannihilation/TAWorldView.js | 2 +- .../TotalAnnihilationGame.js | 7 + tools/verifyTotalAnnihilation.js | 144 ++++++++++++++++++ 5 files changed, 241 insertions(+), 23 deletions(-) diff --git a/src/games/totalannihilation/TAHud.js b/src/games/totalannihilation/TAHud.js index 330751a..8d04cdd 100644 --- a/src/games/totalannihilation/TAHud.js +++ b/src/games/totalannihilation/TAHud.js @@ -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 // 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. */ function shortName(name) { @@ -350,6 +350,13 @@ export default class TAHud { lines.push(`HP ${Math.ceil(lead.hp)} / ${lead.maxHp}`); 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`); + 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'); for (const w of def.weaponDefs ?? []) lines.push(`${w.name}${w.manual ? ' — manual (attack order)' : ''}`); } else { diff --git a/src/games/totalannihilation/TALogic.js b/src/games/totalannihilation/TALogic.js index fb1335e..87a983c 100644 --- a/src/games/totalannihilation/TALogic.js +++ b/src/games/totalannihilation/TALogic.js @@ -148,7 +148,7 @@ function baseEntity(state, army, def) { burstLeft: (def.weaponDefs ?? []).map(() => 0), buildTargetId: 0, 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, }; } @@ -238,7 +238,7 @@ function defOf(rules, e) { return rules.defById[e.defId]; } // --------------------------------------------------------------------------- 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 (!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') { const target = entityById(state, order.targetId); 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.wantPath = 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); } @@ -417,7 +428,7 @@ function stepEconomy(state, rules) { a.eIncome = 0; a.mIncome = 0; a.eUpkeep = 0; a.eDrain = 0; a.mDrain = 0; 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 // 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); if (target && target.army === e.army) { 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. const jobs = []; for (const e of state.entities) { - if (e.dead || e._power <= 0) continue; - let jobDef = null; - if (e.site) jobDef = defOf(rules, e); - else if (e.isBuilding && e.queue.length) jobDef = rules.unitById[e.queue[0].defId]; - if (!jobDef || !(jobDef.buildTime > 0)) continue; - const rate = (e._power / nominal) / jobDef.buildTime; // progress fraction per second const a = state.armies[e.army]; - if (!a) continue; - const dE = (jobDef.cost.energy ?? 0) * rate; - const dM = (jobDef.cost.mass ?? 0) * rate; - a.eDrain += dE; - a.mDrain += dM; - jobs.push({ e, rate, army: e.army }); + if (e.dead || !a) continue; + + if (e._power > 0) { + let jobDef = null; + if (e.site) jobDef = defOf(rules, e); + else if (e.isBuilding && e.queue.length) jobDef = rules.unitById[e.queue[0].defId]; + if (jobDef && jobDef.buildTime > 0) { + const rate = (e._power / nominal) / jobDef.buildTime; // progress fraction per second + a.eDrain += (jobDef.cost.energy ?? 0) * rate; + a.mDrain += (jobDef.cost.mass ?? 0) * rate; + jobs.push({ e, rate, army: e.army, kind: e.site ? 'site' : 'factory' }); + } + } + + // 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. @@ -499,7 +533,7 @@ function stepEconomy(state, rules) { for (const j of jobs) { 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) { @@ -513,12 +547,24 @@ function stepEconomy(state, rules) { a.mass = Math.max(0, Math.min(a.massCap, mNext)); } - // 5. Advance construction. + // 5. Advance construction and repair. for (const j of jobs) { const e = j.e; - const adv = e.__adv ?? 0; - e.__adv = 0; + const adv = j.adv ?? 0; 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) { const def = defOf(rules, e); e.progress = Math.min(1, e.progress + adv); @@ -627,6 +673,14 @@ function stepOrders(state, rules) { 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) { case 'stop': e.orders.length = 0; @@ -699,12 +753,18 @@ function stepOrders(state, rules) { } case 'build': - case 'assist': { + case 'assist': + case 'repair': { const target = entityById(state, order.targetId); if (!target) { e.orders.shift(); e.buildTargetId = 0; e.path = null; break; } if (order.type === 'assist' && !target.site && !target.queue?.length) { 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 d = Math.hypot(target.x - e.x, target.y - e.y); if (d > reach * 0.9) { diff --git a/src/games/totalannihilation/TAWorldView.js b/src/games/totalannihilation/TAWorldView.js index ca3d9b1..2f09d0f 100644 --- a/src/games/totalannihilation/TAWorldView.js +++ b/src/games/totalannihilation/TAWorldView.js @@ -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. */ const ORDER_COLORS = { 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 = { diff --git a/src/games/totalannihilation/TotalAnnihilationGame.js b/src/games/totalannihilation/TotalAnnihilationGame.js index fac1107..6cc94fd 100644 --- a/src/games/totalannihilation/TotalAnnihilationGame.js +++ b/src/games/totalannihilation/TotalAnnihilationGame.js @@ -550,6 +550,13 @@ export default class TotalAnnihilationGame extends Phaser.Scene { this._order({ type: 'assist', targetId: target.id }, queue); 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); } diff --git a/tools/verifyTotalAnnihilation.js b/tools/verifyTotalAnnihilation.js index d296b1f..64b5152 100644 --- a/tools/verifyTotalAnnihilation.js +++ b/tools/verifyTotalAnnihilation.js @@ -756,6 +756,150 @@ section('6b. Commander self-repair'); 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'); // ---------------------------------------------------------------------------