feat(totalannihilation): add auto-heal for idle builders

Idle builder units now automatically repair damaged allied structures
and units within their build range. A priority system ensures defensive
buildings are repaired before factories and other buildings. Auto-heal
orders are marked as non-player-assigned so explicit player orders
always take precedence. Builders can re-prioritize targets on a
retargeting cadence, similar to combat target acquisition.

Includes visual asset updates for TA arm and core units/structures.
Adjusts test to isolate passive self-heal from the new auto-heal mechanic.
This commit is contained in:
Brian Fertig 2026-07-25 20:36:50 -06:00
parent 39059a3551
commit 195e88f2dc
10 changed files with 60 additions and 1 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 946 KiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 147 KiB

After

Width:  |  Height:  |  Size: 203 KiB

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1006 KiB

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 141 KiB

After

Width:  |  Height:  |  Size: 184 KiB

Binary file not shown.

View File

@ -710,6 +710,36 @@ function orderDestination(e, order) {
return { x: order.sx ?? order.x, y: order.sy ?? order.y }; return { x: order.sx ?? order.x, y: order.sy ?? order.y };
} }
/** Auto-heal priority: lower tier heals first — defensive > factory > other building > unit. */
function healPriorityTier(def) {
if (!def.isBuilding) return 3;
if (def.weapons?.length) return 0;
if (def.builds?.length) return 1;
return 2;
}
/**
* Best in-range damaged ally for `e` to auto-repair, or null. Same reach math as a player's
* own repair order (`buildRange` + target radius) a site under construction is `assist`'s
* job, not this one, so it's excluded even though its partial hp technically reads as damaged.
*/
function findAutoHealTarget(state, rules, e, def) {
const range = def.buildRange ?? 0;
let best = null, bestTier = Infinity, bestDSq = Infinity;
for (const t of state.entities) {
if (t.dead || t.id === e.id || t.army !== e.army || t.site) continue;
if (t.hp >= t.maxHp) continue;
const reach = range + t.radius;
const dSq = (t.x - e.x) ** 2 + (t.y - e.y) ** 2;
if (dSq > reach * reach) continue;
const tier = healPriorityTier(defOf(rules, t));
if (tier < bestTier || (tier === bestTier && dSq < bestDSq)) {
best = t; bestTier = tier; bestDSq = dSq;
}
}
return best;
}
function stepOrders(state, rules) { function stepOrders(state, rules) {
const ts = state.tileSize; const ts = state.tileSize;
for (const e of state.entities) { for (const e of state.entities) {
@ -721,6 +751,14 @@ function stepOrders(state, rules) {
if (!order) { if (!order) {
e.movingTo = null; e.movingTo = null;
e.buildTargetId = 0; e.buildTargetId = 0;
// Auto-heal: an idle builder with something damaged in range starts repairing it on its
// own, no order needed. `auto: true` marks it as self-assigned rather than player-issued,
// so a real order the player gives afterward — pushOrder always replaces e.orders wholesale
// when not queuing — simply overwrites it like any other order, never fights it.
if (def.buildPower > 0) {
const target = findAutoHealTarget(state, rules, e, def);
if (target) e.orders.push({ type: 'repair', targetId: target.id, auto: true });
}
continue; continue;
} }
@ -732,6 +770,24 @@ function stepOrders(state, rules) {
e.buildTargetId = 0; e.buildTargetId = 0;
} }
// Auto-heal keeps re-prioritising while it works, on the same staggered cadence combat
// uses for target acquisition — a defensive building taking fire should pull a builder off
// a generator it's already patching, but two similarly-urgent targets shouldn't make it
// flicker between them every time this runs.
if (order.type === 'repair' && order.auto && def.buildPower > 0) {
const due = ((state.tick + e.id) % rules.constants.retargetPeriodTicks) === 0;
if (due) {
const current = entityById(state, order.targetId);
const currentValid = !!current && !current.site && current.hp < current.maxHp
&& Math.hypot(current.x - e.x, current.y - e.y) <= (def.buildRange ?? 0) + current.radius;
const currentTier = currentValid ? healPriorityTier(defOf(rules, current)) : Infinity;
const candidate = findAutoHealTarget(state, rules, e, def);
if (candidate && (!currentValid || healPriorityTier(defOf(rules, candidate)) < currentTier)) {
order.targetId = candidate.id;
}
}
}
switch (order.type) { switch (order.type) {
case 'stop': case 'stop':
e.orders.length = 0; e.orders.length = 0;

View File

@ -739,8 +739,11 @@ section('6b. Commander self-repair');
run(st4, 120); run(st4, 120);
check('regeneration stops at full health', c4.hp === c4.maxHp, `${c4.hp}/${c4.maxHp}`); check('regeneration stops at full health', c4.hp === c4.maxHp, `${c4.hp}/${c4.maxHp}`);
// Well outside any builder's repair range, so this isolates passive selfHeal regen from the
// auto-heal nanolathe mechanic (an idle builder now auto-repairs damaged allies in range —
// a real, separate source of healing, not a regression of this one).
const st5 = fresh(); const st5 = fresh();
const tank = L.spawnUnit(st5, hr, 0, 'tank', c4.x + 200, c4.y); const tank = L.spawnUnit(st5, hr, 0, 'tank', c4.x + 2000, c4.y);
tank.hp = tank.maxHp * 0.5; tank.hp = tank.maxHp * 0.5;
const tankHp = tank.hp; const tankHp = tank.hp;
run(st5, 60); run(st5, 60);