totalannihilation: add building upgrades and spinning toppers

- New `upgradesFrom` rule: a building def (Nuclear Power Plant on the
  Energy Generator, Advanced Metal Generator on the Mass Generator) may
  be placed exactly on a friendly, finished building of the named type,
  consuming it for a 50% refund of its build cost instead of needing
  clear ground (TALogic findUpgradeTarget / consumeUpgrade / buildCommand)
- New `topperFrame`: a second sprite stacked on the finished structure
  that spins continuously (the Advanced Metal Generator's centrifuge,
  frame 26), hidden while under construction; TAArt sizes the sheet for
  it and paints a generic spinner-glyph fallback
- New rules: Nuclear Power Plant (frames 22-23) and Advanced Metal
  Generator (frames 24-26) in totalannihilation-rules.json
- TARules: validate topperFrame and upgradesFrom (real building with a
  cost); TAWorldView renders/spins the topper; verifyTotalAnnihilation
  checks topper frames fit and were painted
- Test section covering upgrade placement (ownership, type match, exact
  footprint, refund amount, army-less fallback) and sprites.md updated
  for frames 22-26
This commit is contained in:
Brian Fertig 2026-08-16 15:35:31 -06:00
parent c690d80e68
commit 3c7184564a
9 changed files with 344 additions and 21 deletions

View File

