Compare commits

..

No commits in common. "4b86a853589b48754b85e089b1357026e937ed2b" and "ddac7fcd5a2b279fc6e2b08e1eff51f553319c2d" have entirely different histories.

7 changed files with 27 additions and 398 deletions

View File

@ -386,10 +386,6 @@
"size": "medium",
"radius": 20,
"hp": 7000,
"selfHeal": {
"fractionPerMinute": 0.33,
"pauseAfterDamageSec": 30
},
"speed": 62,
"turnRate": 3.5,
"moveClass": "foot",

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
// 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 · RMB a damaged ally to repair · CTRL+RMB queue · A attack-move · X stop · H hold';
const HINT = 'LMB select · drag box · RMB order · CTRL+RMB queue · A attack-move · X stop · H hold · WASD pan';
/** Build-button captions have ~7 characters of room; "Vehicle Plant" needs shortening. */
function shortName(name) {
@ -350,13 +350,6 @@ 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 {
@ -368,8 +361,7 @@ export default class TAHud {
this.selDetail.setText(lines.join('\n'));
if (placementDef) {
this.hint.setText(`Placing ${placementDef.name} — LMB to site it, hold CTRL to queue several `
+ '(release CTRL when done), Esc to cancel');
this.hint.setText(`Placing ${placementDef.name} — LMB to site it, hold CTRL to queue several, Esc to cancel`);
} else {
this.hint.setText(HINT);
}

View File

@ -141,14 +141,11 @@ 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,
queue: [], jobProgress: 0, rallyX: 0, rallyY: 0, hasRally: false,
_power: 0, _repairPower: 0,
_power: 0,
produceE: 0, produceM: 0, upkeepE: 0, storeE: 0, storeM: 0,
};
}
@ -238,7 +235,7 @@ function defOf(rules, e) { return rules.defById[e.defId]; }
// ---------------------------------------------------------------------------
const ORDER_TYPES = new Set([
'move', 'attackMove', 'attack', 'stop', 'hold', 'patrol', 'guard', 'build', 'assist', 'repair',
'move', 'attackMove', 'attack', 'stop', 'hold', 'patrol', 'guard', 'build', 'assist',
]);
/**
@ -278,13 +275,6 @@ 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' };
@ -313,10 +303,6 @@ 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);
}
@ -428,7 +414,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; e._repairPower = 0; }
for (const e of state.entities) e._power = 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
@ -454,12 +440,7 @@ 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) 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;
if (d <= def.buildRange + target.radius) target._power += def.buildPower;
}
}
}
@ -467,37 +448,19 @@ 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 (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' });
}
}
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 });
}
// 4. Stall factors, then apply.
@ -533,7 +496,7 @@ function stepEconomy(state, rules) {
for (const j of jobs) {
const a = state.armies[j.army];
j.adv = j.rate * a.buildEff * dt;
j.e.__adv = j.rate * a.buildEff * dt;
}
for (const a of state.armies) {
@ -547,24 +510,12 @@ function stepEconomy(state, rules) {
a.mass = Math.max(0, Math.min(a.massCap, mNext));
}
// 5. Advance construction and repair.
// 5. Advance construction.
for (const j of jobs) {
const e = j.e;
const adv = j.adv ?? 0;
const adv = e.__adv ?? 0;
e.__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);
@ -673,14 +624,6 @@ 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;
@ -753,18 +696,12 @@ function stepOrders(state, rules) {
}
case 'build':
case 'assist':
case 'repair': {
case 'assist': {
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) {
@ -1232,27 +1169,10 @@ function applyAoe(state, rules, x, y, w, source) {
export function applyDamage(state, rules, target, amount, source) {
if (target.dead || amount <= 0) return;
target.hp -= amount;
target.lastDamagedTick = state.tick;
state.events.push({ t: 'damaged', id: target.id, amount, x: target.x, y: target.y });
if (target.hp <= 0) killEntity(state, rules, target, source);
}
/**
* Self-repair. Only defs that declare `selfHeal` regenerate, and the clock restarts on every
* hit — so a Commander recovers between engagements but never out-heals incoming fire, and
* cannot be used to tank a fight it is losing.
*/
function stepRegen(state, rules) {
for (const e of state.entities) {
if (e.dead || e.site) continue;
const heal = defOf(rules, e).selfHeal;
if (!heal) continue;
if (e.hp >= e.maxHp) continue;
if (state.tick - e.lastDamagedTick < heal.pauseTicks) continue;
e.hp = Math.min(e.maxHp, e.hp + heal.hpPerTick);
}
}
function killEntity(state, rules, e, source) {
if (e.dead) return;
e.dead = true;
@ -1383,7 +1303,6 @@ export function tick(state, rules) {
stepSeparation(state, rules);
stepCombat(state, rules);
stepProjectiles(state, rules);
stepRegen(state, rules);
if (state.tick % 4 === 0) computeVision(state, rules);
@ -1475,7 +1394,6 @@ 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

@ -208,16 +208,6 @@ export function compileRules(json) {
u.isBuilding = false;
u.weaponDefs = (u.weapons ?? []).map((wid) => weaponById[wid]);
u.maxRange = autoRangeOf(u.weaponDefs);
// Regeneration is per-def data, so any unit can be given it later without code. Rates
// are converted to per-tick here so the sim never divides in its hot loop.
if (u.selfHeal) {
const frac = u.selfHeal.fractionPerMinute;
if (!(frac > 0)) fail(`unit "${u.id}" selfHeal.fractionPerMinute must be positive`);
const pause = u.selfHeal.pauseAfterDamageSec ?? 0;
if (!(pause >= 0)) fail(`unit "${u.id}" selfHeal.pauseAfterDamageSec must be >= 0`);
u.selfHeal.hpPerTick = (u.hp * frac) / 60 / c.tickHz;
u.selfHeal.pauseTicks = Math.round(pause * c.tickHz);
}
u.spritePx = u.spritePx ?? sc.radius * 2;
buildable[u.id] = u;
}

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. */
const ORDER_COLORS = {
move: 0x7dff9b, attackMove: 0xff8a5a, attack: 0xff5a5a, patrol: 0x8ad4ff,
guard: 0xffd27a, assist: 0x8affd0, build: 0x8ad4ff, repair: 0x8affd0,
guard: 0xffd27a, assist: 0x8affd0, build: 0x8ad4ff,
};
export const DEPTHS = {

View File

@ -198,7 +198,6 @@ export default class TotalAnnihilationGame extends Phaser.Scene {
}
_teardown() {
if (this._onBlur) this.game.events.off('blur', this._onBlur);
this._endMatch();
this.screen?.destroy();
this.music?.destroy?.();
@ -214,15 +213,6 @@ export default class TotalAnnihilationGame extends Phaser.Scene {
this._updateCamera(delta);
// Releasing the queue modifier ends a chained build run, so the player is not forced to
// place one more building (or reach for Esc) just to put the cursor down. Only a run that
// was actually chained with CTRL is cancelled this way — picking a building from the menu
// and never touching CTRL leaves the placement alone.
if (this.placement?.chained && !this._queueModDown()) {
this._cancelPlacement();
this.hud.toast('Build queue ended');
}
// The AI thinks on sim ticks, not render frames, so it is stepped inside the same
// fixed-step loop the simulation uses — otherwise its cadence would ride framerate.
const before = st.tick;
@ -323,25 +313,7 @@ export default class TotalAnnihilationGame extends Phaser.Scene {
// Input
// -------------------------------------------------------------------------
/**
* Is the queue modifier held right now? Tracked from the DOM events Phaser forwards rather
* than polled off a Key object, so it covers Cmd as well as either Ctrl key, and reads
* false again after a focus loss swallows the keyup.
*/
_queueModDown() {
return !!this._queueMod;
}
_bindInput() {
const trackMod = (ev) => { this._queueMod = !!(ev?.ctrlKey || ev?.metaKey); };
this.input.keyboard.on('keydown', trackMod);
this.input.keyboard.on('keyup', trackMod);
this.input.on('pointermove', (p) => trackMod(p.event));
this.input.on('pointerdown', (p) => trackMod(p.event));
// A blurred window never delivers the keyup, which would otherwise strand the cursor.
this._onBlur = () => { this._queueMod = false; };
this.game.events.on('blur', this._onBlur);
this.input.on('pointermove', () => { this._pointerLive = true; });
this.input.on('pointerdown', (p) => { this._pointerLive = true; this._onPointerDown(p); });
this.input.on('pointerup', (p) => this._onPointerUp(p));
@ -550,13 +522,6 @@ 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);
}
@ -597,7 +562,7 @@ export default class TotalAnnihilationGame extends Phaser.Scene {
_beginPlacement(def) {
const builder = this._selectedEntities().find((e) => !e.isBuilding && this.rules.defById[e.defId].builds?.includes(def.id));
if (!builder) return;
this.placement = { def, builderId: builder.id, chained: false };
this.placement = { def, builderId: builder.id };
this.view.highlightMassSpots(!!def.terrainMultiplier);
this.input.on('pointermove', this._onPlacementMove, this);
this._onPlacementMove(this.input.activePointer);
@ -630,11 +595,8 @@ export default class TotalAnnihilationGame extends Phaser.Scene {
queue,
});
if (!r.ok) { this.hud.toast(r.error ?? 'Cannot build there', '#ff9a6b'); return; }
// A plain click places one and puts the cursor down. Holding the queue modifier keeps the
// building loaded so a row of generators is one gesture — and `chained` records that the
// player got there by holding CTRL, so releasing it can end the run (see update()).
if (queue) p.chained = true;
else this._cancelPlacement();
// Shift keeps the cursor loaded so a row of generators is one gesture.
if (!queue) this._cancelPlacement();
}
_cancelPlacement() {

View File

@ -671,235 +671,6 @@ 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('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');
// ---------------------------------------------------------------------------