From 3c7184564aa308830bd38926b6abb7f080d3ac46 Mon Sep 17 00:00:00 2001 From: Brian Fertig Date: Sun, 16 Aug 2026 15:35:31 -0600 Subject: [PATCH] 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 --- data/totalannihilation-artwork.json | 4 +- data/totalannihilation-rules.json | 104 +++++++++++++++++- src/games/totalannihilation/TAArt.js | 20 +++- src/games/totalannihilation/TALogic.js | 57 +++++++++- src/games/totalannihilation/TARules.js | 12 ++ src/games/totalannihilation/TAWorldView.js | 30 ++++- .../TotalAnnihilationGame.js | 2 +- src/games/totalannihilation/sprites.md | 56 ++++++++-- tools/verifyTotalAnnihilation.js | 80 ++++++++++++++ 9 files changed, 344 insertions(+), 21 deletions(-) diff --git a/data/totalannihilation-artwork.json b/data/totalannihilation-artwork.json index cb1f800..02b35b3 100644 --- a/data/totalannihilation-artwork.json +++ b/data/totalannihilation-artwork.json @@ -38,7 +38,7 @@ "arm-structures": { "key": "ta-arm-structures", "path": "assets/images/ta/arm-structures.png", "kind": "structure", "frameWidth": 192, "frameHeight": 192, "cols": 6, - "rows": 4 + "rows": 6 }, "core-units": { "key": "ta-core-units", "path": "assets/images/ta/core-units.png", "kind": "unit", @@ -48,7 +48,7 @@ "core-structures": { "key": "ta-core-structures", "path": "assets/images/ta/core-structures.png", "kind": "structure", "frameWidth": 192, "frameHeight": 192, "cols": 6, - "rows": 4 + "rows": 6 }, "terrain-grasslands": { "key": "ta-terrain-grasslands", "path": "assets/images/ta/terrain-grasslands.png", "kind": "terrain", diff --git a/data/totalannihilation-rules.json b/data/totalannihilation-rules.json index 3f66774..743d99d 100644 --- a/data/totalannihilation-rules.json +++ b/data/totalannihilation-rules.json @@ -1024,6 +1024,39 @@ "spritePx": 50, "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", "name": "Fighter", @@ -1182,6 +1215,38 @@ "icon": 10, "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", "name": "Mass Generator", @@ -1214,6 +1279,39 @@ "icon": 11, "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", "name": "Barracks", @@ -1356,10 +1454,8 @@ "buildTime": 55, "buildPower": 140, "builds": [ - "jeep", - "tank", - "rockettank", - "megatank" + "megatank", + "advancedconstructor" ], "spawnOffset": { "x": 0, diff --git a/src/games/totalannihilation/TAArt.js b/src/games/totalannihilation/TAArt.js index 6ddf759..1f22b8a 100644 --- a/src/games/totalannihilation/TAArt.js +++ b/src/games/totalannihilation/TAArt.js @@ -671,7 +671,9 @@ const PROC_PAINTERS = { structure(scene, key, spec, rules) { const defs = rules.buildings.filter((b) => b.sheetSlot === 'structureSheet'); 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); for (const d of defs) { const draw = STRUCT_SHAPES[d.procShape]; @@ -684,6 +686,22 @@ const PROC_PAINTERS = { 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(); }, diff --git a/src/games/totalannihilation/TALogic.js b/src/games/totalannihilation/TALogic.js index fcdfe64..cdd32d9 100644 --- a/src/games/totalannihilation/TALogic.js +++ b/src/games/totalannihilation/TALogic.js @@ -370,15 +370,58 @@ function buildCommand(state, rules, army, units, order, queue) { if (!(bdef.builds ?? []).includes(order.defId)) { 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; + // 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); if (!site) return { ok: false, error: 'placement failed' }; pushOrder(builder, { type: 'build', targetId: site.id }, queue); 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 * 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 }; } -/** 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; if (tx < 0 || ty < 0 || tx + fw > state.w || ty + fh > state.h) { 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 x = tx; x < tx + fw; x++) { const i = y * state.w + x; diff --git a/src/games/totalannihilation/TARules.js b/src/games/totalannihilation/TARules.js index 00002ba..6fbdaaa 100644 --- a/src/games/totalannihilation/TARules.js +++ b/src/games/totalannihilation/TARules.js @@ -303,6 +303,9 @@ export function compileRules(json) { 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 (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)) { 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 ----------------------------------------------------- // The engine understands two rules: "commander" (an army dies with its Commander) and diff --git a/src/games/totalannihilation/TAWorldView.js b/src/games/totalannihilation/TAWorldView.js index b917233..4597497 100644 --- a/src/games/totalannihilation/TAWorldView.js +++ b/src/games/totalannihilation/TAWorldView.js @@ -384,6 +384,17 @@ export default class TAWorldView { 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. let shadow = null; 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 // target every frame in render(). Newly built units start on the ground. s = { - img, turret, final, shadow, defId: e.defId, + img, turret, final, shadow, topper, defId: e.defId, alt: 0, baseScale: img.scaleX, batchDone: 0, batchPrev: 0, // production-dial accounting, see _drawProductionPies }; @@ -409,6 +420,7 @@ export default class TAWorldView { s.turret?.destroy(); s.final?.destroy(); s.shadow?.destroy(); + s.topper?.destroy(); this.sprites.delete(id); } @@ -449,6 +461,7 @@ export default class TAWorldView { if (s.turret) s.turret.setVisible(shown); if (s.final) s.final.setVisible(shown); if (s.shadow) s.shadow.setVisible(shown); + if (s.topper) s.topper.setVisible(shown); if (!shown) continue; 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)); } + // 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) { const tr = lerpAngle(e.pturretRot, e.turretRot, alpha); s.turret.setPosition(x, y - lift).setRotation(tr); diff --git a/src/games/totalannihilation/TotalAnnihilationGame.js b/src/games/totalannihilation/TotalAnnihilationGame.js index 0204ca7..55aba58 100644 --- a/src/games/totalannihilation/TotalAnnihilationGame.js +++ b/src/games/totalannihilation/TotalAnnihilationGame.js @@ -884,7 +884,7 @@ export default class TotalAnnihilationGame extends Phaser.Scene { this.placement.tx = tx; this.placement.ty = ty; this.view.placement = { 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, buildRange: this.rules.defById[builder?.defId ?? '']?.buildRange ?? 0, }; diff --git a/src/games/totalannihilation/sprites.md b/src/games/totalannihilation/sprites.md index addd49e..bf33338 100644 --- a/src/games/totalannihilation/sprites.md +++ b/src/games/totalannihilation/sprites.md @@ -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 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 | | 15 | Megatank | hull + tracks, wider than the Tank | 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 @@ -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 extending to the **right**. - 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 - Airfield units) simply has no turret frame; its whole sprite rotates to face its target. The - Construction Vehicle and Hover Constructor are unarmed, so they never turn to aim regardless — - they just rotate to face wherever they're moving. Aircraft have fixed forward guns, so they - aim by pointing the whole airframe, which is why they have no turret either. +- A unit with no turret (infantry, sniper, rocket trooper, construction vehicle, advanced + construction vehicle, and all three Airfield units) simply has no turret frame; its whole + sprite rotates to face its target. The two Construction Vehicles and the Hover Constructor are + unarmed, so they never turn to aim regardless — they just rotate to face wherever they're + moving. Aircraft have fixed forward guns, so they aim by pointing the whole airframe, which is + why they have no turret either. -Frames 17–63 are free. Add a unit by appending a definition to `units[]` in +Frames 18–63 are free. Add a unit by appending a definition to `units[]` in `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 @@ -173,6 +175,11 @@ build completes; the finished frame crossfades in underneath starting at 20% pro | 19 | Advanced Radar — under construction | 2×2 | | | 20 | Radar Jammer | 2×2 | squat emitter, no dish, broken rings — **placeholder** | | 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 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 imply a firing arc. -Frames 22–23 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. | 20–29 | Commands: move, attack, attack-move, stop, hold, patrol, guard, assist, repair, rally | | 30–31 | Advanced radar, radar jammer | | 32 | Megatank (unit) | +| 33 | Advanced Construction Vehicle (unit) | +| 34–35 | Advanced Metal Generator, Nuclear Power Plant | 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 -category — only the `icon` numbers matter. Frames 33–39 are free. +category — only the `icon` numbers matter. Frames 36–39 are free. ### Painting priority: buildings the Commander and Construction Vehicle put up diff --git a/tools/verifyTotalAnnihilation.js b/tools/verifyTotalAnnihilation.js index cea4614..4b0c3e2 100644 --- a/tools/verifyTotalAnnihilation.js +++ b/tools/verifyTotalAnnihilation.js @@ -139,6 +139,8 @@ section('2. Artwork manifest'); const cap = (sheet.cols ?? 8) * (sheet.rows ?? 8); 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.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) { 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 ?? {})) { @@ -694,6 +699,81 @@ section('3. Economy fixtures'); 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'); // ---------------------------------------------------------------------------