@ -38,7 +38,7 @@
"arm-structures": { "arm-structures": {
"key": "ta-arm-structures", "path": "assets/images/ta/arm-structures.png", "kind": "structure", "key": "ta-arm-structures", "path": "assets/images/ta/arm-structures.png", "kind": "structure",
"frameWidth": 192, "frameHeight": 192, "cols": 6, "frameWidth": 192, "frameHeight": 192, "cols": 6,
"rows": 4 "rows": 6
}, },
"core-units": { "core-units": {
"key": "ta-core-units", "path": "assets/images/ta/core-units.png", "kind": "unit", "key": "ta-core-units", "path": "assets/images/ta/core-units.png", "kind": "unit",
@ -48,7 +48,7 @@
"core-structures": { "core-structures": {
"key": "ta-core-structures", "path": "assets/images/ta/core-structures.png", "kind": "structure", "key": "ta-core-structures", "path": "assets/images/ta/core-structures.png", "kind": "structure",
"frameWidth": 192, "frameHeight": 192, "cols": 6, "frameWidth": 192, "frameHeight": 192, "cols": 6,
"rows": 4 "rows": 6
}, },
"terrain-grasslands": { "terrain-grasslands": {
"key": "ta-terrain-grasslands", "path": "assets/images/ta/terrain-grasslands.png", "kind": "terrain", "key": "ta-terrain-grasslands", "path": "assets/images/ta/terrain-grasslands.png", "kind": "terrain",

View File

@ -1024,6 +1024,39 @@
"spritePx": 50, "spritePx": 50,
"moveSound": "sfx-engine-heavy" "moveSound": "sfx-engine-heavy"
}, },
{
"id": "advancedconstructor",
"name": "Advanced Construction Vehicle",
"role": "builder",
"size": "medium",
"radius": 26,
"hp": 950,
"speed": 80,
"turnRate": 2.2,
"moveClass": "tread",
"armorClass": "light",
"sight": 360,
"cost": {
"energy": 900,
"mass": 220
},
"buildTime": 22,
"builtBy": [
"advancedvehicleplant"
],
"buildPower": 135,
"buildRange": 200,
"builds": [
"advancedmassgen",
"nuclearplant"
],
"sheetSlot": "unitSheet",
"frame": 17,
"procShape": "constructor",
"icon": 33,
"spritePx": 54,
"moveSound": "sfx-engine-heavy"
},
{ {
"id": "fighter", "id": "fighter",
"name": "Fighter", "name": "Fighter",
@ -1182,6 +1215,38 @@
"icon": 10, "icon": 10,
"idleAnimation": true "idleAnimation": true
}, },
{
"id": "nuclearplant",
"name": "Nuclear Power Plant",
"footprint": {
"w": 2,
"h": 2
},
"hp": 4200,
"armorClass": "structure",
"sight": 160,
"cost": {
"energy": 2200,
"mass": 260
},
"buildTime": 42,
"produce": {
"energy": 80
},
"storage": {
"energy": 900
},
"deathExplosion": {
"radius": 220,
"damage": 650
},
"upgradesFrom": "energygen",
"sheetSlot": "structureSheet",
"frame": 22,
"buildFrame": 23,
"procShape": "energyGen",
"icon": 35
},
{ {
"id": "massgen", "id": "massgen",
"name": "Mass Generator", "name": "Mass Generator",
@ -1214,6 +1279,39 @@
"icon": 11, "icon": 11,
"idleAnimation": true "idleAnimation": true
}, },
{
"id": "advancedmassgen",
"name": "Advanced Metal Generator",
"footprint": {
"w": 2,
"h": 2
},
"hp": 3200,
"armorClass": "structure",
"sight": 160,
"cost": {
"energy": 1400,
"mass": 150
},
"buildTime": 30,
"produce": {
"mass": 8.0
},
"upkeep": {
"energy": 60
},
"storage": {
"mass": 400
},
"terrainMultiplier": "massMultiplier",
"upgradesFrom": "massgen",
"sheetSlot": "structureSheet",
"frame": 24,
"buildFrame": 25,
"topperFrame": 26,
"procShape": "massGen",
"icon": 34
},
{ {
"id": "barracks", "id": "barracks",
"name": "Barracks", "name": "Barracks",
@ -1356,10 +1454,8 @@
"buildTime": 55, "buildTime": 55,
"buildPower": 140, "buildPower": 140,
"builds": [ "builds": [
"jeep", "megatank",
"tank", "advancedconstructor"
"rockettank",
"megatank"
], ],
"spawnOffset": { "spawnOffset": {
"x": 0, "x": 0,

View File

@ -671,7 +671,9 @@ const PROC_PAINTERS = {
structure(scene, key, spec, rules) { structure(scene, key, spec, rules) {
const defs = rules.buildings.filter((b) => b.sheetSlot === 'structureSheet'); const defs = rules.buildings.filter((b) => b.sheetSlot === 'structureSheet');
let count = 0; let count = 0;
for (const d of defs) count = Math.max(count, d.frame + 1, (d.buildFrame ?? -1) + 1); for (const d of defs) {
count = Math.max(count, d.frame + 1, (d.buildFrame ?? -1) + 1, (d.topperFrame ?? -1) + 1);
}
const sh = mkCanvasSheet(scene, key, spec.frameWidth, spec.frameHeight, spec.cols, count); const sh = mkCanvasSheet(scene, key, spec.frameWidth, spec.frameHeight, spec.cols, count);
for (const d of defs) { for (const d of defs) {
const draw = STRUCT_SHAPES[d.procShape]; const draw = STRUCT_SHAPES[d.procShape];
@ -684,6 +686,22 @@ const PROC_PAINTERS = {
ctx.globalAlpha = 1; ctx.globalAlpha = 1;
}); });
} }
// A generic spinner glyph — real toppers (the Advanced Metal Generator's centrifuge) get
// real art; this is just enough for the frame to register and not render blank.
if (d.topperFrame != null) {
sh.at(d.topperFrame, (ctx, S) => {
const c = S / 2, r = S * 0.3;
ctx.strokeStyle = '#cfe3ff'; ctx.lineWidth = S * 0.035;
ctx.beginPath(); ctx.arc(c, c, r, 0, Math.PI * 2); ctx.stroke();
for (let i = 0; i < 4; i++) {
const ang = (Math.PI / 2) * i;
ctx.beginPath();
ctx.moveTo(c, c);
ctx.lineTo(c + Math.cos(ang) * r, c + Math.sin(ang) * r);
ctx.stroke();
}
});
}
} }
return sh.finish(); return sh.finish();
}, },

View File

@ -370,15 +370,58 @@ function buildCommand(state, rules, army, units, order, queue) {
if (!(bdef.builds ?? []).includes(order.defId)) { if (!(bdef.builds ?? []).includes(order.defId)) {
return { ok: false, error: `${bdef.name} cannot build ${def.name}` }; return { ok: false, error: `${bdef.name} cannot build ${def.name}` };
} }
const legal = canPlaceAt(state, rules, order.tx, order.ty, def); const legal = canPlaceAt(state, rules, order.tx, order.ty, def, army);
if (!legal.ok) return legal; if (!legal.ok) return legal;
// An upgrade building (Nuclear Power Plant over an Energy Generator, Advanced Metal Generator
// over a Mass Generator) may land exactly on the friendly building it names in `upgradesFrom`
// instead of needing clear ground — canPlaceAt already let this through, so re-find the same
// target here and consume it.
const upgradeTarget = findUpgradeTarget(state, army, order.tx, order.ty, def);
if (upgradeTarget) reclaimUpgradeTarget(state, rules, army, upgradeTarget);
const site = placeBuilding(state, rules, army, order.defId, order.tx, order.ty); const site = placeBuilding(state, rules, army, order.defId, order.tx, order.ty);
if (!site) return { ok: false, error: 'placement failed' }; if (!site) return { ok: false, error: 'placement failed' };
pushOrder(builder, { type: 'build', targetId: site.id }, queue); pushOrder(builder, { type: 'build', targetId: site.id }, queue);
return { ok: true, siteId: site.id }; return { ok: true, siteId: site.id };
} }
/**
* A friendly, finished building of `def.upgradesFrom`'s type sitting in EXACTLY this footprint
* the only shape of "occupied" an upgrade building is allowed to land on. Deliberately not a
* looser "overlaps anywhere" match: a 2x2 upgrade has to replace a 2x2 original one-for-one, not
* clip a neighbour.
*/
function findUpgradeTarget(state, army, tx, ty, def) {
if (!def.upgradesFrom) return null;
for (const e of state.entities) {
if (e.dead || e.site || !e.isBuilding || e.army !== army) continue;
if (e.defId !== def.upgradesFrom) continue;
if (e.tx === tx && e.ty === ty && e.fw === def.footprint.w && e.fh === def.footprint.h) return e;
}
return null;
}
/**
* Consume a building being upgraded in place: half its own build cost comes back (a genuine
* refund, not a discount on the new building's price), and it disappears quietly no death
* explosion, no kill/loss stat, no 'unitDestroyed' blast FX. This is recycling your own
* structure to make room for its replacement, not losing it to the enemy.
*/
function reclaimUpgradeTarget(state, rules, army, target) {
const tdef = rules.buildingById[target.defId];
const a = state.armies[army];
a.energy = Math.min(a.energyCap, a.energy + (tdef.cost?.energy ?? 0) * 0.5);
a.mass = Math.min(a.massCap, a.mass + (tdef.cost?.mass ?? 0) * 0.5);
target.dead = true;
stampFootprint(state.nav, rules, target.tx, target.ty, target.fw, target.fh, false);
recomputeEconomyCaps(state, rules);
for (const o of state.entities) {
if (o.buildTargetId === target.id) o.buildTargetId = 0;
if (o.targetId === target.id) o.targetId = 0;
}
}
/** /**
* Remove one order from a unit's queue by index the missing counterpart to `stop`, which * Remove one order from a unit's queue by index the missing counterpart to `stop`, which
* only ever clears the whole queue. Cancelling the active order (index 0) gets the same state * only ever clears the whole queue. Cancelling the active order (index 0) gets the same state
@ -418,12 +461,20 @@ export function cancelOrder(state, rules, army, unitId, index) {
return { ok: true }; return { ok: true };
} }
/** Can this footprint go here? Checks bounds, terrain buildability and occupancy. */ /**
export function canPlaceAt(state, rules, tx, ty, def) { * Can this footprint go here? Checks bounds, terrain buildability and occupancy.
*
* `army` is optional and only needed to recognise an upgrade placement (see `upgradesFrom`)
* every caller that doesn't pass one just gets the plain "is this ground clear" answer, which
* is what siting logic that has no notion of ownership (the AI's placement search, verify
* fixtures) actually wants anyway.
*/
export function canPlaceAt(state, rules, tx, ty, def, army = null) {
const fw = def.footprint.w, fh = def.footprint.h; const fw = def.footprint.w, fh = def.footprint.h;
if (tx < 0 || ty < 0 || tx + fw > state.w || ty + fh > state.h) { if (tx < 0 || ty < 0 || tx + fw > state.w || ty + fh > state.h) {
return { ok: false, error: 'off the map' }; return { ok: false, error: 'off the map' };
} }
if (army != null && findUpgradeTarget(state, army, tx, ty, def)) return { ok: true };
for (let y = ty; y < ty + fh; y++) { for (let y = ty; y < ty + fh; y++) {
for (let x = tx; x < tx + fw; x++) { for (let x = tx; x < tx + fw; x++) {
const i = y * state.w + x; const i = y * state.w + x;

View File

@ -303,6 +303,9 @@ export function compileRules(json) {
fail(`building "${b.id}" needs a footprint of at least 1x1 tiles`); fail(`building "${b.id}" needs a footprint of at least 1x1 tiles`);
} }
if (!Number.isInteger(b.frame) || b.frame < 0) fail(`building "${b.id}" needs a non-negative integer frame`); if (!Number.isInteger(b.frame) || b.frame < 0) fail(`building "${b.id}" needs a non-negative integer frame`);
if (b.topperFrame != null && (!Number.isInteger(b.topperFrame) || b.topperFrame < 0)) {
fail(`building "${b.id}" topperFrame must be a non-negative integer`);
}
if (b.terrainMultiplier && !terrain.some((t) => b.terrainMultiplier in t)) { if (b.terrainMultiplier && !terrain.some((t) => b.terrainMultiplier in t)) {
fail(`building "${b.id}" terrainMultiplier "${b.terrainMultiplier}" is on no terrain type`); fail(`building "${b.id}" terrainMultiplier "${b.terrainMultiplier}" is on no terrain type`);
} }
@ -355,6 +358,15 @@ export function compileRules(json) {
} }
} }
} }
// `upgradesFrom` names the building a def may be placed directly on top of, reclaiming half
// its build cost (see TALogic.buildCommand/findUpgradeTarget) — must be a real building, and
// one with a real cost to actually reclaim from.
for (const b of buildings) {
if (b.upgradesFrom == null) continue;
const from = buildingById[b.upgradesFrom];
if (!from) fail(`building "${b.id}" upgradesFrom references unknown building "${b.upgradesFrom}"`);
else if (!from.cost) fail(`building "${b.id}" upgradesFrom "${b.upgradesFrom}" has no cost to reclaim`);
}
// ---- victory modes ----------------------------------------------------- // ---- victory modes -----------------------------------------------------
// The engine understands two rules: "commander" (an army dies with its Commander) and // The engine understands two rules: "commander" (an army dies with its Commander) and

