SpaceCities / test /combat.test.js
Claude
Claude Opus 5
Tier 1, step 4e: clear autoTarget on a plain move order (A13)
3589155 unverified
Raw History Blame Contribute Delete
85.7 kB
import { test } from "node:test";
import assert from "node:assert/strict";
import { createGameState, makeUnit, makeBuilding } from "../engine/state.js";
import { buildUnitGrid } from "../engine/grid.js";
import { updateCombat, updateBuildingCombat, updateWorkerCombat } from "../engine/combat.js";
import { UNITS, BUILDINGS, UPGRADES, rankMults } from "../engine/entities.js";
import { sampleTerrain } from "../engine/map.js";
import { collectAnvils } from "../engine/sim.js";
function faceOff(state, x = 500, y = 500) {
const a = makeUnit("skiff", "player", x, y);
const b = makeUnit("skiff", "ai", x + 10, y); // well within weapon range
state.units.set(a.id, a);
state.units.set(b.id, b);
return [a, b];
}
test("a combat unit auto-acquires and damages an enemy within aggro range with no order at all", () => {
const state = createGameState({ planetId: "ferros" });
const [a, b] = faceOff(state);
const startHp = b.hp;
updateCombat(state, a, UNITS.skiff.cooldown);
assert.ok(b.hp < startHp);
});
test("focus-fire: a unit with a focusId concentrates on that target over a closer one", () => {
const state = createGameState({ planetId: "ferros" });
const attacker = makeUnit("skiff", "ai", 500, 500);
const closer = makeUnit("skiff", "player", 512, 500); // nearest β€” the dispersed pick might otherwise take this
const focus = makeUnit("skiff", "player", 530, 500); // the directed target, still inside skiff range/aggro
for (const u of [attacker, closer, focus]) state.units.set(u.id, u);
attacker.attackTimer = 0;
attacker.focusId = focus.id;
const focusHp = focus.hp, closerHp = closer.hp;
updateCombat(state, attacker, 0); // dt 0 so only targeting + the ready shot happen
assert.ok(focus.hp < focusHp, "the directed (focus) target took the hit");
assert.equal(closer.hp, closerHp, "the closer enemy was ignored in favour of the focus target");
});
test("focus-fire falls back to normal acquire when the focus target is dead or out of reach", () => {
const state = createGameState({ planetId: "ferros" });
const attacker = makeUnit("skiff", "ai", 500, 500);
const near = makeUnit("skiff", "player", 515, 500);
state.units.set(attacker.id, attacker);
state.units.set(near.id, near);
attacker.attackTimer = 0;
attacker.focusId = "u-does-not-exist"; // stale focus
const nearHp = near.hp;
updateCombat(state, attacker, 0);
assert.ok(near.hp < nearHp, "a stale focus doesn't freeze the unit β€” it auto-acquires the real enemy");
});
test("Hold stance: a holding unit fires on an in-range enemy but won't chase one out of range", () => {
const s1 = createGameState({ planetId: "ferros" });
const holder = makeUnit("skiff", "player", 500, 500); // range 40, aggro 120
const far = makeUnit("skiff", "ai", 590, 500); // 90 away: inside aggro, outside range
s1.units.set(holder.id, holder); s1.units.set(far.id, far);
holder.hold = true;
updateCombat(s1, holder, 0.1);
assert.equal(holder.x, 500, "a holding unit stands its ground instead of chasing an out-of-range target");
const s2 = createGameState({ planetId: "ferros" });
const holder2 = makeUnit("skiff", "player", 500, 500);
const near = makeUnit("skiff", "ai", 520, 500); // 20 away: already in range
s2.units.set(holder2.id, holder2); s2.units.set(near.id, near);
holder2.hold = true; holder2.attackTimer = 0;
const nearHp = near.hp;
updateCombat(s2, holder2, 0.1);
assert.ok(near.hp < nearHp, "but it still fires on anything that comes into range");
});
test("a combat unit's death no longer grants an instant resource refund to its owner", () => {
const state = createGameState({ planetId: "ferros" });
const attacker = makeUnit("skiff", "ai", 500, 500);
const victim = makeUnit("skiff", "player", 510, 500);
state.units.set(attacker.id, attacker); state.units.set(victim.id, victim);
victim.hp = 1; attacker.attackTimer = 0;
const before = state.players.player.resources.ore;
updateCombat(state, attacker, 0);
assert.equal(state.units.has(victim.id), false, "the victim died");
assert.equal(state.players.player.resources.ore, before,
"no instant refund β€” it leaves minable battle wreckage instead (see test/wreckage.test.js)");
});
test("a dead worker now leaves wreckage too, unlike the old combat-only salvage refund", () => {
const state = createGameState({ planetId: "ferros" });
const attacker = makeUnit("skiff", "ai", 500, 500);
const worker = makeUnit("worker", "player", 510, 500);
state.units.set(attacker.id, attacker); state.units.set(worker.id, worker);
worker.hp = 1; attacker.attackTimer = 0;
updateCombat(state, attacker, 0);
assert.equal(state.units.has(worker.id), false, "the worker died");
assert.equal(state.wrecks.length, 1, "workers now leave wreckage too β€” no longer a total loss (test/wreckage.test.js)");
});
test("kiting: a reloading Tactical ranged unit steps away from a closed-in enemy without firing", () => {
const state = createGameState({ planetId: "ferros", aiMicro: true });
const lancer = makeUnit("lancer", "ai", 500, 500); // range 55, so danger band ~41
const enemy = makeUnit("bastion", "player", 520, 500); // 20 away β€” well inside the danger band
state.units.set(lancer.id, lancer); state.units.set(enemy.id, enemy);
lancer.order = { type: "attack", targetId: enemy.id };
lancer.attackTimer = 1; // reloading β€” should kite, not shoot
const enemyHp = enemy.hp;
updateCombat(state, lancer, 0.1);
assert.ok(lancer.x < 500, "the lancer backed away from the enemy on its right");
assert.equal(enemy.hp, enemyHp, "and held its fire while reloading (no shot this tick)");
});
test("kiting is Tactical-only and ranged-only: a Standard ranged unit and a melee unit both hold ground", () => {
// Standard AI (micro off): a reloading lancer stands.
const std = createGameState({ planetId: "ferros" });
const l1 = makeUnit("lancer", "ai", 500, 500);
const e1 = makeUnit("bastion", "player", 520, 500);
std.units.set(l1.id, l1); std.units.set(e1.id, e1);
l1.order = { type: "attack", targetId: e1.id }; l1.attackTimer = 1;
updateCombat(std, l1, 0.1);
assert.equal(l1.x, 500, "Standard AI never kites");
// Tactical, but a melee brawler (range < 50) doesn't kite either.
const tac = createGameState({ planetId: "ferros", aiMicro: true });
const b1 = makeUnit("bastion", "ai", 500, 500); // range 24 β€” too short to kite
const e2 = makeUnit("skiff", "player", 512, 500);
tac.units.set(b1.id, b1); tac.units.set(e2.id, e2);
b1.order = { type: "attack", targetId: e2.id }; b1.attackTimer = 1;
updateCombat(tac, b1, 0.1);
assert.equal(b1.x, 500, "a short-range brawler stands and trades, it doesn't kite");
});
test("a killed target is removed from state and the killer's explicit order clears", () => {
const state = createGameState({ planetId: "ferros" });
const [a, b] = faceOff(state);
a.order = { type: "attack", targetId: b.id };
b.hp = 1; // one hit from behind the guard clause below
updateCombat(state, a, UNITS.skiff.cooldown);
assert.equal(state.units.has(b.id), false);
assert.equal(a.order, null);
});
test("an explicit attack order on a target killed by someone else re-acquires instead of freezing", () => {
const state = createGameState({ planetId: "ferros" });
const [a, b] = faceOff(state);
const c = makeUnit("skiff", "player", 500, 500);
state.units.set(c.id, c);
a.order = { type: "attack", targetId: b.id };
state.units.delete(b.id); // simulate b dying to a different attacker this same tick
const other = makeUnit("skiff", "ai", a.x + 10, a.y);
state.units.set(other.id, other);
const startHp = other.hp;
updateCombat(state, a, UNITS.skiff.cooldown);
assert.equal(a.order, null, "the stale order should be dropped, not kept forever");
assert.ok(other.hp < startHp, "it should have engaged the new nearby enemy instead of idling");
});
test("a plain move order is honored even with an enemy sitting right on top of the destination", () => {
const state = createGameState({ planetId: "ferros" });
const a = makeUnit("skiff", "player", 500, 500);
const enemy = makeUnit("skiff", "ai", 505, 500); // well within aggro range
state.units.set(a.id, a);
state.units.set(enemy.id, enemy);
a.order = { type: "move", x: 700, y: 500 };
const enemyHp = enemy.hp;
updateCombat(state, a, 0.1);
assert.equal(enemy.hp, enemyHp, "should not have attacked despite the enemy being in range");
assert.ok(a.x > 500, "should have moved toward its destination, not stayed to fight");
assert.equal(a.order.type, "move", "the move order should survive an enemy being nearby");
});
test("a worker lands its (weak) hit on an enemy it's been ordered to attack", () => {
const state = createGameState({ planetId: "ferros" });
const worker = makeUnit("worker", "player", 500, 500);
const enemy = makeUnit("skiff", "ai", 508, 500); // within the worker's short reach
state.units.set(worker.id, worker);
state.units.set(enemy.id, enemy);
worker.order = { type: "attack", targetId: enemy.id };
const startHp = enemy.hp;
updateWorkerCombat(state, worker, UNITS.worker, UNITS.worker.cooldown);
assert.equal(startHp - enemy.hp, UNITS.worker.attack, "a worker's swing lands for its attack stat");
});
test("a worker's kill clears its attack order so a queued waypoint (or idle) can follow", () => {
const state = createGameState({ planetId: "ferros" });
const worker = makeUnit("worker", "player", 500, 500);
const enemy = makeUnit("skiff", "ai", 508, 500);
state.units.set(worker.id, worker);
state.units.set(enemy.id, enemy);
worker.order = { type: "attack", targetId: enemy.id };
enemy.hp = 1;
updateWorkerCombat(state, worker, UNITS.worker, UNITS.worker.cooldown);
assert.equal(state.units.has(enemy.id), false, "the target is removed");
assert.equal(worker.order, null, "and the order clears");
});
test("a worker out of reach closes on its attack target instead of firing", () => {
const state = createGameState({ planetId: "ferros" });
const worker = makeUnit("worker", "player", 500, 500);
const enemy = makeUnit("skiff", "ai", 700, 500); // far beyond the worker's range
state.units.set(worker.id, worker);
state.units.set(enemy.id, enemy);
worker.order = { type: "attack", targetId: enemy.id };
const startHp = enemy.hp;
updateWorkerCombat(state, worker, UNITS.worker, 0.1);
assert.equal(enemy.hp, startHp, "no damage from out of range");
assert.ok(worker.x > 500, "it moved toward the target");
});
test("a move order still eventually clears on arrival, same as before", () => {
const state = createGameState({ planetId: "ferros" });
const a = makeUnit("skiff", "player", 500, 500);
state.units.set(a.id, a);
a.order = { type: "move", x: 500, y: 500 }; // already there
updateCombat(state, a, 0.1);
assert.equal(a.order, null);
});
test("attack-move still engages an enemy encountered along the way (unlike plain move)", () => {
const state = createGameState({ planetId: "ferros" });
const a = makeUnit("skiff", "player", 500, 500);
const enemy = makeUnit("skiff", "ai", 505, 500);
state.units.set(a.id, a);
state.units.set(enemy.id, enemy);
a.order = { type: "attack-move", x: 700, y: 500 };
const enemyHp = enemy.hp;
updateCombat(state, a, UNITS.skiff.cooldown);
assert.ok(enemy.hp < enemyHp, "attack-move should still fight what it runs into");
});
// A patrol leg (engine/commands.js issuePatrol) is an ordinary attack-move order with an extra
// `patrol: true` flag combat.js never has to look at β€” the proposal's own claim ("combat.js
// needs nothing") is exactly that the flag is inert here: engaging along the way works
// identically with or without it.
test("an attack-move order carrying patrol:true still engages an enemy encountered along the way", () => {
const state = createGameState({ planetId: "ferros" });
const a = makeUnit("skiff", "player", 500, 500);
const enemy = makeUnit("skiff", "ai", 505, 500);
state.units.set(a.id, a);
state.units.set(enemy.id, enemy);
a.order = { type: "attack-move", x: 700, y: 500, patrol: true };
const enemyHp = enemy.hp;
updateCombat(state, a, UNITS.skiff.cooldown);
assert.ok(enemy.hp < enemyHp, "a patrol leg still fights what it runs into, exactly like a plain attack-move");
});
test("Bastion deals its bonus damage specifically against Skiff", () => {
const state = createGameState({ planetId: "ferros" });
const bastion = makeUnit("bastion", "player", 500, 500);
const skiff = makeUnit("skiff", "ai", 500 + UNITS.bastion.range - 1, 500); // within melee range
state.units.set(bastion.id, bastion);
state.units.set(skiff.id, skiff);
const startHp = skiff.hp;
updateCombat(state, bastion, UNITS.bastion.cooldown);
const expectedDamage = UNITS.bastion.attack + UNITS.bastion.bonusVs.skiff;
assert.equal(startHp - skiff.hp, expectedDamage);
});
test("Bastion deals only its base damage against a non-Skiff target", () => {
const state = createGameState({ planetId: "ferros" });
const attacker = makeUnit("bastion", "player", 500, 500);
const otherBastion = makeUnit("bastion", "ai", 500 + UNITS.bastion.range - 1, 500);
state.units.set(attacker.id, attacker);
state.units.set(otherBastion.id, otherBastion);
const startHp = otherBastion.hp;
updateCombat(state, attacker, UNITS.bastion.cooldown);
assert.equal(startHp - otherBastion.hp, UNITS.bastion.attack);
});
test("Lancer deals its bonus damage specifically against Bastion", () => {
const state = createGameState({ planetId: "ferros" });
const lancer = makeUnit("lancer", "player", 500, 500);
const bastion = makeUnit("bastion", "ai", 500 + UNITS.lancer.range - 1, 500); // within Lancer's long range
state.units.set(lancer.id, lancer);
state.units.set(bastion.id, bastion);
const startHp = bastion.hp;
updateCombat(state, lancer, UNITS.lancer.cooldown);
const expectedDamage = UNITS.lancer.attack + UNITS.lancer.bonusVs.bastion;
assert.equal(startHp - bastion.hp, expectedDamage);
});
test("Lancer deals only its base damage against a non-Bastion target", () => {
const state = createGameState({ planetId: "ferros" });
const lancer = makeUnit("lancer", "player", 500, 500);
const skiff = makeUnit("skiff", "ai", 500 + UNITS.lancer.range - 1, 500);
state.units.set(lancer.id, lancer);
state.units.set(skiff.id, skiff);
const startHp = skiff.hp;
updateCombat(state, lancer, UNITS.lancer.cooldown);
assert.equal(startHp - skiff.hp, UNITS.lancer.attack);
});
test("Skiff deals its bonus damage specifically against Lancer, closing the rock-paper-scissors loop", () => {
const state = createGameState({ planetId: "ferros" });
const skiff = makeUnit("skiff", "player", 500, 500);
const lancer = makeUnit("lancer", "ai", 500 + UNITS.skiff.range - 1, 500);
state.units.set(skiff.id, skiff);
state.units.set(lancer.id, lancer);
const startHp = lancer.hp;
updateCombat(state, skiff, UNITS.skiff.cooldown);
const expectedDamage = UNITS.skiff.attack + UNITS.skiff.bonusVs.lancer;
assert.equal(startHp - lancer.hp, expectedDamage);
});
test("the rock-paper-scissors triangle is a genuine cycle: no unit also counters the unit that counters it", () => {
assert.ok(UNITS.skiff.bonusVs.lancer > 0, "Skiff should beat Lancer");
assert.ok(UNITS.bastion.bonusVs.skiff > 0, "Bastion should beat Skiff");
assert.ok(UNITS.lancer.bonusVs.bastion > 0, "Lancer should beat Bastion");
assert.ok(!UNITS.skiff.bonusVs.bastion, "Skiff must not also counter Bastion, or Skiff would beat everything");
assert.ok(!UNITS.bastion.bonusVs.lancer, "Bastion must not also counter Lancer, or Bastion would beat everything");
assert.ok(!UNITS.lancer.bonusVs.skiff, "Lancer must not also counter Skiff, or Lancer would beat everything");
});
// Counter-triangle readability (docs/improvement-proposals.md "Counter-triangle telegraphs"):
// performAttack stamps a `bonus` flag on the attackHit event whenever bonusVs applied, so
// effects.js/renderEffects.js can telegraph a counter hit instead of drawing it identically to
// a futile plink. Pure event bookkeeping β€” the damage-amount assertions above already cover the
// numbers; these only check the new event field.
test("performAttack stamps bonus:true on the attackHit event when the attacker's bonusVs counters the target's type", () => {
const state = createGameState({ planetId: "ferros" });
const skiff = makeUnit("skiff", "ai", 500, 500); // Skiff.bonusVs = { lancer: 10 }
const lancer = makeUnit("lancer", "player", 500 + UNITS.skiff.range - 1, 500);
state.units.set(skiff.id, skiff);
state.units.set(lancer.id, lancer);
updateCombat(state, skiff, UNITS.skiff.cooldown);
const hit = state.events.find(e => e.type === "attackHit");
assert.ok(hit, "expected an attackHit event");
assert.equal(hit.bonus, true, "Skiff vs Lancer is the counter-triangle bonus matchup");
});
test("performAttack leaves the attackHit event's bonus flag falsy for a matchup with no bonusVs", () => {
const state = createGameState({ planetId: "ferros" });
const [a, b] = faceOff(state); // two Skiffs β€” neither counters the other
updateCombat(state, a, UNITS.skiff.cooldown);
const hit = state.events.find(e => e.type === "attackHit");
assert.ok(hit, "expected an attackHit event");
assert.ok(!hit.bonus, "no counter-triangle bonus applies between two Skiffs");
});
// The `bonus` flag is scoped to the per-type hard counter (def.bonusVs[target.type]) only β€” kept
// distinct from the pre-existing `heavy` flag (def.bonusVsBuildings, a class-wide siege bonus with
// no specific counter-triangle matchup to telegraph). A Breacher has no bonusVs table at all, so
// its building hits stay `heavy` but never `bonus`.
test("performAttack's bonus flag stays falsy for a class-wide siege bonus (bonusVsBuildings), unlike the pre-existing heavy flag", () => {
const state = createGameState({ planetId: "ferros" });
const breacher = makeUnit("breacher", "player", 500, 500); // bonusVsBuildings: 30, no per-type bonusVs
const turret = makeBuilding("turret", "ai", 500 + UNITS.breacher.range - 1, 500);
state.units.set(breacher.id, breacher);
state.buildings.set(turret.id, turret);
breacher.order = { type: "attack", targetId: turret.id };
updateCombat(state, breacher, UNITS.breacher.cooldown);
const hit = state.events.find(e => e.type === "attackHit");
assert.ok(hit, "expected an attackHit event");
assert.equal(hit.heavy, true, "sanity: this is the same siege hit the pre-existing `heavy` flag already covers");
assert.ok(!hit.bonus, "bonusVsBuildings is not a bonusVs counter-triangle matchup, so bonus must stay falsy");
});
test("Skiff has no bonus damage table and deals only its base attack", () => {
const state = createGameState({ planetId: "ferros" });
const [a, b] = faceOff(state);
const startHp = b.hp;
updateCombat(state, a, UNITS.skiff.cooldown);
assert.equal(startHp - b.hp, UNITS.skiff.attack);
});
test("Overcharged Weapons multiplies the attacker's damage dealt", () => {
const state = createGameState({ planetId: "ferros" });
const [a, b] = faceOff(state);
state.players.player.upgrades.overchargedWeapons = true;
const startHp = b.hp;
updateCombat(state, a, UNITS.skiff.cooldown);
const { damageDealtMult } = UPGRADES.overchargedWeapons;
assert.ok(Math.abs((startHp - b.hp) - UNITS.skiff.attack * damageDealtMult) < 1e-9);
});
test("Reinforced Plating multiplies the defender's damage taken", () => {
const state = createGameState({ planetId: "ferros" });
const [a, b] = faceOff(state);
state.players.ai.upgrades.reinforcedPlating = true; // the defender's research, not the attacker's
const startHp = b.hp;
updateCombat(state, a, UNITS.skiff.cooldown);
const { damageTakenMult } = UPGRADES.reinforcedPlating;
assert.ok(Math.abs((startHp - b.hp) - UNITS.skiff.attack * damageTakenMult) < 1e-9);
});
// Bulwark's structure shielding, made official (docs/improvement-proposals.md): attackDamage
// (engine/combat.js) applies damageTakenMult to EVERY target it computes damage for β€” units AND
// buildings alike, with no kind/role filter β€” so a Bulwark player's turrets, Command Center, and
// Habitats already take reduced damage too, not just their combat units. These two pin that
// already-live behavior against the two structures the proposal calls out by name: a turret (the
// static defense a raid has to punch through) and a Habitat (hp 250, the softest raid target).
test("Reinforced Plating reduces damage taken by a defended turret, not just combat units", () => {
const state = createGameState({ planetId: "ferros" });
const attacker = makeUnit("skiff", "player", 500, 500);
const turret = makeBuilding("turret", "ai", 510, 500); // same stand-off faceOff uses for its unit-target sibling above
state.units.set(attacker.id, attacker);
state.buildings.set(turret.id, turret);
state.players.ai.upgrades.reinforcedPlating = true; // the turret owner's research, not the attacker's
const startHp = turret.hp;
updateCombat(state, attacker, UNITS.skiff.cooldown);
const { damageTakenMult } = UPGRADES.reinforcedPlating;
assert.ok(Math.abs((startHp - turret.hp) - UNITS.skiff.attack * damageTakenMult) < 1e-9,
"a Bulwark-researched turret takes reduced damage exactly like a combat unit would");
});
test("Reinforced Plating reduces damage taken by a defended Habitat, the softest raid target", () => {
const state = createGameState({ planetId: "ferros" });
const attacker = makeUnit("skiff", "player", 500, 500);
const habitat = makeBuilding("habitat", "ai", 510, 500);
state.units.set(attacker.id, attacker);
state.buildings.set(habitat.id, habitat);
state.players.ai.upgrades.reinforcedPlating = true;
const startHp = habitat.hp;
updateCombat(state, attacker, UNITS.skiff.cooldown);
const { damageTakenMult } = UPGRADES.reinforcedPlating;
assert.ok(Math.abs((startHp - habitat.hp) - UNITS.skiff.attack * damageTakenMult) < 1e-9,
"a Habitat (hp 250) β€” the raid target supply chokes on β€” is shielded by Bulwark too");
});
test("both upgrades stack: attacker's damage bonus and defender's damage reduction apply together", () => {
const state = createGameState({ planetId: "ferros" });
const [a, b] = faceOff(state);
state.players.player.upgrades.overchargedWeapons = true;
state.players.ai.upgrades.reinforcedPlating = true;
const startHp = b.hp;
updateCombat(state, a, UNITS.skiff.cooldown);
const expected = UNITS.skiff.attack * UPGRADES.overchargedWeapons.damageDealtMult * UPGRADES.reinforcedPlating.damageTakenMult;
assert.ok(Math.abs((startHp - b.hp) - expected) < 1e-9);
});
test("attackers fan out across several nearby enemies instead of all dogpiling the nearest", () => {
const state = createGameState({ planetId: "ferros", rng: () => 0.5 });
state.units.clear();
// A cluster of attackers facing a cluster of enemies, all within the local
// engagement band β€” so the spread policy has several targets to distribute
// across rather than everyone locking the single closest.
const attackers = [];
for (let i = 0; i < 6; i++) { const u = makeUnit("skiff", "player", 500 + i * 4, 500); state.units.set(u.id, u); attackers.push(u); }
for (let i = 0; i < 4; i++) { const e = makeUnit("skiff", "ai", 545 + i * 8, 500); state.units.set(e.id, e); }
for (const u of attackers) updateCombat(state, u, 0.001); // acquire (tiny dt so nobody dies this step)
const targets = new Set(attackers.map(u => u.autoTarget).filter(Boolean));
assert.ok(targets.size >= 2, `attackers should spread across multiple targets, got ${targets.size}`);
});
test("a unit sticks to its auto-target while it's alive and in range, instead of re-picking each tick", () => {
const state = createGameState({ planetId: "ferros", rng: () => 0.5 });
state.units.clear();
const a = makeUnit("skiff", "player", 500, 500);
const near = makeUnit("skiff", "ai", 520, 500);
const nearer = makeUnit("skiff", "ai", 505, 500); // closer, but arrives "after" a is already locked on `near`
state.units.set(a.id, a);
state.units.set(near.id, near);
a.autoTarget = near.id; // already committed
updateCombat(state, a, 0.001);
assert.equal(a.autoTarget, near.id, "it keeps its committed target even with a closer enemy present");
state.units.set(nearer.id, nearer); // introduce the closer enemy AFTER the lock
updateCombat(state, a, 0.001);
assert.equal(a.autoTarget, near.id, "still committed β€” no re-dogpiling onto the newly-closest foe");
});
test("the two Assault tiers stack multiplicatively on damage dealt", () => {
const state = createGameState({ planetId: "ferros" });
const [a, b] = faceOff(state);
state.players.player.upgrades.overchargedWeapons = true; // Assault I
state.players.player.upgrades.overchargedCore = true; // Assault II
const startHp = b.hp;
updateCombat(state, a, UNITS.skiff.cooldown);
const mult = UPGRADES.overchargedWeapons.damageDealtMult * UPGRADES.overchargedCore.damageDealtMult;
assert.ok(Math.abs((startHp - b.hp) - UNITS.skiff.attack * mult) < 1e-9, "both tiers multiply the base damage");
});
// ---- Doctrine depth redesign (docs/improvement-proposals.md, merged): Assault's Tier-2 grants a
// chase-speed verb and its Tier-3 capstone (Overdrive Actuators) grants attack tempo β€” two
// mechanically distinct identities, unlike Bulwark's regen (see test/repair.test.js for that
// reconciliation). lastHitAt stamping (read by Bulwark's regen pass) is also pinned here since
// it's stamped from this file's performAttack/applySplash.
test("Overcharged Core (Assault Tier 2) grants chaseSpeedMult: a unit chasing an acquired-but-out-of-range target closes distance faster", () => {
const dt = 0.05;
function chaseStep(withUpgrade) {
const state = createGameState({ planetId: "ferros" });
const chaser = makeUnit("skiff", "player", 500, 500);
const target = makeUnit("skiff", "ai", 580, 500); // 80 away: beyond range (40), inside aggro (120)
state.units.set(chaser.id, chaser);
state.units.set(target.id, target);
chaser.autoTarget = target.id; // already acquired β€” isolate the chase branch from acquisition
if (withUpgrade) state.players.player.upgrades.overchargedCore = true;
updateCombat(state, chaser, dt);
return chaser.x - 500;
}
const plain = chaseStep(false);
const boosted = chaseStep(true);
assert.ok(plain > 0, "sanity: the unit actually closed distance this tick");
const { chaseSpeedMult } = UPGRADES.overchargedCore;
assert.ok(Math.abs(boosted - plain * chaseSpeedMult) < 1e-9,
`expected the chase step scaled by chaseSpeedMult (${chaseSpeedMult}), got ${boosted} vs plain ${plain}`);
});
test("chaseSpeedMult only speeds up the chase branch, not a plain move order", () => {
const state = createGameState({ planetId: "ferros" });
const unit = makeUnit("skiff", "player", 500, 500);
state.units.set(unit.id, unit);
state.players.player.upgrades.overchargedCore = true;
unit.order = { type: "move", x: 600, y: 500 };
updateCombat(state, unit, 0.05);
assert.ok(Math.abs((unit.x - 500) - UNITS.skiff.speed * 0.05) < 1e-9,
"a plain move order travels at the unit's ordinary speed β€” chaseSpeedMult never applies here");
});
test("Overdrive Actuators (Assault Tier 3) shortens the attack cooldown reset β€” the main unit attack path", () => {
const state = createGameState({ planetId: "ferros" });
const [a, b] = faceOff(state);
a.attackTimer = 0;
state.players.player.upgrades.overdriveActuators = true;
updateCombat(state, a, 0);
const { attackCooldownMult } = UPGRADES.overdriveActuators;
assert.ok(Math.abs(a.attackTimer - UNITS.skiff.cooldown * attackCooldownMult) < 1e-9,
"the reset cooldown is multiplied by attackCooldownMult");
});
test("without Overdrive Actuators, the attack cooldown resets to the unit's plain cooldown", () => {
const state = createGameState({ planetId: "ferros" });
const [a, b] = faceOff(state);
a.attackTimer = 0;
updateCombat(state, a, 0);
assert.equal(a.attackTimer, UNITS.skiff.cooldown, "no capstone researched -> unmultiplied cooldown");
});
test("Overdrive Actuators also shortens the attack cooldown reset for a worker's explicit attack order", () => {
const state = createGameState({ planetId: "ferros" });
const worker = makeUnit("worker", "player", 500, 500);
const target = makeUnit("skiff", "ai", 505, 500); // 5 away, inside the worker's 15 range
state.units.set(worker.id, worker);
state.units.set(target.id, target);
worker.order = { type: "attack", targetId: target.id };
worker.attackTimer = 0;
state.players.player.upgrades.overdriveActuators = true;
updateWorkerCombat(state, worker, UNITS.worker, 0);
const { attackCooldownMult } = UPGRADES.overdriveActuators;
assert.ok(Math.abs(worker.attackTimer - UNITS.worker.cooldown * attackCooldownMult) < 1e-9);
});
test("Overdrive Actuators does not speed up static defense β€” a turret's cooldown is untouched (army tempo, not base defense)", () => {
const state = createGameState({ planetId: "ferros" });
const attacker = makeUnit("skiff", "ai", 500, 500);
const turret = makeBuilding("turret", "player", 510, 500);
state.units.set(attacker.id, attacker);
state.buildings.set(turret.id, turret);
state.players.player.upgrades.overdriveActuators = true; // the turret owner's research
turret.attackTimer = 0;
updateBuildingCombat(state, turret, 0);
assert.equal(turret.attackTimer, BUILDINGS.turret.cooldown,
"the Assault capstone is scoped to mobile army units β€” a turret's fire rate is unaffected");
});
test("a landed hit stamps the target's lastHitAt to the current state.time", () => {
const state = createGameState({ planetId: "ferros" });
const [a, b] = faceOff(state);
state.time = 123.5;
assert.equal(b.lastHitAt, undefined, "sanity: never hit yet");
updateCombat(state, a, UNITS.skiff.cooldown);
assert.equal(b.lastHitAt, 123.5, "the moment damage lands, the target's lastHitAt is stamped to state.time");
});
test("splash damage also stamps lastHitAt on the units it catches, not just the primary target", () => {
const state = createGameState({ planetId: "ferros" });
state.time = 77;
const colossus = makeUnit("colossus", "player", 500, 500);
const primary = makeUnit("skiff", "ai", 600, 500); // within Colossus range (185)
const bystander = makeUnit("skiff", "ai", 610, 500); // 10 away from primary β€” inside splash radius (26)
state.units.set(colossus.id, colossus);
state.units.set(primary.id, primary);
state.units.set(bystander.id, bystander);
colossus.attackTimer = 0;
colossus.autoTarget = primary.id; // isolate splash from acquisition
updateCombat(state, colossus, 0);
assert.equal(primary.lastHitAt, 77, "the primary target is stamped");
assert.equal(bystander.lastHitAt, 77, "a bystander caught in the splash radius is stamped too");
});
test("a player's own upgrades don't affect damage against their own side", () => {
const state = createGameState({ planetId: "ferros" });
const [a, b] = faceOff(state);
state.players.player.upgrades.reinforcedPlating = true; // attacker researched the DEFENSIVE upgrade for themselves
const startHp = b.hp;
updateCombat(state, a, UNITS.skiff.cooldown);
assert.equal(startHp - b.hp, UNITS.skiff.attack, "the attacker's own defensive research shouldn't reduce their own damage output");
});
// A completed turret standing at (500,500); enemies dropped near it, well
// clear of the map's far-apart Command Centers so the only target in aggro
// is the one the test placed.
function turretAt(state, x = 500, y = 500) {
const t = makeBuilding("turret", "player", x, y);
state.buildings.set(t.id, t);
return t;
}
test("a completed Sentinel Turret auto-acquires and damages an enemy unit in range", () => {
const state = createGameState({ planetId: "ferros" });
const turret = turretAt(state);
const enemy = makeUnit("skiff", "ai", turret.x + 10, turret.y);
state.units.set(enemy.id, enemy);
const startHp = enemy.hp;
updateBuildingCombat(state, turret, BUILDINGS.turret.cooldown);
assert.equal(startHp - enemy.hp, BUILDINGS.turret.attack);
assert.equal(turret.targetId, enemy.id);
});
test("a turret's target that dies to someone else earlier the same tick is dropped, not deref'd β€” it re-acquires cleanly instead of freezing", () => {
// The mobile-unit analog: "an explicit attack order on a target killed by someone else
// re-acquires instead of freezing" above. A turret has no order pipeline, but building.targetId
// is the same kind of persisted, potentially-stale reference β€” this simulates it having locked
// onto `stale` on some earlier pass, then `stale` dying to a DIFFERENT attacker before the
// turret's own updateBuildingCombat runs this tick.
const state = createGameState({ planetId: "ferros" });
const turret = turretAt(state);
const stale = makeUnit("skiff", "ai", turret.x + 10, turret.y);
state.units.set(stale.id, stale);
turret.targetId = stale.id; // simulate the turret already locked onto this target
state.units.delete(stale.id); // simulate it dying to a different attacker earlier this same tick
const other = makeUnit("skiff", "ai", turret.x + 15, turret.y); // a fresh live enemy still in range
state.units.set(other.id, other);
const startHp = other.hp;
updateBuildingCombat(state, turret, BUILDINGS.turret.cooldown);
assert.equal(turret.targetId, other.id, "re-acquired the live enemy instead of keeping/deref'ing the dead one");
assert.ok(other.hp < startHp, "and actually landed a hit on it, same tick");
assert.ok(Number.isFinite(other.hp), "no NaN-poisoning from a stale dereferenced target");
});
test("a turret whose only target dies to someone else earlier the same tick goes idle, not stuck referencing the corpse", () => {
const state = createGameState({ planetId: "ferros" });
const turret = turretAt(state);
const stale = makeUnit("skiff", "ai", turret.x + 10, turret.y);
state.units.set(stale.id, stale);
turret.targetId = stale.id;
state.units.delete(stale.id); // dies to someone else, and nothing else is left in range
assert.doesNotThrow(() => updateBuildingCombat(state, turret, BUILDINGS.turret.cooldown));
assert.equal(turret.targetId, null, "cleanly falls back to idle rather than holding the stale id");
});
test("a turret under construction never fires and holds no target", () => {
const state = createGameState({ planetId: "ferros" });
const turret = makeBuilding("turret", "player", 500, 500, { constructing: true });
state.buildings.set(turret.id, turret);
const enemy = makeUnit("skiff", "ai", 510, 500);
state.units.set(enemy.id, enemy);
const startHp = enemy.hp;
updateBuildingCombat(state, turret, BUILDINGS.turret.cooldown);
assert.equal(enemy.hp, startHp, "an unfinished turret must not deal damage");
assert.equal(turret.targetId, null);
});
test("a turret ignores an enemy sitting just beyond its range", () => {
const state = createGameState({ planetId: "ferros" });
const turret = turretAt(state);
const enemy = makeUnit("skiff", "ai", turret.x + BUILDINGS.turret.range + 5, turret.y);
state.units.set(enemy.id, enemy);
const startHp = enemy.hp;
updateBuildingCombat(state, turret, BUILDINGS.turret.cooldown);
assert.equal(enemy.hp, startHp);
assert.equal(turret.targetId, null);
});
test("a turret respects its cooldown: two quick ticks land only one hit", () => {
const state = createGameState({ planetId: "ferros" });
const turret = turretAt(state);
const enemy = makeUnit("skiff", "ai", 510, 500);
state.units.set(enemy.id, enemy);
const startHp = enemy.hp;
updateBuildingCombat(state, turret, 0.1);
updateBuildingCombat(state, turret, 0.1); // still inside cooldown β€” no second shot
assert.equal(startHp - enemy.hp, BUILDINGS.turret.attack);
});
test("a turret kill removes the entity and pushes an entityKilled event", () => {
const state = createGameState({ planetId: "ferros" });
const turret = turretAt(state);
const enemy = makeUnit("skiff", "ai", 510, 500);
enemy.hp = 1;
state.units.set(enemy.id, enemy);
updateBuildingCombat(state, turret, BUILDINGS.turret.cooldown);
assert.equal(state.units.has(enemy.id), false);
assert.ok(state.events.some(e => e.type === "entityKilled"));
});
test("Overcharged Weapons multiplies a turret's damage, same as a unit's", () => {
const state = createGameState({ planetId: "ferros" });
const turret = turretAt(state);
const enemy = makeUnit("skiff", "ai", 510, 500);
state.units.set(enemy.id, enemy);
state.players.player.upgrades.overchargedWeapons = true;
const startHp = enemy.hp;
updateBuildingCombat(state, turret, BUILDINGS.turret.cooldown);
const { damageDealtMult } = UPGRADES.overchargedWeapons;
assert.ok(Math.abs((startHp - enemy.hp) - BUILDINGS.turret.attack * damageDealtMult) < 1e-9);
});
test("a Breacher deals its structure bonus against a building but only base damage against a unit", () => {
const vsBuilding = createGameState({ planetId: "ferros" });
const breacher = makeUnit("breacher", "player", 500, 500);
const barracks = makeBuilding("barracks", "ai", 600, 500); // within the Breacher's 150 range
vsBuilding.units.set(breacher.id, breacher);
vsBuilding.buildings.set(barracks.id, barracks);
const barracksHp = barracks.hp;
updateCombat(vsBuilding, breacher, UNITS.breacher.cooldown);
assert.equal(barracksHp - barracks.hp, UNITS.breacher.attack + UNITS.breacher.bonusVsBuildings);
const vsUnit = createGameState({ planetId: "ferros" });
const breacher2 = makeUnit("breacher", "player", 500, 500);
const skiff = makeUnit("skiff", "ai", 600, 500);
vsUnit.units.set(breacher2.id, breacher2);
vsUnit.units.set(skiff.id, skiff);
const skiffHp = skiff.hp;
updateCombat(vsUnit, breacher2, UNITS.breacher.cooldown);
assert.equal(skiffHp - skiff.hp, UNITS.breacher.attack, "no structure bonus, no bonusVs β€” just base attack against a unit");
});
test("a Breacher shells a building even when an enemy unit stands closer", () => {
const state = createGameState({ planetId: "ferros" });
const breacher = makeUnit("breacher", "player", 500, 500);
const skiff = makeUnit("skiff", "ai", 520, 500); // closer (20 away)
const barracks = makeBuilding("barracks", "ai", 600, 500); // farther (100 away)
state.units.set(breacher.id, breacher);
state.units.set(skiff.id, skiff);
state.buildings.set(barracks.id, barracks);
const skiffHp = skiff.hp, barracksHp = barracks.hp;
updateCombat(state, breacher, UNITS.breacher.cooldown);
assert.ok(barracks.hp < barracksHp, "prefersBuildings should win over the nearer unit");
assert.equal(skiff.hp, skiffHp, "the closer unit should be ignored");
});
test("a Breacher falls back to the nearest unit when no building is in aggro range", () => {
const state = createGameState({ planetId: "ferros" });
const breacher = makeUnit("breacher", "player", 500, 500);
const skiff = makeUnit("skiff", "ai", 520, 500);
state.units.set(breacher.id, breacher);
state.units.set(skiff.id, skiff);
const skiffHp = skiff.hp;
updateCombat(state, breacher, UNITS.breacher.cooldown);
assert.ok(skiff.hp < skiffHp, "with no building to shell, it should still engage a unit");
});
test("default acquisition is unchanged: the nearest enemy wins across units and buildings, ties to units", () => {
const nearest = createGameState({ planetId: "ferros" });
const skiff = makeUnit("skiff", "player", 500, 500);
const enemyUnit = makeUnit("skiff", "ai", 530, 500); // 30 away
const enemyBuilding = makeBuilding("barracks", "ai", 550, 500); // 50 away
nearest.units.set(skiff.id, skiff);
nearest.units.set(enemyUnit.id, enemyUnit);
nearest.buildings.set(enemyBuilding.id, enemyBuilding);
const unitHp = enemyUnit.hp, buildingHp = enemyBuilding.hp;
updateCombat(nearest, skiff, UNITS.skiff.cooldown);
assert.ok(enemyUnit.hp < unitHp, "the nearer unit should be chosen over the farther building");
assert.equal(enemyBuilding.hp, buildingHp);
// Exact tie: a unit and a building at the same distance both inside weapon
// range β€” the unit must win (units are scanned first).
const tie = createGameState({ planetId: "ferros" });
const skiff2 = makeUnit("skiff", "player", 500, 500);
const tiedUnit = makeUnit("skiff", "ai", 515, 500);
const tiedBuilding = makeBuilding("barracks", "ai", 515, 500);
tie.units.set(skiff2.id, skiff2);
tie.units.set(tiedUnit.id, tiedUnit);
tie.buildings.set(tiedBuilding.id, tiedBuilding);
const tiedUnitHp = tiedUnit.hp, tiedBuildingHp = tiedBuilding.hp;
updateCombat(tie, skiff2, UNITS.skiff.cooldown);
assert.ok(tiedUnit.hp < tiedUnitHp, "an exact distance tie should resolve to the unit");
assert.equal(tiedBuilding.hp, tiedBuildingHp);
});
test("a planet sight modifier scales aggro range for both sides", () => {
// An enemy just inside a Skiff's full aggro range but well beyond weapon
// range: with the target acquired the Skiff steps toward it (moving), and
// with it out of aggro the Skiff has nothing to chase (stays put). Movement
// is the clean tell for whether acquisition happened.
function engages(sightMult) {
const state = createGameState({ planetId: "ferros" });
state.map.modifiers = { sightMult };
const a = makeUnit("skiff", "player", 500, 500);
const enemy = makeUnit("skiff", "ai", 500 + UNITS.skiff.aggroRange * 0.9, 500);
state.units.set(a.id, a);
state.units.set(enemy.id, enemy);
updateCombat(state, a, 0.1);
return a.x > 500;
}
assert.equal(engages(1), true, "at full aggro the enemy just inside range is chased");
assert.equal(engages(0.75), false, "a 0.75 sight modifier pulls that same enemy out of aggro range");
});
test("a Breacher out-ranges a Sentinel Turret: it chips the turret down while taking nothing back", () => {
const state = createGameState({ planetId: "ferros" });
const turret = makeBuilding("turret", "ai", 500, 500);
const breacher = makeUnit("breacher", "player", 500 + 140, 500); // 140: inside Breacher's 150, outside turret's 130
state.buildings.set(turret.id, turret);
state.units.set(breacher.id, breacher);
const turretHp = turret.hp, breacherHp = breacher.hp;
for (let t = 0; t < 6; t += 0.5) {
updateCombat(state, breacher, 0.5);
updateBuildingCombat(state, turret, 0.5);
}
assert.ok(turret.hp < turretHp, "the Breacher should be steadily shelling the turret");
assert.equal(breacher.hp, breacherHp, "the turret can't reach the Breacher, so it takes no damage");
});
// ---- Broad-phase grid: every test above drives acquireTarget through the O(n)
// fallback (no state.unitGrid at all). Real gameplay never does that β€” sim.js
// builds state.unitGrid every tick before combat runs β€” so acquireTarget's grid
// branch (queryNeighbors, engine/grid.js) needs its own coverage too.
test("acquireTarget finds an enemy through the populated broad-phase grid, across a cell boundary β€” not just when both units share one cell", () => {
// engine/grid.js's CELL is 96: placing the pair 90 apart straddles the
// column-10/11 boundary, so a broken or silently-skipped neighbor-cell query
// (unlike the same-cell placements every other test in this file happens to
// use) would miss this target while the O(n) fallback still finds it fine.
const state = createGameState({ planetId: "ferros" });
const attacker = makeUnit("skiff", "player", 1000, 500); // grid column 10
const target = makeUnit("skiff", "ai", 1090, 500); // grid column 11 β€” 90 away: inside aggro (120), outside weapon range (40)
state.units.set(attacker.id, attacker);
state.units.set(target.id, target);
state.unitGrid = buildUnitGrid(state); // the real per-tick broad-phase index (engine/grid.js), same as sim.js builds every tick
updateCombat(state, attacker, 0.1);
assert.equal(attacker.autoTarget, target.id, "the target should be acquired through the grid, same as the fallback tests above establish");
assert.ok(attacker.x > 1000, "and immediately start closing the distance, exactly like the no-grid case");
});
// ---- Terrain's combatMult: test/terrain.test.js only checks it as static
// TERRAIN table data. Nothing before this exercised it in a live attack.
test("terrain's combatMult is genuinely applied to a live attack, not just read as static data", () => {
// Baseline: ferros carries no terrain (test/terrain.test.js) β€” every cell
// samples combatMult 1, so this is exactly the plain-base-damage case every
// other exact-damage test in this file already assumes.
const flat = createGameState({ planetId: "ferros" });
const [flatAttacker, flatTarget] = faceOff(flat, 500, 500);
const flatStartHp = flatTarget.hp;
updateCombat(flat, flatAttacker, UNITS.skiff.cooldown);
const flatDamage = flatStartHp - flatTarget.hp;
assert.equal(flatDamage, UNITS.skiff.attack, "fixture sanity: flat ground deals plain base damage");
// Pyralis' central mesa is high ground (test/terrain.test.js): the attacker
// stands ON it, the target just beside it β€” combatMult is read from the
// ATTACKER's own position (engine/combat.js attackDamage), a positional edge
// for whoever holds the high ground, not a per-target check.
const high = createGameState({ planetId: "pyralis" });
const map = high.map;
const attacker = makeUnit("skiff", "player", map.width * 0.5, map.height * 0.5);
const target = makeUnit("skiff", "ai", map.width * 0.5 + 10, map.height * 0.5);
high.units.set(attacker.id, attacker);
high.units.set(target.id, target);
const tile = sampleTerrain(map.terrain, attacker.x, attacker.y);
assert.equal(tile.name, "high", "fixture sanity: the attacker is standing on the mesa");
const highStartHp = target.hp;
updateCombat(high, attacker, UNITS.skiff.cooldown);
const highDamage = highStartHp - target.hp;
const expected = UNITS.skiff.attack * tile.combatMult; // documented as 1.15 (engine/map.js TERRAIN.high)
assert.ok(Math.abs(highDamage - expected) < 1e-9, `expected exactly ${expected} from the high-ground multiplier, got ${highDamage}`);
assert.ok(highDamage > flatDamage, "and that's strictly more than the flat-ground baseline");
});
// ---- High ground extends weapon acquisition, not just fog sight: fog reveal already
// scales by the source tile's terrain sightMult (engine/fog.js updateFog srcMult), but
// aggro (acquireTarget/stillEngageable) only ever multiplied by the sideMod sightMult,
// never terrain β€” so a unit on a mesa/ridge could SEE an enemy it refused to ENGAGE.
// Nothing before this exercised aggro range against terrain at all.
test("a unit on high ground acquires a fresh target beyond its flat-ground aggro range; the same distance on open ground does not", () => {
// helix's central ridge is high ground (engine/map.js PLANET_MODIFIERS.helix) with NO
// world-level sightMult of its own (unlike pyralis/nimbus above), so the only aggro
// multiplier in play is the terrain one β€” isolates this fix from the pre-existing
// sideMod(sightMult) path this file's other tests already cover.
// Skiff aggroRange 120 * TERRAIN.high.sightMult 1.25 = 150.
const D = 135; // strictly beyond flat aggro (120), strictly inside high-ground aggro (150)
const flat = createGameState({ planetId: "ferros" }); // no terrain at all -> sightMult/combatMult both 1 everywhere
const flatAttacker = makeUnit("skiff", "player", 500, 500);
const flatTarget = makeUnit("skiff", "ai", 500 + D, 500);
flat.units.set(flatAttacker.id, flatAttacker);
flat.units.set(flatTarget.id, flatTarget);
updateCombat(flat, flatAttacker, 0.1);
assert.equal(flatAttacker.autoTarget, null, "flat ground: the target sits beyond the un-extended aggro range");
const high = createGameState({ planetId: "helix" });
const map = high.map;
const highAttacker = makeUnit("skiff", "player", map.width * 0.5, map.height * 0.5);
const highTarget = makeUnit("skiff", "ai", map.width * 0.5 + D, map.height * 0.5);
high.units.set(highAttacker.id, highAttacker);
high.units.set(highTarget.id, highTarget);
const tile = sampleTerrain(map.terrain, highAttacker.x, highAttacker.y);
assert.equal(tile.name, "high", "fixture sanity: the attacker is standing on the ridge");
updateCombat(high, highAttacker, 0.1);
assert.equal(highAttacker.autoTarget, highTarget.id, "high ground: the identical distance is now inside the terrain-extended aggro range");
});
test("stillEngageable also folds in terrain: a high-ground unit keeps its already-locked target instead of switching to a closer one", () => {
const D = 135; // beyond flat aggro (120), inside this attacker's high-ground aggro (150)
const high = createGameState({ planetId: "helix" });
const map = high.map;
const attacker = makeUnit("skiff", "player", map.width * 0.5, map.height * 0.5);
// Locked from a (simulated) previous tick, out at the terrain-extended range.
const lockedTarget = makeUnit("skiff", "ai", map.width * 0.5 + D, map.height * 0.5);
// Well within the FLAT aggro range too β€” what acquireTarget would prefer if stillEngageable
// ever fell through to it (nearer target, unmodified by the fix being tested here).
const closerTarget = makeUnit("skiff", "ai", map.width * 0.5 + 60, map.height * 0.5);
high.units.set(attacker.id, attacker);
high.units.set(lockedTarget.id, lockedTarget);
high.units.set(closerTarget.id, closerTarget);
attacker.autoTarget = lockedTarget.id;
updateCombat(high, attacker, 0.1);
assert.equal(attacker.autoTarget, lockedTarget.id,
"stillEngageable's own terrain-extended aggro holds the original lock, rather than falling through to acquireTarget and picking the closer enemy");
});
// ---- Uphill concealment, acquisition half (docs/improvement-proposals.md "A real LOS pass"):
// fog.js's reveal() already caps how far a NON-high-ground source sees onto high ground at
// UPHILL_FRAC of its sight; the identical rule applies here to weapon acquisition, so a unit
// can't shell a held ridge blind just because its raw aggro range happens to reach that far.
// Skiff aggroRange 120 * UPHILL_FRAC 0.55 = 66 β€” D=90 sits strictly beyond that cap but strictly
// inside the flat 120 aggro, isolating the effect exactly like the fog tests do.
test("a lowland attacker can't acquire a high-ground enemy beyond aggro*UPHILL_FRAC", () => {
const D = 90;
const state = createGameState({ planetId: "helix" });
const map = state.map;
const attacker = makeUnit("skiff", "player", map.width * 0.5 - 170, map.height * 0.5);
assert.equal(sampleTerrain(map.terrain, attacker.x, attacker.y).name, "open", "fixture sanity: attacker starts off the ridge");
const highTarget = makeUnit("skiff", "ai", attacker.x + D, attacker.y);
assert.equal(sampleTerrain(map.terrain, highTarget.x, highTarget.y).name, "high", "fixture sanity: target sits on the ridge, D away");
state.units.set(attacker.id, attacker);
state.units.set(highTarget.id, highTarget);
updateCombat(state, attacker, 0.1);
assert.equal(attacker.autoTarget, null, "beyond the uphill-capped range β€” the attacker never acquires it, though it's well within the flat 120 aggro");
});
test("the identical distance is acquired normally when the target is NOT on high ground β€” the cap targets high ground specifically", () => {
const D = 90;
const state = createGameState({ planetId: "ferros" }); // all-open, no terrain at all
const attacker = makeUnit("skiff", "player", 500, 500);
const target = makeUnit("skiff", "ai", 500 + D, 500);
state.units.set(attacker.id, attacker);
state.units.set(target.id, target);
updateCombat(state, attacker, 0.1);
assert.equal(attacker.autoTarget, target.id, "same distance, open ground: acquired fine, same as before this feature");
});
test("the uphill acquisition cap is symmetric: an AI-owned attacker is capped identically against a player-owned high-ground target", () => {
const D = 90;
const state = createGameState({ planetId: "helix" });
const map = state.map;
const attacker = makeUnit("skiff", "ai", map.width * 0.5 - 170, map.height * 0.5);
const highTarget = makeUnit("skiff", "player", attacker.x + D, attacker.y);
assert.equal(sampleTerrain(map.terrain, highTarget.x, highTarget.y).name, "high", "fixture sanity");
state.units.set(attacker.id, attacker);
state.units.set(highTarget.id, highTarget);
updateCombat(state, attacker, 0.1);
assert.equal(attacker.autoTarget, null,
"the AI reads acquireTarget through the exact same code path as the player β€” no separate AI-side change needed, and none was made");
});
// ---- anvilAura (the Aegis's guardAura): reduces damage taken by allies inside
// its bubble. Nothing before this landed a real hit through it.
test("a friendly Aegis's cryo-armour aura reduces damage taken by exactly its documented multiplier", () => {
const unshielded = createGameState({ planetId: "ferros" });
const [attacker1, target1] = faceOff(unshielded, 500, 500);
const startHp1 = target1.hp;
updateCombat(unshielded, attacker1, UNITS.skiff.cooldown);
const unshieldedDamage = startHp1 - target1.hp;
const shielded = createGameState({ planetId: "ferros" });
const attacker2 = makeUnit("skiff", "player", 500, 500);
const target2 = makeUnit("skiff", "ai", 500 + UNITS.skiff.range - 1, 500); // within melee range, as faceOff would place it
// Well within the target's 96-range guardAura, but past the attacker's own aggro
// range (120) β€” otherwise it's a second enemy inside spreadEnemy's local band and
// the attacker sometimes fans onto the Aegis itself instead of the intended target.
const aegis = makeUnit("aegis", "ai", target2.x + 90, target2.y);
shielded.units.set(attacker2.id, attacker2);
shielded.units.set(target2.id, target2);
shielded.units.set(aegis.id, aegis);
collectAnvils(shielded); // sim.js's per-tick anvil snapshot that combat.js's attackDamage reads (engine/sim.js)
const startHp2 = target2.hp;
updateCombat(shielded, attacker2, UNITS.skiff.cooldown);
const shieldedDamage = startHp2 - target2.hp;
const { damageTakenMult } = UNITS.aegis.guardAura;
const expected = UNITS.skiff.attack * damageTakenMult;
assert.ok(Math.abs(shieldedDamage - expected) < 1e-9, `expected exactly ${expected} with the Aegis's damage-taken multiplier applied, got ${shieldedDamage}`);
assert.ok(shieldedDamage < unshieldedDamage, "and that's strictly less than the same attack against an unshielded target");
});
test("the Aegis's aura doesn't shield itself β€” the source's explicit exclusion, so it can still be focused down", () => {
const state = createGameState({ planetId: "ferros" });
const attacker = makeUnit("skiff", "player", 500, 500);
const aegis = makeUnit("aegis", "ai", 500 + UNITS.skiff.range - 1, 500); // within melee range, and trivially within its OWN aura (distance 0)
state.units.set(attacker.id, attacker);
state.units.set(aegis.id, aegis);
collectAnvils(state);
const startHp = aegis.hp;
updateCombat(state, attacker, UNITS.skiff.cooldown);
assert.equal(startHp - aegis.hp, UNITS.skiff.attack,
"full damage landed β€” an Aegis standing inside its own aura radius doesn't reduce its own damage taken");
});
// ---- Colossus splash (docs/improvement-proposals.md "Colossus splash: def-driven area damage
// as the T3 anti-mass verb"): a generic def.splash = {radius, frac} mechanic, shipped on the
// Colossus first. After the primary hit lands, enemy units near the impact point (the primary
// target's own position) take dmg*frac*(1 - d/radius) falloff damage β€” punishing exactly the
// clumped, same-owner-packed formations separation.js produces. Enemy-only (never the attacker's
// own side) and units-only (never a building, however close one stands to the impact).
test("a splash attack damages a nearby enemy unit around the impact point, on top of the primary target's own hit", () => {
const state = createGameState({ planetId: "ferros" });
const colossus = makeUnit("colossus", "player", 500, 500);
const target = makeUnit("skiff", "ai", 500 + UNITS.colossus.range - 1, 500); // the primary hit, within weapon range
const nearby = makeUnit("skiff", "ai", target.x + 10, target.y); // 10 away from the impact β€” inside splash radius
state.units.set(colossus.id, colossus);
state.units.set(target.id, target);
state.units.set(nearby.id, nearby);
// Pin the primary target explicitly: with two enemies now in aggro range, the ordinary
// dispersed auto-acquire (spreadEnemy) could otherwise fan onto either one β€” irrelevant to
// what this test checks (splash's own falloff math), so an explicit order removes the ambiguity.
colossus.order = { type: "attack", targetId: target.id };
const targetStartHp = target.hp, nearbyStartHp = nearby.hp;
updateCombat(state, colossus, UNITS.colossus.cooldown);
const { radius, frac } = UNITS.colossus.splash;
const expectedSplash = UNITS.colossus.attack * frac * (1 - 10 / radius);
assert.ok(expectedSplash > 0, "sanity: 10 units is inside the tuned splash radius");
assert.ok(Math.abs((nearbyStartHp - nearby.hp) - expectedSplash) < 1e-9,
`expected splash damage ${expectedSplash}, got ${nearbyStartHp - nearby.hp}`);
assert.equal(targetStartHp - target.hp, UNITS.colossus.attack,
"the primary target itself takes only its plain direct hit β€” splash punishes units caught nearby, not a double-dip on the target");
});
test("splash damage falls off linearly with distance from the impact point", () => {
const state = createGameState({ planetId: "ferros" });
const colossus = makeUnit("colossus", "player", 500, 500);
const target = makeUnit("skiff", "ai", 500 + UNITS.colossus.range - 1, 500);
const near = makeUnit("skiff", "ai", target.x + 5, target.y);
const far = makeUnit("skiff", "ai", target.x - 20, target.y); // farther, but still inside the splash radius
state.units.set(colossus.id, colossus);
state.units.set(target.id, target);
state.units.set(near.id, near);
state.units.set(far.id, far);
colossus.order = { type: "attack", targetId: target.id }; // pin the primary target β€” see comment above
const nearStartHp = near.hp, farStartHp = far.hp;
updateCombat(state, colossus, UNITS.colossus.cooldown);
const nearDamage = nearStartHp - near.hp, farDamage = farStartHp - far.hp;
assert.ok(farDamage > 0, "the farther bystander still takes some splash damage");
assert.ok(nearDamage > farDamage, "the closer bystander takes strictly more splash damage than the farther one");
});
test("splash never reaches an enemy unit outside the splash radius", () => {
const state = createGameState({ planetId: "ferros" });
const colossus = makeUnit("colossus", "player", 500, 500);
const target = makeUnit("skiff", "ai", 500 + UNITS.colossus.range - 1, 500);
const { radius } = UNITS.colossus.splash;
const outside = makeUnit("skiff", "ai", target.x + radius + 5, target.y); // just past the splash radius
state.units.set(colossus.id, colossus);
state.units.set(target.id, target);
state.units.set(outside.id, outside);
colossus.order = { type: "attack", targetId: target.id }; // pin the primary target β€” see comment above
const outsideHp = outside.hp;
updateCombat(state, colossus, UNITS.colossus.cooldown);
assert.equal(outside.hp, outsideHp, "an enemy outside the splash radius takes no splash damage at all");
});
test("splash never damages the attacker's own side, even standing right next to the impact point", () => {
const state = createGameState({ planetId: "ferros" });
const colossus = makeUnit("colossus", "player", 500, 500);
const target = makeUnit("skiff", "ai", 500 + UNITS.colossus.range - 1, 500);
const friendly = makeUnit("skiff", "player", target.x + 5, target.y); // player-owned, well inside the blast
state.units.set(colossus.id, colossus);
state.units.set(target.id, target);
state.units.set(friendly.id, friendly);
colossus.order = { type: "attack", targetId: target.id }; // pin the primary target β€” see comment above
const friendlyHp = friendly.hp;
updateCombat(state, colossus, UNITS.colossus.cooldown);
assert.equal(friendly.hp, friendlyHp, "a friendly unit inside the blast radius takes zero splash damage β€” no friendly fire");
});
test("splash never damages a building, even one standing well within the splash radius of a unit impact", () => {
const state = createGameState({ planetId: "ferros" });
const colossus = makeUnit("colossus", "player", 500, 500);
const target = makeUnit("skiff", "ai", 500 + UNITS.colossus.range - 1, 500);
// A building parked essentially on top of the impact point β€” colliders.js's canPlaceBuilding
// only ever gates a BUILDING's placement against other buildings/nodes, never against units
// ("Deliberately ignores units: they move"), so a unit standing this close to an enemy
// building is a real in-game configuration, not a contrived one.
const barracks = makeBuilding("barracks", "ai", target.x + 2, target.y);
state.units.set(colossus.id, colossus);
state.units.set(target.id, target);
state.buildings.set(barracks.id, barracks);
colossus.order = { type: "attack", targetId: target.id }; // pin the primary target β€” see comment above
const barracksHp = barracks.hp;
updateCombat(state, colossus, UNITS.colossus.cooldown);
assert.equal(barracks.hp, barracksHp, "a building must never take splash damage, however close it stands to the impact");
});
test("a splash death still leaves battle wreckage and pushes its own entityKilled event, same as an ordinary kill", () => {
const state = createGameState({ planetId: "ferros" });
const colossus = makeUnit("colossus", "player", 500, 500);
const target = makeUnit("skiff", "ai", 500 + UNITS.colossus.range - 1, 500);
const nearby = makeUnit("skiff", "ai", target.x + 5, target.y);
nearby.hp = 1; // its share of splash is easily enough to kill it outright
state.units.set(colossus.id, colossus);
state.units.set(target.id, target);
state.units.set(nearby.id, nearby);
colossus.order = { type: "attack", targetId: target.id }; // pin the primary target β€” see comment above
updateCombat(state, colossus, UNITS.colossus.cooldown);
assert.equal(state.units.has(nearby.id), false, "the splash-killed bystander is removed from state");
assert.ok(state.events.some(e => e.type === "entityKilled" && e.x === nearby.x && e.y === nearby.y),
"the splash kill pushes its own entityKilled event");
assert.equal(state.wrecks.length, 1, "the splash kill deposits battle wreckage through the same depositWreckage path");
});
test("multiple nearby enemies each take independently-computed splash damage from one primary hit β€” order-independent", () => {
const state = createGameState({ planetId: "ferros" });
const colossus = makeUnit("colossus", "player", 500, 500);
const target = makeUnit("skiff", "ai", 500 + UNITS.colossus.range - 1, 500);
const doomed = makeUnit("skiff", "ai", target.x + 5, target.y); // close β€” dies to its share of splash
const far = makeUnit("skiff", "ai", target.x - 20, target.y); // farther β€” survives with a smaller hit
doomed.hp = 5;
state.units.set(colossus.id, colossus);
state.units.set(target.id, target);
state.units.set(doomed.id, doomed);
state.units.set(far.id, far);
colossus.order = { type: "attack", targetId: target.id }; // pin the primary target β€” see comment above
const farStartHp = far.hp;
updateCombat(state, colossus, UNITS.colossus.cooldown);
assert.equal(state.units.has(doomed.id), false, "the closer, low-hp bystander dies to splash");
const { radius, frac } = UNITS.colossus.splash;
const expectedFarDamage = UNITS.colossus.attack * frac * (1 - 20 / radius);
assert.ok(Math.abs((farStartHp - far.hp) - expectedFarDamage) < 1e-9,
"the surviving bystander's own damage is unaffected by the other bystander dying in the same splash pass");
});
test("splash finds nearby enemies through the populated broad-phase grid too, not just the no-grid fallback", () => {
const state = createGameState({ planetId: "ferros" });
const colossus = makeUnit("colossus", "player", 1000, 500); // grid column ~10
const target = makeUnit("skiff", "ai", 1000 + UNITS.colossus.range - 1, 500);
const nearby = makeUnit("skiff", "ai", target.x + 10, target.y);
state.units.set(colossus.id, colossus);
state.units.set(target.id, target);
state.units.set(nearby.id, nearby);
colossus.order = { type: "attack", targetId: target.id }; // pin the primary target β€” see comment above
state.unitGrid = buildUnitGrid(state); // the real per-tick broad-phase index (engine/grid.js), same as sim.js builds every tick
const nearbyHp = nearby.hp;
updateCombat(state, colossus, UNITS.colossus.cooldown);
assert.ok(nearby.hp < nearbyHp, "splash should find and damage the nearby enemy through the grid path too");
});
test("a non-splash attacker never applies any splash damage β€” def.splash is opt-in per unit", () => {
const state = createGameState({ planetId: "ferros" });
const [a, b] = faceOff(state);
const bystander = makeUnit("skiff", "ai", b.x + 5, b.y); // right next to the plain Skiff's own target
state.units.set(bystander.id, bystander);
a.order = { type: "attack", targetId: b.id }; // pin the target β€” two enemies now in aggro, and this test cares
// only about splash, not which one the plain dispersed pick lands on
const bystanderHp = bystander.hp;
updateCombat(state, a, UNITS.skiff.cooldown);
assert.equal(bystander.hp, bystanderHp, "a Skiff (no def.splash) never applies splash damage to anything nearby");
});
test("performAttack stamps splashRadius on the attackHit event for a splash attacker, and leaves it falsy for a plain one", () => {
const splashState = createGameState({ planetId: "ferros" });
const colossus = makeUnit("colossus", "player", 500, 500);
const target = makeUnit("skiff", "ai", 500 + UNITS.colossus.range - 1, 500);
splashState.units.set(colossus.id, colossus);
splashState.units.set(target.id, target);
updateCombat(splashState, colossus, UNITS.colossus.cooldown);
const splashHit = splashState.events.find(e => e.type === "attackHit");
assert.ok(splashHit, "expected an attackHit event");
assert.equal(splashHit.splashRadius, UNITS.colossus.splash.radius);
const plainState = createGameState({ planetId: "ferros" });
const [a, b] = faceOff(plainState);
updateCombat(plainState, a, UNITS.skiff.cooldown);
const plainHit = plainState.events.find(e => e.type === "attackHit");
assert.ok(plainHit, "expected an attackHit event");
assert.ok(!plainHit.splashRadius, "a non-splash attacker's attackHit event carries no splashRadius");
});
// ---- The static-defense ladder (docs/improvement-proposals.md) ---------------------------------
// ---- Aegis Bastion (a static guard-aura projector, entities.js BUILDINGS.aegisbastion): the
// same anvilAura mechanic the Aegis UNIT's own guardAura already proves above (line ~908), now
// sourced from a BUILDING via sim.js collectAnvils' buildings pass. Positions mirror that existing
// unit-sourced test's own offsets/reasoning: the shield sits within its OWN 130-range aura of the
// target (100 away) but past the ATTACKER's aggro range (139 away, over the Skiff's 120), so the
// attacker can never acquire the shield itself instead of the intended target.
test("an Aegis Bastion's aura reduces damage taken by a friendly BUILDING inside its bubble, the same way the Aegis unit's aura shields a unit", () => {
const state = createGameState({ planetId: "ferros" });
const attacker = makeUnit("skiff", "player", 500, 500);
const target = makeBuilding("barracks", "ai", 500 + UNITS.skiff.range - 1, 500); // within melee range, no attack of its own
const aegisBastion = makeBuilding("aegisbastion", "ai", target.x + 100, target.y); // within the projector's own 130-range aura
state.units.set(attacker.id, attacker);
state.buildings.set(target.id, target);
state.buildings.set(aegisBastion.id, aegisBastion);
collectAnvils(state);
const startHp = target.hp;
updateCombat(state, attacker, UNITS.skiff.cooldown);
const dmg = startHp - target.hp;
const expected = UNITS.skiff.attack * BUILDINGS.aegisbastion.guardAura.damageTakenMult;
assert.ok(Math.abs(dmg - expected) < 1e-9, `expected exactly ${expected} with the aura's damage-taken multiplier applied, got ${dmg}`);
});
test("an Aegis Bastion's aura reduces damage taken by a friendly UNIT inside its bubble too β€” anvilAura is target-kind-agnostic", () => {
const state = createGameState({ planetId: "ferros" });
const attacker = makeUnit("skiff", "player", 500, 500);
const target = makeUnit("skiff", "ai", 500 + UNITS.skiff.range - 1, 500);
const aegisBastion = makeBuilding("aegisbastion", "ai", target.x + 100, target.y);
state.units.set(attacker.id, attacker);
state.units.set(target.id, target);
state.buildings.set(aegisBastion.id, aegisBastion);
collectAnvils(state);
const startHp = target.hp;
updateCombat(state, attacker, UNITS.skiff.cooldown);
const dmg = startHp - target.hp;
const expected = UNITS.skiff.attack * BUILDINGS.aegisbastion.guardAura.damageTakenMult;
assert.ok(Math.abs(dmg - expected) < 1e-9, `expected exactly ${expected}, got ${dmg}`);
});
test("an Aegis Bastion's aura doesn't shield itself β€” anvilAura's existing id-exclusion check already covers a building source, not just a unit one", () => {
const state = createGameState({ planetId: "ferros" });
const attacker = makeUnit("skiff", "player", 500, 500);
const aegisBastion = makeBuilding("aegisbastion", "ai", 500 + UNITS.skiff.range - 1, 500); // trivially within its own aura (distance 0)
state.units.set(attacker.id, attacker);
state.buildings.set(aegisBastion.id, aegisBastion);
collectAnvils(state);
const startHp = aegisBastion.hp;
updateCombat(state, attacker, UNITS.skiff.cooldown);
assert.equal(startHp - aegisBastion.hp, UNITS.skiff.attack,
"full damage landed β€” an Aegis Bastion standing inside its own aura radius doesn't reduce its own damage taken");
});
test("a still-constructing Aegis Bastion projects no aura yet β€” collectAnvils skips it until it's actually finished standing", () => {
const state = createGameState({ planetId: "ferros" });
const attacker = makeUnit("skiff", "player", 500, 500);
const target = makeUnit("skiff", "ai", 500 + UNITS.skiff.range - 1, 500);
const aegisBastion = makeBuilding("aegisbastion", "ai", target.x + 100, target.y, { constructing: true });
state.units.set(attacker.id, attacker);
state.units.set(target.id, target);
state.buildings.set(aegisBastion.id, aegisBastion);
collectAnvils(state);
const startHp = target.hp;
updateCombat(state, attacker, UNITS.skiff.cooldown);
assert.equal(startHp - target.hp, UNITS.skiff.attack, "no aura discount β€” the projector isn't finished yet");
});
// ---- Plasma Torpedo Battery (ammo-fed static defense, entities.js BUILDINGS.torpedobattery):
// updateBuildingCombat requires building.input[ammo.com] >= ammo.perShot before firing, and
// decrements it on every shot β€” a dry battery holds fire rather than shooting for free.
function batteryAt(state, x = 500, y = 500, ammo = 10) {
const b = makeBuilding("torpedobattery", "player", x, y);
b.input = { plasmatorp: ammo };
state.buildings.set(b.id, b);
return b;
}
test("a stocked Torpedo Battery fires on an enemy in range and decrements its ammo larder by exactly one shot's worth", () => {
const state = createGameState({ planetId: "ferros" });
const battery = batteryAt(state);
const enemy = makeUnit("skiff", "ai", battery.x + 10, battery.y);
state.units.set(enemy.id, enemy);
const startHp = enemy.hp;
const startAmmo = battery.input.plasmatorp;
updateBuildingCombat(state, battery, BUILDINGS.torpedobattery.cooldown);
assert.equal(startHp - enemy.hp, BUILDINGS.torpedobattery.attack, "landed its full attack");
assert.equal(battery.input.plasmatorp, startAmmo - BUILDINGS.torpedobattery.ammo.perShot,
"decremented the ammo larder by exactly one shot's worth");
});
test("a dry Torpedo Battery holds fire β€” a target in range and cooldown ready, but no ammo banked", () => {
const state = createGameState({ planetId: "ferros" });
const battery = batteryAt(state, 500, 500, 0); // empty larder
const enemy = makeUnit("skiff", "ai", battery.x + 10, battery.y);
state.units.set(enemy.id, enemy);
const startHp = enemy.hp;
updateBuildingCombat(state, battery, BUILDINGS.torpedobattery.cooldown);
assert.equal(enemy.hp, startHp, "a dry battery deals no damage β€” it holds fire rather than shooting for free");
assert.equal(battery.targetId, enemy.id, "it still acquires/tracks the threat β€” only firing is gated, not targeting");
});
test("a Torpedo Battery with less than one shot's worth banked still holds fire", () => {
const state = createGameState({ planetId: "ferros" });
const battery = batteryAt(state, 500, 500, BUILDINGS.torpedobattery.ammo.perShot / 2);
const enemy = makeUnit("skiff", "ai", battery.x + 10, battery.y);
state.units.set(enemy.id, enemy);
const startHp = enemy.hp;
updateBuildingCombat(state, battery, BUILDINGS.torpedobattery.cooldown);
assert.equal(enemy.hp, startHp, "half a shot isn't enough to fire");
});
test("a Torpedo Battery with no input buffer at all (never yet serviced) holds fire without throwing", () => {
const state = createGameState({ planetId: "ferros" });
const battery = makeBuilding("torpedobattery", "player", 500, 500); // no .input set at all
const enemy = makeUnit("skiff", "ai", battery.x + 10, battery.y);
state.units.set(enemy.id, enemy);
const startHp = enemy.hp;
assert.doesNotThrow(() => updateBuildingCombat(state, battery, BUILDINGS.torpedobattery.cooldown));
assert.equal(enemy.hp, startHp, "an unserviced battery has never fired a shot");
});
test("a Torpedo Battery resumes firing the instant its larder is topped back up past perShot, without waiting on a stale cooldown", () => {
const state = createGameState({ planetId: "ferros" });
const battery = batteryAt(state, 500, 500, 0);
const enemy = makeUnit("skiff", "ai", battery.x + 10, battery.y);
state.units.set(enemy.id, enemy);
updateBuildingCombat(state, battery, BUILDINGS.torpedobattery.cooldown); // dry β€” holds fire
assert.equal(battery.attackTimer, 0, "an unfired shot never starts the cooldown β€” it stays ready to fire immediately");
battery.input.plasmatorp = BUILDINGS.torpedobattery.ammo.perShot; // a worker delivers a torpedo
const startHp = enemy.hp;
updateBuildingCombat(state, battery, 0.1);
assert.ok(startHp - enemy.hp > 0, "fires the instant ammo is available");
assert.equal(battery.input.plasmatorp, 0, "and consumes exactly the one shot's worth it just fired");
});
test("Overcharged Weapons and a dry magazine compose correctly: still holds fire regardless of the damage multiplier", () => {
const state = createGameState({ planetId: "ferros" });
const battery = batteryAt(state, 500, 500, 0);
const enemy = makeUnit("skiff", "ai", battery.x + 10, battery.y);
state.units.set(enemy.id, enemy);
state.players.player.upgrades.overchargedWeapons = true;
const startHp = enemy.hp;
updateBuildingCombat(state, battery, BUILDINGS.torpedobattery.cooldown);
assert.equal(enemy.hp, startHp, "no damage multiplier matters when the battery never fires at all");
});
/* ---------------------------------------------------------------------------------------------
Veterancy ranks (docs/improvement-proposals.md "Veterancy ranks: kills forge small combat
multipliers and visible chevrons"): each confirmed kill increments a UNIT-kind attacker's
`kills` (buildings/turrets never accrue it); rankMults(unit) maps the running total to a
damage-dealt/damage-taken multiplier pair via the 3/8/18 thresholds, applied in attackDamage
beside the existing upgradeMult product β€” symmetrically for both sides, with zero AI-side code.
--------------------------------------------------------------------------------------------- */
test("a unit-kind attacker's kills increments by exactly one when its hit kills the target", () => {
const state = createGameState({ planetId: "ferros" });
const [a, b] = faceOff(state);
b.hp = 1; // dies to this hit
updateCombat(state, a, UNITS.skiff.cooldown);
assert.equal(a.kills, 1);
});
test("kills does not increment when the target survives the hit", () => {
const state = createGameState({ planetId: "ferros" });
const [a, b] = faceOff(state); // full hp β€” this single hit won't kill it
updateCombat(state, a, UNITS.skiff.cooldown);
assert.ok(b.hp > 0, "fixture sanity: the target survived");
assert.equal(a.kills, undefined, "no kill yet β€” the field is never even initialized");
});
test("a turret's (building-kind) attacker never accrues kills β€” veterancy is a unit-only trait", () => {
const state = createGameState({ planetId: "ferros" });
const turret = turretAt(state);
const enemy = makeUnit("skiff", "ai", 510, 500);
enemy.hp = 1;
state.units.set(enemy.id, enemy);
updateBuildingCombat(state, turret, BUILDINGS.turret.cooldown);
assert.equal(state.units.has(enemy.id), false, "fixture sanity: the turret's hit did kill it");
assert.equal(turret.kills, undefined, "buildings/turrets are never attacker.kind === 'unit'");
});
test("kills accumulates across repeated kills, one per confirmed kill", () => {
const state = createGameState({ planetId: "ferros" });
const attacker = makeUnit("skiff", "player", 500, 500);
state.units.set(attacker.id, attacker);
for (let i = 0; i < 3; i++) {
const victim = makeUnit("skiff", "ai", 505, 500);
victim.hp = 1;
state.units.set(victim.id, victim);
updateCombat(state, attacker, UNITS.skiff.cooldown);
}
assert.equal(attacker.kills, 3);
});
test("rankMults returns no bonus below the first veterancy threshold (0 kills, or a bare unit with no field at all)", () => {
assert.deepEqual(rankMults({ kills: 0 }), { rank: 0, dealtMult: 1, takenMult: 1 });
assert.deepEqual(rankMults({ kills: 2 }), { rank: 0, dealtMult: 1, takenMult: 1 });
assert.deepEqual(rankMults({}), { rank: 0, dealtMult: 1, takenMult: 1 }, "a fresh unit off the line has no kills field yet");
});
test("rankMults confers rank 1 at 3 kills: +6% damage dealt, -6% damage taken", () => {
const m = rankMults({ kills: 3 });
assert.equal(m.rank, 1);
assert.ok(Math.abs(m.dealtMult - 1.06) < 1e-9, `dealtMult ${m.dealtMult}`);
assert.ok(Math.abs(m.takenMult - 0.94) < 1e-9, `takenMult ${m.takenMult}`);
});
test("rankMults confers rank 2 at 8 kills, stacking a second +6%/-6% layer", () => {
const m = rankMults({ kills: 8 });
assert.equal(m.rank, 2);
assert.ok(Math.abs(m.dealtMult - 1.06 ** 2) < 1e-9);
assert.ok(Math.abs(m.takenMult - 0.94 ** 2) < 1e-9);
});
test("rankMults confers rank 3 at 18 kills, the top of the ladder", () => {
const m = rankMults({ kills: 18 });
assert.equal(m.rank, 3);
assert.ok(Math.abs(m.dealtMult - 1.06 ** 3) < 1e-9);
assert.ok(Math.abs(m.takenMult - 0.94 ** 3) < 1e-9);
});
test("rankMults never exceeds rank 3 no matter how many kills pile up", () => {
assert.equal(rankMults({ kills: 500 }).rank, 3);
});
test("rankMults thresholds are exclusive on the low side: one kill short of a threshold stays at the previous rank", () => {
assert.equal(rankMults({ kills: 2 }).rank, 0);
assert.equal(rankMults({ kills: 7 }).rank, 1);
assert.equal(rankMults({ kills: 17 }).rank, 2);
});
test("a veteran attacker's rank multiplier applies in attackDamage, on top of the existing upgrade multipliers", () => {
const state = createGameState({ planetId: "ferros" });
const [a, b] = faceOff(state);
a.kills = 3; // rank 1
const startHp = b.hp;
updateCombat(state, a, UNITS.skiff.cooldown);
const dealt = startHp - b.hp;
const expected = UNITS.skiff.attack * rankMults(a).dealtMult;
assert.ok(Math.abs(dealt - expected) < 1e-9, `expected ${expected}, dealt ${dealt}`);
});
test("a veteran defender's rank multiplier reduces the damage it takes", () => {
const state = createGameState({ planetId: "ferros" });
const [a, b] = faceOff(state);
b.kills = 8; // rank 2
const startHp = b.hp;
updateCombat(state, a, UNITS.skiff.cooldown);
const dealt = startHp - b.hp;
const expected = UNITS.skiff.attack * rankMults(b).takenMult;
assert.ok(Math.abs(dealt - expected) < 1e-9, `expected ${expected}, dealt ${dealt}`);
});
test("veterancy applies symmetrically to an AI-owned attacker β€” no AI-side code needed", () => {
const state = createGameState({ planetId: "ferros" });
const attacker = makeUnit("skiff", "ai", 500, 500);
const target = makeUnit("skiff", "player", 510, 500);
attacker.kills = 3;
state.units.set(attacker.id, attacker);
state.units.set(target.id, target);
const startHp = target.hp;
updateCombat(state, attacker, UNITS.skiff.cooldown);
const dealt = startHp - target.hp;
const expected = UNITS.skiff.attack * rankMults(attacker).dealtMult;
assert.ok(Math.abs(dealt - expected) < 1e-9, "the AI's own veteran unit gets the identical bonus a player veteran would");
});
/* ---------------------------------------------------------------------------------------------
Tiered destruction (docs/improvement-proposals.md "Tiered destruction: deaths scale with what
died"): the entityKilled event carries unitType/kind on top of the pre-existing x/y/owner, so
boot.js/effects.js/renderEffects.js/sound.js can scale the death's visuals and audio by what
actually died. Purely additive fields β€” the state-mutation side (removeEntity, wreckage) is
untouched, so byte-identical determinism holds.
--------------------------------------------------------------------------------------------- */
test("performAttack's entityKilled event carries the dead entity's unitType and kind", () => {
const state = createGameState({ planetId: "ferros" });
const [a, b] = faceOff(state);
b.hp = 1;
updateCombat(state, a, UNITS.skiff.cooldown);
const ev = state.events.find(e => e.type === "entityKilled");
assert.ok(ev, "fixture sanity: the hit killed the target");
assert.equal(ev.unitType, "skiff");
assert.equal(ev.kind, "unit");
});
test("a turret kill's entityKilled event carries the KILLED entity's unitType/kind, not the turret's", () => {
const state = createGameState({ planetId: "ferros" });
const turret = turretAt(state);
const enemy = makeUnit("skiff", "ai", 510, 500);
enemy.hp = 1;
state.units.set(enemy.id, enemy);
updateBuildingCombat(state, turret, BUILDINGS.turret.cooldown);
const ev = state.events.find(e => e.type === "entityKilled");
assert.ok(ev);
assert.equal(ev.unitType, "skiff");
assert.equal(ev.kind, "unit");
});
test("a splash kill's entityKilled event also carries unitType/kind", () => {
const state = createGameState({ planetId: "ferros" });
const colossus = makeUnit("colossus", "player", 500, 500);
const target = makeUnit("skiff", "ai", 500 + UNITS.colossus.range - 1, 500);
const nearby = makeUnit("skiff", "ai", target.x + 10, target.y);
nearby.hp = 1; // dies to splash falloff
state.units.set(colossus.id, colossus);
state.units.set(target.id, target);
state.units.set(nearby.id, nearby);
colossus.order = { type: "attack", targetId: target.id };
updateCombat(state, colossus, UNITS.colossus.cooldown);
const splashKill = state.events.find(e => e.type === "entityKilled" && e.x === nearby.x && e.y === nearby.y);
assert.ok(splashKill, "the splash-killed unit pushed its own entityKilled event");
assert.equal(splashKill.unitType, "skiff");
assert.equal(splashKill.kind, "unit");
});
test("a plain move order clears a stale auto-acquired target β€” a retreating unit is not still in combat mode (A13)", () => {
// updateCombat returns before the acquisition block on a plain move, and combat.js:57 is the ONLY
// place in the whole engine that writes autoTarget β€” so the field was unreachable-for-clearing for
// the entire duration of a move. separation.js's isCombatMode reads it as a combat-mode register,
// directly contradicting its own doc comment two lines above ("Deliberately NOT combat mode for a
// plain 'move' order… a stale autoTarget reads as still-combat-mode for ONE TICK too long β€”
// harmless"). Pulling a unit out of a fight with Move is the documented way to disengage, so
// retreating units used the tight combat packing forever instead of the padded idle spacing.
const s = createGameState({ planetId: "ferros", seed: 21 });
s.units.clear();
const mine = makeUnit("skiff", "player", 700, 500);
const foe = makeUnit("skiff", "ai", 720, 500);
s.units.set(mine.id, mine);
s.units.set(foe.id, foe);
updateCombat(s, mine, 0.1);
assert.equal(mine.autoTarget, foe.id, "fixture sanity: it auto-acquired the enemy");
mine.order = { type: "move", x: 200, y: 200 };
updateCombat(s, mine, 0.1);
assert.equal(mine.autoTarget, null,
"a plain move order must clear the stale target rather than leaving the unit flagged combat-mode");
});