View File

@ -384,6 +384,17 @@ export default class TAWorldView {
this._addWorld(turret); this._addWorld(turret);
} }
// A building's decorative topper (e.g. the Advanced Metal Generator's centrifuge): a second
// sprite stacked on the roof that spins continuously and fast, independent of the slow
// idleAnimation breathing/turn every other resource building gets — this is meant to read as
// active machinery, not ambient scenery. Hidden while under construction (see render()).
let topper = null;
if (def.isBuilding && def.topperFrame != null) {
topper = this.scene.add.image(e.x, e.y, key, def.topperFrame);
topper.setDisplaySize(def.footprint.w * this.ts, def.footprint.h * this.ts);
this._addWorld(topper);
}
// Anything that leaves the ground gets a flattened black copy of its own frame beneath it. // Anything that leaves the ground gets a flattened black copy of its own frame beneath it.
let shadow = null; let shadow = null;
if (def.flight) { if (def.flight) {
@ -394,7 +405,7 @@ export default class TAWorldView {
// `alt` is the current altitude as a 0..1 fraction of def.flight.height, eased toward its // `alt` is the current altitude as a 0..1 fraction of def.flight.height, eased toward its
// target every frame in render(). Newly built units start on the ground. // target every frame in render(). Newly built units start on the ground.
s = { s = {
img, turret, final, shadow, defId: e.defId, img, turret, final, shadow, topper, defId: e.defId,
alt: 0, baseScale: img.scaleX, alt: 0, baseScale: img.scaleX,
batchDone: 0, batchPrev: 0, // production-dial accounting, see _drawProductionPies batchDone: 0, batchPrev: 0, // production-dial accounting, see _drawProductionPies
}; };
@ -409,6 +420,7 @@ export default class TAWorldView {
s.turret?.destroy(); s.turret?.destroy();
s.final?.destroy(); s.final?.destroy();
s.shadow?.destroy(); s.shadow?.destroy();
s.topper?.destroy();
this.sprites.delete(id); this.sprites.delete(id);
} }
@ -449,6 +461,7 @@ export default class TAWorldView {
if (s.turret) s.turret.setVisible(shown); if (s.turret) s.turret.setVisible(shown);
if (s.final) s.final.setVisible(shown); if (s.final) s.final.setVisible(shown);
if (s.shadow) s.shadow.setVisible(shown); if (s.shadow) s.shadow.setVisible(shown);
if (s.topper) s.topper.setVisible(shown);
if (!shown) continue; if (!shown) continue;
const x = e.px + (e.x - e.px) * alpha; const x = e.px + (e.x - e.px) * alpha;
@ -520,6 +533,21 @@ export default class TAWorldView {
s.img.setRotation((s.idleRot.turns + turnT) * (Math.PI / 2)); s.img.setRotation((s.idleRot.turns + turnT) * (Math.PI / 2));
} }
// The topper (e.g. the Advanced Metal Generator's centrifuge) spins fast and continuously
// off real time rather than sim ticks, so it stays smooth regardless of simSpeed and never
// needs its own per-entity timer state. Hidden while the site is still a wireframe skeleton
// — a spinning centrifuge on unfinished girders would read as broken, not "under construction".
if (s.topper) {
s.topper.setPosition(x, y);
s.topper.setDepth(band + (y / state.worldH) * 10 + 0.06);
if (e.site) {
s.topper.setAlpha(0);
} else {
const spinMs = 1800; // one full rotation every 1.8s — fast enough to read as active
s.topper.setAlpha(1).setRotation(((this.scene.time.now % spinMs) / spinMs) * Math.PI * 2);
}
}
if (s.turret) { if (s.turret) {
const tr = lerpAngle(e.pturretRot, e.turretRot, alpha); const tr = lerpAngle(e.pturretRot, e.turretRot, alpha);
s.turret.setPosition(x, y - lift).setRotation(tr); s.turret.setPosition(x, y - lift).setRotation(tr);

View File

@ -884,7 +884,7 @@ export default class TotalAnnihilationGame extends Phaser.Scene {
this.placement.tx = tx; this.placement.ty = ty; this.placement.tx = tx; this.placement.ty = ty;
this.view.placement = { this.view.placement = {
def, tx, ty, def, tx, ty,
legal: Logic.canPlaceAt(this.match, this.rules, tx, ty, def), legal: Logic.canPlaceAt(this.match, this.rules, tx, ty, def, this.playerArmy),
builderX: builder?.x, builderY: builder?.y, builderX: builder?.x, builderY: builder?.y,
buildRange: this.rules.defById[builder?.defId ?? '']?.buildRange ?? 0, buildRange: this.rules.defById[builder?.defId ?? '']?.buildRange ?? 0,
}; };

View File

@ -43,7 +43,7 @@ Frames are numbered **left to right, then top to bottom**, starting at 0.
--- ---
## Unit sheets (64×64 cells, 8 per row) ## Unit sheets (128×128 cells, 8 per row)
Both army sheets use the identical frame layout — that is what lets one unit definition serve Both army sheets use the identical frame layout — that is what lets one unit definition serve
every army. `spritePx` is the on-screen size the frame is scaled to; draw to fill the cell and every army. `spritePx` is the on-screen size the frame is scaled to; draw to fill the cell and
@ -68,6 +68,7 @@ let the scale do the work.
| 14 | Hover Constructor | skirted hull over a plenum, nanolathe crane, no tracks | 52 | | 14 | Hover Constructor | skirted hull over a plenum, nanolathe crane, no tracks | 52 |
| 15 | Megatank | hull + tracks, wider than the Tank | 66 | | 15 | Megatank | hull + tracks, wider than the Tank | 66 |
| 16 | Megatank turret | twin cannon barrels + side rocket pods | 66 | | 16 | Megatank turret | twin cannon barrels + side rocket pods | 66 |
| 17 | Advanced Construction Vehicle | tracked hull, twin nanolathe cranes, no gun | 54 |
### Units that leave the ground ### Units that leave the ground
@ -120,13 +121,14 @@ independently, so the unit aims while it drives. Rules:
sits at the cell centre (64, 64 for the current 128×128 unit cells), with the barrel sits at the cell centre (64, 64 for the current 128×128 unit cells), with the barrel
extending to the **right**. extending to the **right**.
- Leave the rest of the turret cell transparent. The hull shows through it. - Leave the rest of the turret cell transparent. The hull shows through it.
- A unit with no turret (infantry, sniper, rocket trooper, construction vehicle, and all three - A unit with no turret (infantry, sniper, rocket trooper, construction vehicle, advanced
Airfield units) simply has no turret frame; its whole sprite rotates to face its target. The construction vehicle, and all three Airfield units) simply has no turret frame; its whole
Construction Vehicle and Hover Constructor are unarmed, so they never turn to aim regardless — sprite rotates to face its target. The two Construction Vehicles and the Hover Constructor are
they just rotate to face wherever they're moving. Aircraft have fixed forward guns, so they unarmed, so they never turn to aim regardless — they just rotate to face wherever they're
aim by pointing the whole airframe, which is why they have no turret either. moving. Aircraft have fixed forward guns, so they aim by pointing the whole airframe, which is
why they have no turret either.
Frames 1763 are free. Add a unit by appending a definition to `units[]` in Frames 1863 are free. Add a unit by appending a definition to `units[]` in
`data/totalannihilation-rules.json` with its `frame` (and optional `turretFrame`) — no code. `data/totalannihilation-rules.json` with its `frame` (and optional `turretFrame`) — no code.
A weapon may declare `barrels` (default 1) and, when `barrels > 1`, a `barrelSpacing` in world A weapon may declare `barrels` (default 1) and, when `barrels > 1`, a `barrelSpacing` in world
@ -173,6 +175,11 @@ build completes; the finished frame crossfades in underneath starting at 20% pro
| 19 | Advanced Radar — under construction | 2×2 | | | 19 | Advanced Radar — under construction | 2×2 | |
| 20 | Radar Jammer | 2×2 | squat emitter, no dish, broken rings — **placeholder** | | 20 | Radar Jammer | 2×2 | squat emitter, no dish, broken rings — **placeholder** |
| 21 | Radar Jammer — under construction | 2×2 | | | 21 | Radar Jammer — under construction | 2×2 | |
| 22 | Nuclear Power Plant | 2×2 | cooling towers + reactor dome |
| 23 | Nuclear Power Plant — under construction | 2×2 | |
| 24 | Advanced Metal Generator | 2×2 | circular receptacle in the centre — the topper (frame 26) sits into it |
| 25 | Advanced Metal Generator — under construction | 2×2 | |
| 26 | Advanced Metal Generator topper — spinning centrifuge | — | **not a building**, see below |
The three sensor structures share a visual language deliberately: a low pad with corner The three sensor structures share a visual language deliberately: a low pad with corner
footings and **no armoured plinth**, so a glance separates "sensor" from "gun". They never footings and **no armoured plinth**, so a glance separates "sensor" from "gun". They never
@ -188,7 +195,36 @@ Defensive structures never rotate, and their mount traverses freely in the simul
shoot in every direction regardless of how the art is drawn. Draw them facing **up** and do not shoot in every direction regardless of how the art is drawn. Draw them facing **up** and do not
imply a firing arc. imply a firing arc.
Frames 2223 are free in the current 6×4 sheet; growing it another row adds six more. **Frame 26 is a `topperFrame`, not a second building.** A building def may name one
(`"topperFrame": 26` on `advancedmassgen`) to get a second sprite stacked on top of the finished
structure, at the same display size as the building itself, that spins **continuously and
fast** — 360° every 1.8s, real time, independent of `idleAnimation`'s slow whole-building
breathing/turn (`TAWorldView`'s `render()`, the block right after the `idleAnimation` one).
Unlike a unit's `turretFrame`, a topper never aims at anything; it just spins, and it fades in
only once the building completes (hidden throughout the wireframe/build phase — a spinning
centrifuge on unfinished girders would read as broken machinery, not construction). Draw a
topper's frame centred exactly like a turret's, with matching transparent padding to whatever
extent it should read as smaller than the building itself — the renderer scales the whole cell
uniformly, so the padding you draw is the only thing controlling how big it looks once placed.
`sh.at(frame, ...)` isn't required for the sheet to hold it — see `mkCanvasSheet.finish()` — but
`TAArt.js`'s `structure` painter does draw a generic spinning-glyph fallback for any topper.
**`upgradesFrom` lets a building be placed directly on top of a friendly building it names,
consuming it for a 50% refund of ITS OWN build cost** (not a discount on the new building's
price — a genuine resource credit) instead of requiring clear ground. Both `nuclearplant`
(`upgradesFrom: "energygen"`) and `advancedmassgen` (`upgradesFrom: "massgen"`) use it. The
match has to be exact — same tile position AND same footprint size as the building being
replaced, checked by `TALogic.findUpgradeTarget` — so this is "swap this generator for its
advanced version," not "clear whatever's nearby." The old building disappears quietly: no death
explosion, no kill/loss stat, no 'unitDestroyed' blast FX, because reclaiming your own structure
to upgrade it isn't the same event as losing it. `TALogic.canPlaceAt` takes an optional 5th
`army` argument to recognise this — omit it (as the AI's placement search and most of the verify
fixtures do) and you get the plain "is this ground clear" answer with no upgrade awareness at
all, which is the deliberately conservative default for any caller that doesn't actually know
whose building is sitting there.
**No frames remain free in the current 6×6 sheet** (grown from 4 rows to 6 for this session,
1152×1152) — growing it another row is the next move for anything new.
--- ---
@ -260,10 +296,12 @@ preview size.
| 2029 | Commands: move, attack, attack-move, stop, hold, patrol, guard, assist, repair, rally | | 2029 | Commands: move, attack, attack-move, stop, hold, patrol, guard, assist, repair, rally |
| 3031 | Advanced radar, radar jammer | | 3031 | Advanced radar, radar jammer |
| 32 | Megatank (unit) | | 32 | Megatank (unit) |
| 33 | Advanced Construction Vehicle (unit) |
| 3435 | Advanced Metal Generator, Nuclear Power Plant |
Row 0 filled up before the Airfield units were added, which is why the hover constructor sits Row 0 filled up before the Airfield units were added, which is why the hover constructor sits
next to the buildings at 17 rather than with the other units. Nothing reads a row as a next to the buildings at 17 rather than with the other units. Nothing reads a row as a
category — only the `icon` numbers matter. Frames 3339 are free. category — only the `icon` numbers matter. Frames 3639 are free.
### Painting priority: buildings the Commander and Construction Vehicle put up ### Painting priority: buildings the Commander and Construction Vehicle put up

View File

@ -139,6 +139,8 @@ section('2. Artwork manifest');
const cap = (sheet.cols ?? 8) * (sheet.rows ?? 8); const cap = (sheet.cols ?? 8) * (sheet.rows ?? 8);
check(`${d.id} frame ${d.frame} fits ${sheet.key}`, d.frame < cap); check(`${d.id} frame ${d.frame} fits ${sheet.key}`, d.frame < cap);
if (d.turretFrame != null) check(`${d.id} turret frame fits`, d.turretFrame < cap); if (d.turretFrame != null) check(`${d.id} turret frame fits`, d.turretFrame < cap);
if (d.buildFrame != null) check(`${d.id} build frame fits`, d.buildFrame < cap);
if (d.topperFrame != null) check(`${d.id} topper frame fits`, d.topperFrame < cap);
} }
} }
} }
@ -216,6 +218,9 @@ section('2b. Procedural art actually paints');
if (d.buildFrame != null) { if (d.buildFrame != null) {
check(`${d.id} build frame was painted`, tex?.frames.includes(d.buildFrame)); check(`${d.id} build frame was painted`, tex?.frames.includes(d.buildFrame));
} }
if (d.topperFrame != null) {
check(`${d.id} topper frame was painted`, tex?.frames.includes(d.topperFrame));
}
} }
} }
for (const [name, sheet] of Object.entries(artJson.sheets ?? {})) { for (const [name, sheet] of Object.entries(artJson.sheets ?? {})) {
@ -694,6 +699,81 @@ section('3. Economy fixtures');
check('some terrain carries that multiplier', !!multTerrain); check('some terrain carries that multiplier', !!multTerrain);
} }
// ---------------------------------------------------------------------------
section('3b. Building upgrades in place');
// ---------------------------------------------------------------------------
{
// The Advanced Metal Generator / Nuclear Power Plant can be placed directly on top of a
// friendly Mass/Energy Generator instead of needing clear ground, reclaiming half the older
// building's cost. This exercises the whole path end to end: canPlaceAt(..., army) recognising
// the spot, buildCommand consuming the old building and crediting the refund, and a real site
// for the new building landing exactly where the old one stood.
const map = generateMap(rules, { seed: 40, size: 'small', symmetry: 'mirror-x' });
const cmdStart = map.starts.find((s) => s.army === 0) ?? map.starts[0];
const tx = cmdStart.x + 3, ty = cmdStart.y;
const st = L.createMatch(rules, {
seed: 40, map: { ...map, buildings: [{ army: 0, type: 'massgen', tx, ty }] },
armies: [{ armyId: 'arm' }, { armyId: 'core' }],
});
const a = st.armies[0];
// Headroom below the cap, or the refund's Math.min(cap, ...) clamp would silently eat it and
// this fixture would "pass" while testing nothing.
a.massCap = 5000; a.energyCap = 5000; a.mass = 2500; a.energy = 2500;
const oldGen = st.entities.find((e) => e.defId === 'massgen' && !e.dead);
check('fixture mass generator placed', !!oldGen);
const advDef = rules.buildingById.advancedmassgen;
check('advanced metal generator declares upgradesFrom massgen', advDef.upgradesFrom === 'massgen');
const builder = L.spawnUnit(st, rules, 0, 'advancedconstructor', oldGen.x + 300, oldGen.y);
const massBefore = a.mass;
const expectedRefund = rules.buildingById.massgen.cost.mass * 0.5;
const r = L.issueOrder(st, rules, {
army: 0, unitIds: [builder.id],
order: { type: 'build', defId: 'advancedmassgen', tx, ty },
});
check('advanced metal generator order accepted directly on the mass generator', r.ok, r.error);
check('the old mass generator is gone', !!oldGen.dead);
check('half the mass generator\'s build cost was refunded',
Math.abs((a.mass - massBefore) - expectedRefund) < 1e-6,
`+${(a.mass - massBefore).toFixed(1)} vs expected +${expectedRefund}`);
const site = st.entities.find((e) => e.id === r.siteId);
check('a new advanced metal generator site stands in its exact footprint',
!!site && site.defId === 'advancedmassgen' && site.tx === tx && site.ty === ty && site.site === true);
// Fresh ground still works — upgrading in place is an alternative, not a replacement.
let fx = tx + 6, fy = ty;
while (!L.canPlaceAt(st, rules, fx, fy, advDef, 0).ok && fx < st.w - 4) fx++;
const r2 = L.issueOrder(st, rules, {
army: 0, unitIds: [builder.id],
order: { type: 'build', defId: 'advancedmassgen', tx: fx, ty: fy },
});
check('advanced metal generator also builds on fresh ground', r2.ok, r2.error);
// Ownership: an enemy's finished mass generator must not be upgradeable.
const enemyGen = L.placeBuilding(st, rules, 1, 'massgen', tx + 20, ty);
enemyGen.site = false; enemyGen.progress = 1; enemyGen.hp = rules.buildingById.massgen.hp;
check('cannot upgrade an enemy\'s mass generator', !L.canPlaceAt(st, rules, tx + 20, ty, advDef, 0).ok);
// Type mismatch: an Advanced Metal Generator's upgradesFrom is massgen, not energygen.
const wrongType = L.placeBuilding(st, rules, 0, 'energygen', tx + 30, ty);
wrongType.site = false; wrongType.progress = 1; wrongType.hp = rules.buildingById.energygen.hp;
check('cannot upgrade a different building type', !L.canPlaceAt(st, rules, tx + 30, ty, advDef, 0).ok);
// The reverse pairing: a Nuclear Power Plant upgrades that same Energy Generator.
const nukeDef = rules.buildingById.nuclearplant;
check('nuclear power plant declares upgradesFrom energygen', nukeDef.upgradesFrom === 'energygen');
check('nuclear power plant can place directly on that energy generator',
L.canPlaceAt(st, rules, tx + 30, ty, nukeDef, 0).ok);
// Without an army argument, canPlaceAt must fall back to the plain occupancy check — callers
// that don't know ownership (the AI's placement search, most of this very test file) should
// never silently start recognising upgrade spots.
check('canPlaceAt without an army never grants an upgrade placement',
!L.canPlaceAt(st, rules, tx + 30, ty, nukeDef).ok);
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
section('4. Pathfinding'); section('4. Pathfinding');
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------