diff --git a/data/totalannihilation-rules.json b/data/totalannihilation-rules.json index d1f8688..60f264f 100644 --- a/data/totalannihilation-rules.json +++ b/data/totalannihilation-rules.json @@ -1265,7 +1265,9 @@ "hold": 24, "patrol": 25, "guard": 26, - "assist": 27 + "assist": 27, + "repair": 28, + "rally": 29 }, "aiSkills": [ { diff --git a/src/games/totalannihilation/TAArt.js b/src/games/totalannihilation/TAArt.js index e92dd23..1170885 100644 --- a/src/games/totalannihilation/TAArt.js +++ b/src/games/totalannihilation/TAArt.js @@ -783,6 +783,29 @@ const PROC_PAINTERS = { ctx.moveTo(S * 0.24, S / 2); ctx.lineTo(S * 0.76, S / 2); ctx.stroke(); }); + // A wrench, deliberately unlike assist's plus sign: assist pours build power into something + // unfinished, repair puts hit points back into something already standing. + glyph(cmd.repair, (ctx, S) => { + ctx.strokeStyle = '#8affd0'; ctx.lineWidth = 5; ctx.lineCap = 'round'; + ctx.beginPath(); + ctx.moveTo(S * 0.32, S * 0.68); ctx.lineTo(S * 0.66, S * 0.34); + ctx.stroke(); + ctx.lineCap = 'butt'; + ctx.beginPath(); ctx.arc(S * 0.70, S * 0.30, S * 0.13, 0.6, 5.2); + ctx.lineWidth = 4; ctx.stroke(); + ctx.fillStyle = '#8affd0'; + ctx.beginPath(); ctx.arc(S * 0.30, S * 0.70, S * 0.06, 0, Math.PI * 2); ctx.fill(); + }); + // A pennant on a post — where finished units are told to go. + glyph(cmd.rally, (ctx, S) => { + ctx.strokeStyle = '#cfe3ff'; ctx.lineWidth = 3; + ctx.beginPath(); ctx.moveTo(S * 0.34, S * 0.18); ctx.lineTo(S * 0.34, S * 0.82); ctx.stroke(); + ctx.fillStyle = '#ffd27a'; + ctx.beginPath(); + ctx.moveTo(S * 0.34, S * 0.20); ctx.lineTo(S * 0.76, S * 0.34); ctx.lineTo(S * 0.34, S * 0.48); + ctx.closePath(); ctx.fill(); + ctx.strokeStyle = '#26262e'; ctx.lineWidth = 2; ctx.stroke(); + }); return sh.finish(); }, diff --git a/src/games/totalannihilation/TAHud.js b/src/games/totalannihilation/TAHud.js index 936ba8c..841fcbf 100644 --- a/src/games/totalannihilation/TAHud.js +++ b/src/games/totalannihilation/TAHud.js @@ -23,17 +23,39 @@ const DIM = '#8fa0b8'; const BAR_H = 62; // top resource strip const BOT_H = 190; // bottom command bar const MINI = 176; // minimap edge length -const BTN_W = 88, BTN_H = 74; // build-menu button size +const BTN_W = 88, BTN_H = 74; // button size, shared by both grids const GRID_COLS = 8; // build-menu columns const GRID_PITCH = 96; // column pitch (button + gutter) -// The bottom bar has room for exactly ONE row of buttons: a second row runs down into the -// hint line at the foot of the panel. GRID_COLS must therefore stay >= the longest `builds` -// list in the rules — currently 8, the Constructor and Hover Constructor. Adding a ninth -// build option to any unit means widening the grid or moving the hint, not just adding it. +const ROW_PITCH = 82; // row pitch; two rows fit the bar, three do not +const CMD_COLS = 5; // command grid is 5 wide x 2 deep -// CTRL is the queue modifier (as in the original game); SHIFT adds to the selection and -// buys x5 from a factory. Kept in one place so the hint and the bindings cannot drift. -const HINT = 'LMB select · drag box · RMB order · RMB a damaged ally to repair · CTRL+RMB queue · A attack-move · X stop · H hold'; +/** + * The order buttons, in grid order: row one is what every mobile unit can do, row two is the + * stances plus the builder/factory verbs. + * + * `needs` decides whether the button is live for the current selection, and `instant` splits + * the two interaction models: Stop and Hold apply to the selection immediately, everything + * else arms a pending command that the next world click resolves — the same flow the A/P/G + * hotkeys already used, which is why those hotkeys keep working unchanged. + * + * Reclaim is absent rather than greyed out. There are no wrecks to reclaim in this engine + * yet, and a permanently dead button just teaches players to stop looking at that corner. + */ +const COMMANDS = [ + { id: 'move', label: 'Move', key: 'RMB', needs: 'mobile', tip: 'Walk to a point without engaging on the way.' }, + { id: 'attack', label: 'Attack', key: '', needs: 'armed', tip: 'Pick one target and commit to it.' }, + { id: 'attackMove', label: 'Atk-Mv', key: 'A', needs: 'mobile', tip: 'Advance to a point, engaging anything met on the way.' }, + { id: 'patrol', label: 'Patrol', key: 'P', needs: 'mobile', tip: 'Shuttle between here and a point, engaging on both legs.' }, + { id: 'guard', label: 'Guard', key: 'G', needs: 'mobile', tip: 'Escort a friendly unit and fight what attacks it.' }, + { id: 'stop', label: 'Stop', key: 'X', needs: 'any', instant: true, tip: 'Clear the whole order queue.' }, + { id: 'hold', label: 'Hold', key: 'H', needs: 'mobile', instant: true, tip: 'Stay put and shoot what comes into range.' }, + { id: 'repair', label: 'Repair', key: '', needs: 'builder', tip: 'Put hit points back into a damaged friendly.' }, + { id: 'assist', label: 'Assist', key: '', needs: 'builder', tip: 'Add build power to a site or a factory already working.' }, + { id: 'setRally', label: 'Rally', key: '', needs: 'factory', tip: 'Send newly built units to a point.' }, +]; + +// Modifiers, unchanged by the buttons: CTRL queues an order (as in the original game), SHIFT +// adds to the selection and buys x5 from a factory. /** Build-button captions have ~7 characters of room; "Vehicle Plant" needs shortening. */ function shortName(name) { @@ -135,22 +157,106 @@ export default class TAHud { bg.setStrokeStyle(2, EDGE); this.root.add(bg); + // Geometry first — the selection text is sized against where the command grid starts. + this.gridX = GAME_WIDTH - 24 - GRID_COLS * GRID_PITCH; + this.gridY = y + 16; + // Command grid sits immediately left of the build grid, sharing its button size and pitch + // so the two read as one control surface. Right edge lands 32px short of the build menu. + this.cmdX = this.gridX - 32 - (CMD_COLS - 1) * GRID_PITCH - BTN_W; + this.cmdY = this.gridY; + + // The selection panel runs from the minimap to the command grid and no further. It used to + // have the whole width to itself; now that the order buttons sit at 624 it has to be told, + // or a mixed selection's title runs straight under them. + const selW = this.cmdX - (MINI + 60) - 16; this.selTitle = s.add.text(MINI + 60, y + 12, '', { fontFamily: FONT, fontSize: '26px', color: TEXT, + wordWrap: { width: selW }, maxLines: 1, }); this.selDetail = s.add.text(MINI + 60, y + 44, '', { fontFamily: FONT, fontSize: '19px', color: DIM, lineSpacing: 3, + wordWrap: { width: selW }, maxLines: 3, }); this.queueText = s.add.text(MINI + 60, y + 120, '', { fontFamily: FONT, fontSize: '19px', color: '#9ce6a0', + wordWrap: { width: selW }, maxLines: 2, }); - this.hint = s.add.text(GAME_WIDTH - 24, y + BOT_H - 26, '', { - fontFamily: FONT, fontSize: '17px', color: DIM, - }).setOrigin(1, 0.5); - this.root.add([this.selTitle, this.selDetail, this.queueText, this.hint]); + this.root.add([this.selTitle, this.selDetail, this.queueText]); + this._buildCommandGrid(); + } - this.gridX = GAME_WIDTH - 24 - GRID_COLS * GRID_PITCH; - this.gridY = y + 16; + /** + * The order buttons. Built ONCE and then only restyled — unlike the build grid, whose + * contents change with the selection, this set is fixed and rebuilding ten containers every + * frame would churn interactive objects for nothing. + */ + _buildCommandGrid() { + const s = this.scene; + this.cmdButtons = []; + COMMANDS.forEach((cmd, i) => { + const col = i % CMD_COLS, row = Math.floor(i / CMD_COLS); + // Container position is its CENTRE — see the note in _drawGrid. Every child below is + // centred on (0,0) so the visible button and its hit area coincide. + const x = this.cmdX + col * GRID_PITCH + BTN_W / 2; + const y = this.cmdY + row * ROW_PITCH + BTN_H / 2; + const c = s.add.container(x, y); + const box = s.add.rectangle(0, 0, BTN_W, BTN_H, 0x22304a, 1).setStrokeStyle(2, EDGE); + const label = s.add.text(0, -BTN_H / 2 + 14, cmd.label, { + fontFamily: FONT, fontSize: '20px', color: TEXT, + }).setOrigin(0.5, 0); + const hotkey = s.add.text(0, BTN_H / 2 - 26, cmd.key, { + fontFamily: FONT, fontSize: '15px', color: DIM, + }).setOrigin(0.5, 0); + c.add([box, label, hotkey]); + c.setSize(BTN_W, BTN_H); + c.setInteractive({ useHandCursor: true }); + const btn = { cmd, c, box, label, hotkey, enabled: false, armed: false }; + c.on('pointerover', () => { if (btn.enabled && !btn.armed) box.setFillStyle(0x2e3f5f, 1); }); + c.on('pointerout', () => this._styleCommand(btn)); + this.tooltip.attachTo(c, () => ({ + title: cmd.label, + lines: [{ text: cmd.tip }, ...(cmd.key ? [{ text: `Hotkey: ${cmd.key}` }] : [])], + })); + c.on('pointerdown', (p, lx, ly, ev) => { + ev?.stopPropagation(); + if (btn.enabled) this.h.onCommand?.(cmd.id); + }); + this.root.add(c); + this.cmdButtons.push(btn); + }); + } + + _styleCommand(btn) { + if (!btn.enabled) { + btn.box.setFillStyle(0x1a2130, 1).setStrokeStyle(2, 0x2a3244); + btn.label.setColor('#5d6b80'); btn.hotkey.setColor('#4e5a6d'); + return; + } + if (btn.armed) { + btn.box.setFillStyle(0x3d6a4a, 1).setStrokeStyle(2, 0x7dff9b); + btn.label.setColor('#dcffe6'); btn.hotkey.setColor('#9ce6a0'); + return; + } + btn.box.setFillStyle(0x22304a, 1).setStrokeStyle(2, EDGE); + btn.label.setColor(TEXT); btn.hotkey.setColor(DIM); + } + + /** Light up the orders this selection can actually give, and mark the armed one. */ + _refreshCommands(sel, pending) { + const R = this.rules; + const mobile = sel.filter((e) => !e.isBuilding && !e.site); + const can = { + any: sel.length > 0, + mobile: mobile.length > 0, + armed: mobile.some((e) => (R.defById[e.defId].maxRange ?? 0) > 0), + builder: mobile.some((e) => R.defById[e.defId].builds?.length), + factory: sel.some((e) => e.isBuilding && !e.site && R.defById[e.defId].builds?.length), + }; + for (const btn of this.cmdButtons) { + btn.enabled = !!can[btn.cmd.needs]; + btn.armed = btn.enabled && pending === btn.cmd.id; + this._styleCommand(btn); + } } _clearGrid() { @@ -289,6 +395,9 @@ export default class TAHud { const sx = MINI / st.worldW, sy = MINI / st.worldH; for (const e of st.entities) { if (e.dead) continue; + // The Commander-death cascade leaves its victims in state while removing them from the + // world, so the minimap has to honour it too or a wiped army lingers as a field of dots. + if (this.view.vaporised.has(e.id)) continue; if (e.army !== this.playerArmy && !this.view.visibleToPlayer(e)) continue; const a = this.rules.armies[e.army]; g.fillStyle(a ? a.colorInt : 0x888888, 1); @@ -307,7 +416,7 @@ export default class TAHud { // Per-frame update // ------------------------------------------------------------------------- - update(timeMs, selection, placementDef) { + update(timeMs, selection, placementDef, pendingCommand = null) { const st = this.state, army = st.armies[this.playerArmy]; if (!army) return; @@ -328,6 +437,9 @@ export default class TAHud { this._bakeMinimap(timeMs); this._drawMinimapOverlay(); this._refreshSelection(selection, placementDef); + // Outside the signature-cached selection refresh: the armed command changes on its own + // schedule (a hotkey, a click, an Esc) with the selection untouched. + this._refreshCommands([...selection], pendingCommand); } _refreshSelection(selection, placementDef) { @@ -345,7 +457,6 @@ export default class TAHud { this.selTitle.setText(''); this.selDetail.setText(''); this.queueText.setText(''); - this.hint.setText(HINT); this._clearGrid(); return; } @@ -367,9 +478,10 @@ export default class TAHud { const job = lead.orders?.[0]; if (job?.type === 'repair') lines.push('Repairing'); else if (job?.type === 'build' || job?.type === 'assist') lines.push('Building'); - // A builder is the only thing that can repair, so say so where the player is looking. - if (!lead.site && (this.rules.defById[lead.defId].builds ?? []).length && !lead.isBuilding) { - lines.push('Right-click a damaged ally to repair it'); + // A guarding builder silently switches between mending and assisting, so say which. + else if (job?.type === 'guard' && lead.buildTargetId) { + const t = this.state.entities.find((x) => x.id === lead.buildTargetId && !x.dead); + lines.push(t && t.hp < t.maxHp ? 'Guarding — repairing' : 'Guarding — assisting'); } if (!lead.site && lead.hasRally) lines.push('Rally point set'); for (const w of def.weaponDefs ?? []) lines.push(`${w.name}${w.manual ? ' — manual (attack order)' : ''}`); @@ -381,13 +493,6 @@ export default class TAHud { } this.selDetail.setText(lines.join('\n')); - if (placementDef) { - this.hint.setText(`Placing ${placementDef.name} — LMB to site it, hold CTRL to queue several ` - + '(release CTRL when done), Esc to cancel'); - } else { - this.hint.setText(HINT); - } - // Grid shows the builder's structures, or the factory's units — never both. const builder = sel.find((e) => !e.isBuilding && !e.site && this.rules.defById[e.defId].builds?.length); const factory = sel.find((e) => e.isBuilding && !e.site && this.rules.defById[e.defId].builds?.length); diff --git a/src/games/totalannihilation/TALogic.js b/src/games/totalannihilation/TALogic.js index 42eb3e0..6b94404 100644 --- a/src/games/totalannihilation/TALogic.js +++ b/src/games/totalannihilation/TALogic.js @@ -327,9 +327,9 @@ export function issueOrder(state, rules, cmd) { // One shared destination, fanned into formation slots — a 40-unit move order costs one A*. const slots = formationSlots(order.x, order.y, units); units.forEach((u, i) => { - const o = { ...order, sx: slots[i * 2], sy: slots[i * 2 + 1] }; - if (order.type === 'patrol') { o.fromX = u.x; o.fromY = u.y; o.leg = 0; } - pushOrder(u, o, cmd.queue); + const sx = slots[i * 2], sy = slots[i * 2 + 1]; + if (order.type === 'patrol') { addPatrolWaypoint(u, sx, sy, cmd.queue); return; } + pushOrder(u, { ...order, sx, sy }, cmd.queue); }); return { ok: true }; } @@ -525,11 +525,24 @@ function stepEconomy(state, rules) { if (target && target.army === e.army) { const d = Math.hypot(target.x - e.x, target.y - e.y); if (d > def.buildRange + target.radius) continue; - // Repair power is kept in its OWN accumulator. A damaged factory has build power of - // its own for the unit it is producing; folding an incoming repair into the same - // number would silently speed up that unit's production. + // Three places the power can land, in priority order. + // + // Repair is kept in its OWN accumulator: a damaged factory has build power of its own + // for the unit it is producing, and folding an incoming repair into the same number + // would silently speed up that unit's production. + // + // DAMAGE COMES FIRST. A nanolathe pointed at a factory that is both hurt and building + // patches it before it helps the production line — a half-dead factory that keeps + // pumping out units while ignoring the hole in its roof is the wrong answer, and it is + // what "guard this and keep it alive" means. + // + // The last branch is the one that was missing: build power aimed at a healthy factory + // with something in its queue now speeds that job up. `assist` has always ACCEPTED a + // producing factory as a target and stepOrders has always held the nanolathe on it, + // but the power evaporated here, so assisting a factory did visibly nothing. if (target.site) target._power += def.buildPower; else if (target.hp < target.maxHp) target._repairPower += def.buildPower; + else if (target.isBuilding && target.queue?.length) target._power += def.buildPower; } } } @@ -737,13 +750,66 @@ function servicePathQueue(state, rules) { } function orderDestination(e, order) { - if (order.type === 'patrol') { - return order.leg === 0 ? { x: order.sx ?? order.x, y: order.sy ?? order.y } - : { x: order.fromX, y: order.fromY }; - } + if (order.type === 'patrol') return patrolWaypoint(order); return { x: order.sx ?? order.x, y: order.sy ?? order.y }; } +/** + * The waypoint a patrol is currently heading for. + * + * A patrol carries a `route`: a flat [x,y,x,y,…] circuit whose FIRST point is where the unit + * stood when the order was given, followed by every point the player clicked. `leg` indexes it + * and wraps, so the unit runs the circuit forever. One clicked point therefore still gives the + * classic there-and-back — the two-point route is just the shortest circuit, not a special case. + * + * Orders restored from a save written before routes existed carry the old fromX/fromY pair + * instead, and are read the old way rather than migrated. + */ +export function patrolWaypoint(order) { + const r = order.route; + if (!r || r.length < 4) { + return order.leg === 0 + ? { x: order.sx ?? order.x, y: order.sy ?? order.y } + : { x: order.fromX ?? order.sx ?? order.x, y: order.fromY ?? order.sy ?? order.y }; + } + const n = r.length >> 1; + const i = ((order.leg ?? 0) % n + n) % n; + return { x: r[i * 2], y: r[i * 2 + 1] }; +} + +/** Step a patrol on to its next waypoint, wrapping round the circuit. */ +function advancePatrol(order) { + const n = order.route && order.route.length >= 4 ? order.route.length >> 1 : 2; + order.leg = (((order.leg ?? 0) + 1) % n + n) % n; +} + +/** + * Add a point to `u`'s patrol. + * + * Queued (CTRL held) onto a unit whose last order is already a patrol, this EXTENDS that + * circuit rather than stacking a second patrol behind it — which is what the player means by + * ctrl-clicking a series of points, and is also the only thing that could work: a patrol never + * completes, so anything queued behind one would never run. + */ +function addPatrolWaypoint(u, x, y, queue) { + const last = queue ? u.orders[u.orders.length - 1] : null; + if (last && last.type === 'patrol' && last.route) { + last.route.push(x, y); + return; + } + // Point 0 is where the unit is standing, so the circuit always comes home. + pushOrder(u, { type: 'patrol', route: [u.x, u.y, x, y], leg: 1 }, queue); +} + +/** + * Is there anything for a guarding builder to do to this structure? Damage to mend, a site to + * finish, or a queue to help along. An intact idle building needs nothing, so its guard just + * stands watch — and picks the work up by itself the moment the building starts producing. + */ +function guardChore(target) { + return !!(target.site || target.hp < target.maxHp || target.queue?.length); +} + /** Auto-heal priority: lower tier heals first — defensive > factory > other building > unit. */ function healPriorityTier(def) { if (!def.isBuilding) return 3; @@ -910,7 +976,7 @@ function stepStraferOrders(state, rules, e, def) { e.movingTo = dest; if (d > arrive) { e._wantAirborne = true; break; } if (order.type === 'patrol') { - order.leg = order.leg === 0 ? 1 : 0; + advancePatrol(order); e._wantAirborne = true; e.movingTo = orderDestination(e, order); break; @@ -985,7 +1051,11 @@ function stepOrders(state, rules) { // Without this, giving a repairing Commander a move order left buildTargetId pointing at // the patient, and the economy kept pouring resources into the repair — for as long as // the builder happened to stay in range — even though the player had cancelled it. - if (order.type !== 'build' && order.type !== 'assist' && order.type !== 'repair') { + // + // `guard` is in the list because guarding a structure IS construction work for a builder; + // that case sets and clears the link itself as the target's needs change. + if (order.type !== 'build' && order.type !== 'assist' && order.type !== 'repair' + && order.type !== 'guard') { e.buildTargetId = 0; } @@ -1050,10 +1120,10 @@ function stepOrders(state, rules) { const slack = Math.max(rules.constants.arriveSlackPx, e.radius * 1.2); if (d <= slack || (e.noPath && e.stuckTicks > rules.constants.stuckGiveUpSec * rules.constants.tickHz)) { if (order.type === 'patrol') { - order.leg = order.leg === 0 ? 1 : 0; + advancePatrol(order); e.path = null; e.noPath = false; e.stuckTicks = 0; - seekTo(state, e, ...(order.leg === 0 - ? [order.sx ?? order.x, order.sy ?? order.y] : [order.fromX, order.fromY])); + const next = patrolWaypoint(order); + seekTo(state, e, next.x, next.y); } else { e.orders.shift(); e.path = null; e.movingTo = null; e.stuckTicks = 0; e.noPath = false; @@ -1090,16 +1160,28 @@ function stepOrders(state, rules) { case 'guard': { const target = entityById(state, order.targetId); - if (!target) { e.orders.shift(); break; } + if (!target) { e.orders.shift(); e.buildTargetId = 0; break; } + // A builder guarding a STRUCTURE works on it rather than standing next to it with its + // arms folded: it mends the damage, then lends its build power to whatever the + // structure is making. Escorting is the side effect; the labour is the point, and it + // is what a player means by parking the Commander on a factory. + // + // Which of the two happens is decided in stepEconomy, where repair outranks + // production — so "keep it alive" beats "make it faster" without needing a second + // order type here. + const chore = def.buildPower > 0 && target.isBuilding && guardChore(target); + const reach = chore ? ((def.buildRange ?? 0) + target.radius) * 0.9 : ts * 3; const d = Math.hypot(target.x - e.x, target.y - e.y); - if (d > ts * 3) { + if (d > reach) { if (!e.path && !e.wantPath) seekTo(state, e, target.x, target.y); e.movingTo = { x: target.x, y: target.y }; + e.buildTargetId = 0; if (e.path && Math.hypot(target.x - e.destX, target.y - e.destY) > ts * 2) { e.path = null; requestPath(state, e, target.x, target.y); } } else { e.path = null; e.movingTo = null; + e.buildTargetId = chore ? target.id : 0; } break; } diff --git a/src/games/totalannihilation/TAWorldView.js b/src/games/totalannihilation/TAWorldView.js index 0d51a18..4af64c4 100644 --- a/src/games/totalannihilation/TAWorldView.js +++ b/src/games/totalannihilation/TAWorldView.js @@ -14,6 +14,7 @@ import { ensureSheets, sheetFrameSize } from './TAArt.js'; import { colorInt } from './TAFx.js'; +import { patrolWaypoint } from './TALogic.js'; const CHUNK_PX = 1024; const ZOOMS = [0.5, 0.7, 1.0, 1.4]; @@ -102,6 +103,10 @@ export default class TAWorldView { this._addWorld(this.gGhost); this.sprites = new Map(); // entity id -> { img, turret } + // Ids the Commander-death cascade has already blown up. The entities are still in the + // simulation — the match ended the moment the Commander died and nothing steps after that + // — so this is the view's own record of what it has stopped drawing. + this.vaporised = new Set(); this.selection = new Set(); this.queueSelection = null; // { unitId, index } — a highlighted entry in a drawn order queue this.placement = null; // { def, tx, ty, legal } @@ -425,6 +430,9 @@ export default class TAWorldView { for (const e of state.entities) { if (e.dead) continue; + // Left out of `live`, so the reaper below destroys its sprites and everything drawn + // inside this loop for it — rings, bars, dials — goes with them. + if (this.vaporised.size && this.vaporised.has(e.id)) continue; live.add(e.id); const def = rules.defById[e.defId]; const shown = this.visibleToPlayer(e); @@ -658,13 +666,34 @@ export default class TAWorldView { const col = ORDER_COLORS[o.type] ?? 0x9fe8ff; const active = i === 0; g.lineStyle(active ? 2.5 : 1.5, col, active ? 0.9 : 0.5); - g.lineBetween(px, py, pt.x, pt.y); - if (o.type === 'build') { - g.lineStyle(2, col, 0.9); - g.strokeRect(pt.x - 14, pt.y - 14, 28, 28); - } else { + + // A patrol draws as the whole closed circuit rather than a single waypoint — the + // player built that route point by point and needs to see it as one shape. Its first + // point is where the unit was standing when the order was given, so the loop closing + // back on itself is literal rather than decorative. + const route = o.type === 'patrol' && o.route?.length >= 4 ? o.route : null; + if (route) { + const n = route.length >> 1; + g.lineBetween(px, py, pt.x, pt.y); + for (let k = 0; k < n; k++) { + const a = k * 2, b = ((k + 1) % n) * 2; + g.lineBetween(route[a], route[a + 1], route[b], route[b + 1]); + } g.fillStyle(col, active ? 0.9 : 0.6); - g.fillCircle(pt.x, pt.y, active ? 5 : 4); + for (let k = 0; k < n; k++) g.fillCircle(route[k * 2], route[k * 2 + 1], active ? 5 : 4); + // The waypoint being headed for right now gets a ring, so a long route still shows + // which way round the unit is going. + g.lineStyle(2, col, 0.9); + g.strokeCircle(pt.x, pt.y, 9); + } else { + g.lineBetween(px, py, pt.x, pt.y); + if (o.type === 'build') { + g.lineStyle(2, col, 0.9); + g.strokeRect(pt.x - 14, pt.y - 14, 28, 28); + } else { + g.fillStyle(col, active ? 0.9 : 0.6); + g.fillCircle(pt.x, pt.y, active ? 5 : 4); + } } // A clicked-and-held queue entry gets a bright ring so its selection is unambiguous // before the player commits to deleting it. @@ -679,7 +708,14 @@ export default class TAWorldView { /** World position an order points at, or null if it has no meaningful location. */ _orderPoint(o) { - if (o.type === 'move' || o.type === 'attackMove' || o.type === 'patrol') { + // A patrol's location is whichever waypoint it is currently running to, not the point the + // player happened to click first — the sim's own reading of the order, so the marker and + // the unit never disagree about where it is going. + if (o.type === 'patrol') { + const p = patrolWaypoint(o); + return Number.isFinite(p.x) && Number.isFinite(p.y) ? p : null; + } + if (o.type === 'move' || o.type === 'attackMove') { const x = o.sx ?? o.x, y = o.sy ?? o.y; return Number.isFinite(x) && Number.isFinite(y) ? { x, y } : null; } diff --git a/src/games/totalannihilation/TotalAnnihilationGame.js b/src/games/totalannihilation/TotalAnnihilationGame.js index 82ac67b..4299113 100644 --- a/src/games/totalannihilation/TotalAnnihilationGame.js +++ b/src/games/totalannihilation/TotalAnnihilationGame.js @@ -30,6 +30,18 @@ const EDGE_PAN = 26; // screen-edge scroll band, px const PAN_SPEED = 1100; // px/s at zoom 1 const DRAG_MIN = 8; // px before a click becomes a drag-box +/** What the toast says when a command is armed and waiting for a world click. */ +const COMMAND_PROMPTS = { + move: 'Move: pick a point', + attack: 'Attack: pick an enemy', + attackMove: 'Attack-move: pick a target point', + patrol: 'Patrol: pick a point — hold CTRL and keep clicking for a route', + guard: 'Guard: pick a friendly unit', + repair: 'Repair: pick a damaged friendly', + assist: 'Assist: pick a site or a working factory', + setRally: 'Rally: pick a point', +}; + export default class TotalAnnihilationGame extends Phaser.Scene { constructor() { super('TotalAnnihilationGame'); } @@ -44,7 +56,12 @@ export default class TotalAnnihilationGame extends Phaser.Scene { this.selection = new Set(); this.groups = new Map(); this.placement = null; - this.pendingCommand = null; // 'attackMove' | 'patrol' | 'guard' | 'attack' + // An order armed by a command button (or its hotkey) and waiting for a world click to say + // where or on what. See COMMAND_PROMPTS for the set. + this.pendingCommand = null; + this.chainedCommand = false; + this._deathSeq = null; + this._commanderDeath = null; this.simSpeed = 1; this.settings = readJson(SETTINGS_KEY, { edgeScroll: true, fog: true }); this.lastSfxAt = {}; // per-sound-key gate — see _throttledSfx @@ -183,6 +200,7 @@ export default class TotalAnnihilationGame extends Phaser.Scene { this.hud = new TAHud(this, this.rules, state, this.view, playerArmy, { onBuildPick: (def) => this._beginPlacement(def), onProduce: (def, n) => this._enqueue(def, n), + onCommand: (id) => this._pickCommand(id), onMinimapJump: (x, y) => this.view.centerOn(x, y), }); if (this.music?._objs?.length) this.view.ignoreOnWorldCam(this.music._objs); @@ -195,6 +213,8 @@ export default class TotalAnnihilationGame extends Phaser.Scene { } _endMatch() { + this._deathSeq = null; + this._commanderDeath = null; this.hud?.destroy(); this.hud = null; this.fx?.destroy(); this.fx = null; this.view?.destroy(); this.view = null; @@ -227,6 +247,12 @@ export default class TotalAnnihilationGame extends Phaser.Scene { this._cancelPlacement(); this.hud.toast('Build queue ended'); } + // Same for a chained order run — letting go of CTRL is how the player says "that's the + // whole patrol route". + if (this.chainedCommand && !this._queueModDown()) { + this.chainedCommand = false; + this.pendingCommand = null; + } // The AI thinks on sim ticks, not render frames, so it is stepped inside the same // fixed-step loop the simulation uses — otherwise its cadence would ride framerate. @@ -244,14 +270,17 @@ export default class TotalAnnihilationGame extends Phaser.Scene { const { nanoLinks, projectiles } = this.view.render(st.alpha); this.fx.draw(delta, nanoLinks, projectiles, time); this._syncSelection(); - this.hud.update(time, this._selectedEntities(), this.placement?.def ?? null); + this.hud.update(time, this._selectedEntities(), this.placement?.def ?? null, this.pendingCommand); this._drawDragBox(); if (this.meta?.mode === 'campaign' && time - this._lastAutosave > 60000) { this._lastAutosave = time; this.saveGame(); } - if (st.over) this._finish(st.over); + if (this._deathSeq) this._stepDeathSequence(time); + // The result screen waits for the Commander's send-off. Nothing is stepping the simulation + // by now — tick() returns immediately once state.over is set — so this only delays the UI. + if (st.over && !this._deathSeq) this._finish(st.over); } _onSimEvent(ev) { @@ -267,6 +296,10 @@ export default class TotalAnnihilationGame extends Phaser.Scene { if (def?.isCommander) { // Play the nuclear explosion at full volume for maximum impact this._throttledSfx('sfx-ta-nuclear', 300, 1); + // Kept for the armyEliminated event that follows in this same tick — that is the one + // that knows whether losing the Commander actually ended this army, which under the + // annihilation rule it does not. + this._commanderDeath = { army: ev.army, x: ev.x, y: ev.y }; } else if (def?.moveClass) { // Infantry get the flesh-and-blood cue; anything with an engine — tracked, hovering // or flying — gets the machinery one. @@ -280,6 +313,9 @@ export default class TotalAnnihilationGame extends Phaser.Scene { if (ev.t === 'armyEliminated') { const name = this.rules.armies[ev.army]?.name ?? 'Enemy'; const lost = ev.reason === 'commanderLost'; + if (lost && this._commanderDeath?.army === ev.army) { + this._startDeathSequence(ev.army, this._commanderDeath.x, this._commanderDeath.y); + } if (ev.army !== this.playerArmy) { this.hud.toast(lost ? `${name} Commander destroyed` : `${name} eliminated`, '#9ce6a0'); } else if (lost) { @@ -312,6 +348,91 @@ export default class TotalAnnihilationGame extends Phaser.Scene { } } + // ------------------------------------------------------------------------- + // Commander death sequence + // ------------------------------------------------------------------------- + + /** + * The Commander going up is the loudest thing in this game, so it gets the longest cue: its + * own detonation, then every visible thing that army owned cooking off in a wave from the + * epicentre, then one map-wide flash. The whole thing is timed to the length of the nuclear + * sample, so the picture and the sound finish together, and the result screen is held back + * until it has played out. + * + * Purely cosmetic. checkResult already decided the match — nothing here touches state, which + * is why it can be skipped or retimed freely without the outcome moving. + */ + _startDeathSequence(army, x, y) { + if (this._deathSeq || !this.match || !this.view) return; + const now = this.time.now; + const total = this._sfxDurationMs('sfx-ta-nuclear', 10000); + + // Only what the player can actually see, ordered outward from the epicentre so the cascade + // reads as a wave leaving the Commander rather than as random popping. + const doomed = this.match.entities + .filter((e) => !e.dead && e.army === army && this.view.visibleToPlayer(e)) + .map((e) => ({ + id: e.id, x: e.x, y: e.y, isBuilding: e.isBuilding, radius: e.radius, + d: Math.hypot(e.x - x, e.y - y), + })) + .sort((a, b) => a.d - b.d || a.id - b.id); + + const from = now + total * 0.14; + const span = total * 0.58; + doomed.forEach((d, i) => { + d.at = from + (doomed.length > 1 ? (i / (doomed.length - 1)) * span : 0); + }); + + this._deathSeq = { x, y, doomed, next: 0, novaAt: now + total * 0.76, endAt: now + total, nova: false }; + + // Open on something clearly bigger than an ordinary death, since the def's own explosion + // is sized for the damage it deals rather than for the drama. + const blast = (this.rules.unitById.commander?.deathExplosion?.radius ?? 240) * 2.5; + this.fx.onEvent({ t: 'bigExplosion', x, y, radius: blast }); + this.cameras.main.shake(700, 0.016); + } + + /** Advance the cascade. Synthesised FX events — none of these came from the simulation. */ + _stepDeathSequence(now) { + const seq = this._deathSeq; + while (seq.next < seq.doomed.length && seq.doomed[seq.next].at <= now) { + const d = seq.doomed[seq.next++]; + this.view.vaporised.add(d.id); + this.selection.delete(d.id); + this.fx.onEvent({ t: 'unitDestroyed', x: d.x, y: d.y, radius: d.radius, isBuilding: d.isBuilding }); + this._throttledSfx(d.isBuilding ? 'sfx-ta-vehicle-loss' : 'sfx-ta-unit-loss', 110); + } + if (!seq.nova && now >= seq.novaAt) { + seq.nova = true; + // Sized off the WORLD, not the screen, so it swallows the view at any zoom instead of + // being a big ring the player can see the edge of. + this.fx.onEvent({ + t: 'bigExplosion', x: seq.x, y: seq.y, + radius: Math.hypot(this.match.worldW, this.match.worldH), + }); + this.cameras.main.shake(1400, 0.030); + this.cameras.main.flash(1100, 255, 244, 214); + this.view.uiCam?.flash(1100, 255, 244, 214); + } + if (now >= seq.endAt) this._deathSeq = null; + } + + /** Run the rest of the sequence instantly — for a player who has seen it before. */ + _skipDeathSequence() { + if (!this._deathSeq) return; + for (let i = this._deathSeq.next; i < this._deathSeq.doomed.length; i++) { + this.view.vaporised.add(this._deathSeq.doomed[i].id); + } + this._deathSeq = null; + this.cameras.main.resetFX(); + } + + /** Length of a loaded sound in ms, straight off the audio cache. */ + _sfxDurationMs(key, fallbackMs) { + const secs = this.cache.audio.get(key)?.duration ?? 0; + return secs > 0.25 ? secs * 1000 : fallbackMs; + } + _finish(over) { this.phase = 'result'; const won = over.winner === this.playerArmy; @@ -408,6 +529,7 @@ export default class TotalAnnihilationGame extends Phaser.Scene { const code = ev.key?.toLowerCase(); if (ev.code === 'Escape') { if (this.placement) { this._cancelPlacement(); return; } + if (this._deathSeq) { this._skipDeathSequence(); return; } if (this.pendingCommand) { this.pendingCommand = null; return; } this.togglePause(); return; @@ -431,11 +553,11 @@ export default class TotalAnnihilationGame extends Phaser.Scene { return; } switch (code) { - case 'a': this.pendingCommand = 'attackMove'; this.hud.toast('Attack-move: pick a target point'); break; - case 'p': this.pendingCommand = 'patrol'; this.hud.toast('Patrol: pick a point'); break; - case 'g': this.pendingCommand = 'guard'; this.hud.toast('Guard: pick a friendly unit'); break; - case 'x': this._order({ type: 'stop' }); break; - case 'h': this._order({ type: 'hold' }); break; + case 'a': this._pickCommand('attackMove'); break; + case 'p': this._pickCommand('patrol'); break; + case 'g': this._pickCommand('guard'); break; + case 'x': this._pickCommand('stop'); break; + case 'h': this._pickCommand('hold'); break; case 'q': this.view.zoomBy(-1, GAME_WIDTH / 2, GAME_HEIGHT / 2); break; case 'e': this.view.zoomBy(1, GAME_WIDTH / 2, GAME_HEIGHT / 2); break; case ' ': this._jumpToAction(); break; @@ -651,15 +773,70 @@ export default class TotalAnnihilationGame extends Phaser.Scene { this._cancelQueueOrder(qs); } + /** + * A command button was pressed. Stop and Hold take effect on the spot; everything else arms + * a pending command that the next world click resolves, which is exactly what the A/P/G + * hotkeys have always done — the buttons are a second door onto the same mechanism, not a + * parallel one. Pressing the armed command again disarms it. + */ + _pickCommand(id) { + if (this.pendingCommand === id) { this.pendingCommand = null; return; } + if (id === 'stop' || id === 'hold') { + this.pendingCommand = null; + this._order({ type: id }); + return; + } + this._cancelPlacement(); + this.pendingCommand = id; + this.hud.toast(COMMAND_PROMPTS[id] ?? 'Pick a target'); + } + + /** + * Keep a command armed for as long as CTRL is held, so one press of the button starts a RUN + * of clicks: a patrol route, a string of attack-move waypoints, a list of repair targets. + * Released in update() the moment CTRL comes up, exactly like a chained build placement. + */ + _holdCommand(cmd, queue) { + if (!queue) return; + this.pendingCommand = cmd; + this.chainedCommand = true; + } + _applyPendingCommand(w, queue) { const cmd = this.pendingCommand; this.pendingCommand = null; - if (cmd === 'guard') { + + // Orders that name a thing rather than a place. Each one rejects the wrong kind of target + // with a toast instead of silently doing nothing, because a command that just evaporates + // reads as a broken button. + if (cmd === 'guard' || cmd === 'attack' || cmd === 'repair' || cmd === 'assist') { const t = this._entityAt(w.x, w.y); - if (t && t.army === this.playerArmy) this._order({ type: 'guard', targetId: t.id }, queue); + if (!t) { this.hud.toast('Nothing there', '#ff9a6b'); return; } + const mine = t.army === this.playerArmy; + if (cmd === 'attack' && mine) { this.hud.toast('That is yours', '#ff9a6b'); return; } + if (cmd !== 'attack' && !mine) { this.hud.toast('Pick one of yours', '#ff9a6b'); return; } + if (cmd === 'attack' && !this.view.visibleToPlayer(t)) { this.hud.toast('Nothing there', '#ff9a6b'); return; } + this._order({ type: cmd, targetId: t.id }, queue); + this._holdCommand(cmd, queue); return; } + + // Rally is a FACTORY command, not a unit order — it never touches an order queue. + if (cmd === 'setRally') { + const factories = this._selectedEntities() + .filter((e) => e.isBuilding && !e.site && this.rules.defById[e.defId].builds?.length); + if (!factories.length) return; + for (const f of factories) { + Logic.issueOrder(this.match, this.rules, { + army: this.playerArmy, order: { type: 'setRally', factoryId: f.id, x: w.x, y: w.y }, + }); + } + this.hud.toast('Rally point set'); + return; + } + this._order({ type: cmd, x: w.x, y: w.y }, queue); + this._holdCommand(cmd, queue); } _entityAt(x, y) { diff --git a/src/games/totalannihilation/sprites.md b/src/games/totalannihilation/sprites.md index 85e69d2..035bf1f 100644 --- a/src/games/totalannihilation/sprites.md +++ b/src/games/totalannihilation/sprites.md @@ -212,7 +212,7 @@ is the lowest priority of the eight. | 0–9 | Units: commander, infantry, sniper, jeep, tank, rocket tank, rocket trooper, construction vehicle, fighter, bomber | | 10–16 | Buildings: energy gen, mass gen, barracks, vehicle plant, laser tower, missile launcher, advanced vehicle plant | | 17–18 | Overflow: hover constructor (unit), airfield (building) | -| 20–27 | Commands: move, attack, attack-move, stop, hold, patrol, guard, assist | +| 20–29 | Commands: move, attack, attack-move, stop, hold, patrol, guard, assist, repair, rally | 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 diff --git a/tools/verifyTotalAnnihilation.js b/tools/verifyTotalAnnihilation.js index 867ad87..92ae459 100644 --- a/tools/verifyTotalAnnihilation.js +++ b/tools/verifyTotalAnnihilation.js @@ -303,6 +303,21 @@ section('2c. View layering and first frame'); } } + // The Commander-death cascade blows units up one at a time while the simulation sits + // frozen on state.over, so the view has to be able to stop drawing something that is still + // very much alive in state. Everything drawn for that entity has to go, not just its hull. + { + const victim = st.entities.find((e) => !e.isBuilding && !e.site); + view.selection.add(victim.id); + view.render(0); + check('a live entity draws before it is vaporised', view.sprites.has(victim.id)); + view.vaporised.add(victim.id); + view.render(0); + check('a vaporised entity stops being drawn', !view.sprites.has(victim.id)); + view.vaporised.clear(); + view.selection.clear(); + } + // Depth bands must stay in the intended order. check('bars draw above actors', DEPTHS.bars > DEPTHS.actor); check('fog draws above everything', DEPTHS.fog > Math.max(DEPTHS.fxOver, DEPTHS.bars, DEPTHS.actor)); @@ -529,11 +544,12 @@ section('2d. Container hitbox lint'); offenders === 0, `${offenders} block(s)`); } - // The build menu is ONE row deep — the bottom bar has no vertical room for a second, which - // would run down into the hint line and bury whatever wrapped there. Adding a build option - // to a unit is otherwise a pure JSON edit, so nothing else would catch it: giving the - // Commander an Airfield pushed it to 7 options and hid that button behind the hint text. const hudSrc = readFileSync(join(ROOT, 'src/games/totalannihilation/TAHud.js'), 'utf8'); + const gameSrc = readFileSync(join(ROOT, 'src/games/totalannihilation/TotalAnnihilationGame.js'), 'utf8'); + + // The build menu is ONE row deep. Adding a build option to a unit is otherwise a pure JSON + // edit, so nothing else would catch it: giving the Commander an Airfield pushed it to 7 + // options and pushed that button off into the hint line that used to live under the grid. const cols = Number(/const GRID_COLS = (\d+)/.exec(hudSrc)?.[1]); check('the HUD declares a build-grid column count', Number.isInteger(cols) && cols > 0); for (const d of [...rules.units, ...rules.buildings]) { @@ -541,6 +557,78 @@ section('2d. Container hitbox lint'); if (!n) continue; check(`${d.id}'s build options fit one grid row`, n <= cols, `${n} options vs ${cols} columns`); } + + // ---- command bar ---- + // The order buttons are the only way most of these verbs are discoverable now that the hint + // string is gone, so a button naming an order the engine does not implement — or a grid that + // silently drops its last row off the panel — is a dead end the player cannot route around. + const cmdBlock = /const COMMANDS = \[([\s\S]*?)\n\];/.exec(hudSrc)?.[1] ?? ''; + const cmdIds = [...cmdBlock.matchAll(/\{ id: '(\w+)'/g)].map((m) => m[1]); + const cmdCols = Number(/const CMD_COLS = (\d+)/.exec(hudSrc)?.[1]); + check('the HUD declares command buttons', cmdIds.length > 0); + check('the command grid fits the bar', cmdIds.length <= cmdCols * 2, + `${cmdIds.length} buttons in ${cmdCols}x2`); + // Every id must be something issueOrder actually accepts, or the button does nothing. + const engineOrders = new Set([ + ...(/const ORDER_TYPES = new Set\(\[([\s\S]*?)\]\)/.exec( + readFileSync(join(ROOT, 'src/games/totalannihilation/TALogic.js'), 'utf8'))?.[1] ?? '') + .split(',').map((t) => t.trim().replace(/'/g, '')).filter(Boolean), + 'factoryEnqueue', 'factoryCancel', 'setRally', + ]); + for (const id of cmdIds) { + check(`command "${id}" is an order the engine implements`, engineOrders.has(id)); + } + for (const id of ['move', 'attack', 'attackMove', 'patrol', 'guard', 'stop', 'hold']) { + check(`the bar offers ${id}`, cmdIds.includes(id)); + } + for (const id of ['repair', 'assist', 'setRally']) { + check(`the bar offers ${id} for builders and factories`, cmdIds.includes(id)); + } + // Reclaim has no order, no wreck entity and no payout — it must not appear as a button until + // it does, or it is a control that silently fails. + check('no button promises an unimplemented verb', + !cmdIds.some((id) => !engineOrders.has(id)), cmdIds.filter((id) => !engineOrders.has(id)).join(',')); + // A prompt per targeted command, so an armed button always says what it wants. + const prompts = /const COMMAND_PROMPTS = \{([\s\S]*?)\n\};/.exec(gameSrc)?.[1] ?? ''; + for (const id of cmdIds) { + // Instant commands (Stop, Hold) take effect on the button press and never wait for a click, + // so they have nothing to prompt for. + if (new RegExp(`\\{ id: '${id}'[^}]*instant: true`).test(cmdBlock)) continue; + check(`command "${id}" has a prompt`, new RegExp(`\\b${id}:`).test(prompts)); + } + // The old hint string is gone; nothing should still be trying to set it. + check('the hint line is fully removed', !/this\.hint\b/.test(hudSrc)); + check('every command icon has a frame', + cmdIds.every((id) => id === 'setRally' ? rules.commandIcons.rally != null : rules.commandIcons[id] != null), + Object.keys(rules.commandIcons).join(',')); + + // ---- Commander death sequence ---- + // A Phaser scene cannot be built here, so this is a source lint over the four things that + // would each break the cue silently rather than loudly. + // Anchored on the method definition, not the call site — `\n _name(` only matches a class + // member at this indent, where `_name(` alone finds `this._startDeathSequence(...)` first + // and lints the wrong function body entirely. + const seq = /\n {2}_startDeathSequence\([\s\S]*?\n {2}\}/.exec(gameSrc)?.[0] ?? ''; + check('the Commander death sequence exists', seq.length > 0); + // Timed off the sample rather than a magic number, so re-cutting the audio retimes the cue. + check('the cascade is timed to the nuclear sample', /_sfxDurationMs\('sfx-ta-nuclear'/.test(seq), + 'a hardcoded duration would drift from the audio'); + // Only what the player can see is blown up — an off-screen cascade is wasted work and + // reveals an army's layout through fog. + check('the cascade only consumes visible entities', /visibleToPlayer/.test(seq)); + // The result screen has to wait, which is the whole point of the request. + check('the result screen waits for the sequence', + /if \(st\.over && !this\._deathSeq\) this\._finish/.test(gameSrc), + 'the victory window would cover its own explosion'); + // It must fire on elimination, not merely on a Commander dying — under the annihilation rule + // an army fights on without one, and detonating its whole force would be a lie. + check('the cascade fires on elimination, not on the death alone', + /reason === 'commanderLost'|lost && this\._commanderDeath/.test(gameSrc)); + // The sample it times against has to actually be loaded for this game. + const manifest = readFileSync(join(ROOT, 'src/data/assetManifest.js'), 'utf8'); + const taBlock = /totalannihilation: \[([\s\S]*?)\n \],/.exec(manifest)?.[1] ?? ''; + check('the nuclear sample is in the Total Annihilation manifest', /'nuclear'/.test(taBlock), + 'the cue would silently fall back to its default length'); } // --------------------------------------------------------------------------- @@ -729,6 +817,137 @@ section('4b. Order queueing (CTRL)'); check('a queued build list completes', finished === 3, `${finished}/3 built`); } +// --------------------------------------------------------------------------- +section('4c. Patrol routes'); +// --------------------------------------------------------------------------- +{ + // A patrol carries a `route`: a flat circuit whose first point is where the unit stood when + // the order was given. Ctrl-clicking more points EXTENDS that circuit rather than queueing a + // second patrol behind the first — which would never run, since a patrol never completes. + const map = generateMap(rules, { seed: 909, size: 'small', symmetry: 'mirror-x' }); + const fresh = () => { + const st = L.createMatch(rules, { seed: 909, map, armies: [{ armyId: 'arm' }, { armyId: 'core' }] }); + const s = st.starts.find((x) => x.army === 0); + return { st, e: L.spawnUnit(st, rules, 0, 'tank', s.x * st.tileSize, s.y * st.tileSize) }; + }; + const patrol = (st, e, x, y, queue) => L.issueOrder(st, rules, { + army: 0, unitIds: [e.id], order: { type: 'patrol', x, y }, queue, + }); + + { + // One click is still the classic there-and-back: a two-point circuit, home included. + const { st, e } = fresh(); + const x0 = e.x, y0 = e.y; + patrol(st, e, x0 + 400, y0, false); + const o = e.orders[0]; + check('a single patrol click builds a two-point circuit', + o?.type === 'patrol' && o.route?.length === 4, `route ${JSON.stringify(o?.route)}`); + check('the circuit starts where the unit stood', + Math.abs(o.route[0] - x0) < 1e-6 && Math.abs(o.route[1] - y0) < 1e-6); + check('and heads for the clicked point first', o.leg === 1); + } + + { + // Ctrl-clicking three more points extends the one order to a five-point circuit. + const { st, e } = fresh(); + const x0 = e.x, y0 = e.y; + patrol(st, e, x0 + 300, y0, false); + patrol(st, e, x0 + 300, y0 + 300, true); + patrol(st, e, x0, y0 + 300, true); + patrol(st, e, x0 - 300, y0, true); + check('ctrl-clicking extends one patrol order', e.orders.length === 1, + `${e.orders.length} orders`); + check('every clicked point joins the route', e.orders[0].route.length === 10, + `${e.orders[0].route.length / 2} points`); + } + + { + // A non-queued patrol replaces the route rather than growing it forever. + const { st, e } = fresh(); + patrol(st, e, e.x + 300, e.y, false); + patrol(st, e, e.x + 300, e.y + 300, true); + patrol(st, e, e.x - 200, e.y, false); + check('an unqueued patrol starts a fresh route', + e.orders.length === 1 && e.orders[0].route.length === 4, + `${e.orders.length} orders, ${e.orders[0].route.length / 2} points`); + } + + { + // The circuit is actually flown: over a long run the unit must reach every corner, and the + // order must never complete. + const { st, e } = fresh(); + const x0 = e.x, y0 = e.y, R = 260; + patrol(st, e, x0 + R, y0, false); + patrol(st, e, x0 + R, y0 + R, true); + patrol(st, e, x0, y0 + R, true); + const route = e.orders[0].route.slice(); + const n = route.length >> 1; + const visited = new Array(n).fill(false); + const legsSeen = new Set(); + for (let i = 0; i < 240 * HZ; i++) { + L.tick(st, rules); + if (e.dead || !e.orders.length) break; + legsSeen.add(e.orders[0].leg); + for (let k = 0; k < n; k++) { + if (Math.hypot(e.x - route[k * 2], e.y - route[k * 2 + 1]) < st.tileSize) visited[k] = true; + } + } + check('a patrol order never completes', e.orders.length === 1 && e.orders[0].type === 'patrol'); + check('the unit visits every point on the route', visited.every(Boolean), + `reached ${visited.filter(Boolean).length} of ${n}`); + check('and cycles through every leg', legsSeen.size === n, `${legsSeen.size} of ${n} legs`); + } + + { + // Orders written by a save from before routes existed keep working untouched. + const { st, e } = fresh(); + const legacy = { type: 'patrol', sx: e.x + 200, sy: e.y, fromX: e.x, fromY: e.y, leg: 0 }; + e.orders.push(legacy); + const a = L.patrolWaypoint(legacy); + legacy.leg = 1; + const b = L.patrolWaypoint(legacy); + check('a legacy two-point patrol still resolves both ends', + Math.abs(a.x - (e.x + 200)) < 1e-6 && Math.abs(b.x - e.x) < 1e-6, + `${JSON.stringify(a)} / ${JSON.stringify(b)}`); + } + + { + // Aircraft run the same routes; they just never stop at the corners. + const raw = JSON.parse(readFileSync(join(ROOT, 'data/totalannihilation-rules.json'), 'utf8')); + raw.constants.eliminateWhenUnrecoverable = false; + const ar = compileRules(raw); + const W = 64, H = 64, TS = ar.constants.tileSize; + const px = (t) => t * TS + TS / 2; + const st = L.createMatch(ar, { + seed: 5, victory: 'annihilation', + map: { w: W, h: H, terrain: new Uint8Array(W * H).fill(ar.terrainById.ground.index), starts: [], theme: 'grasslands' }, + armies: [{ armyId: 'arm' }, { armyId: 'core' }], + }); + L.spawnUnit(st, ar, 0, 'infantry', px(1), px(1)); + L.spawnUnit(st, ar, 1, 'infantry', px(W - 2), px(H - 2)); + st.over = null; + for (const a of st.armies) a.alive = true; + const f = L.spawnUnit(st, ar, 0, 'fighter', px(30), px(30)); + const pts = [[px(40), px(30)], [px(40), px(40)], [px(30), px(40)]]; + pts.forEach(([x, y], i) => L.issueOrder(st, ar, { + army: 0, unitIds: [f.id], order: { type: 'patrol', x, y }, queue: i > 0, + })); + const route = f.orders[0].route.slice(); + const n = route.length >> 1; + const visited = new Array(n).fill(false); + for (let i = 0; i < 180 * HZ; i++) { + L.tick(st, ar); + if (f.dead || !f.orders.length) break; + for (let k = 0; k < n; k++) { + if (Math.hypot(f.x - route[k * 2], f.y - route[k * 2 + 1]) < TS * 1.5) visited[k] = true; + } + } + check('an aircraft flies a multi-point patrol route', visited.every(Boolean), + `reached ${visited.filter(Boolean).length} of ${n}`); + check('and stays airborne doing it', f.liftFrac === 1); + } +} + // --------------------------------------------------------------------------- section('5. Movement, separation and size classes'); // --------------------------------------------------------------------------- @@ -1112,6 +1331,125 @@ section('6c. Repair'); } } +// --------------------------------------------------------------------------- +section('6c2. Guarding a structure'); +// --------------------------------------------------------------------------- +{ + // A builder told to guard a structure works on it: damage first, then whatever it is + // producing. Both halves are measured against a control that has no guard, because "the + // factory eventually finished" proves nothing on its own. + const raw = JSON.parse(readFileSync(join(ROOT, 'data/totalannihilation-rules.json'), 'utf8')); + raw.constants.eliminateWhenUnrecoverable = false; + const gr = compileRules(raw); + const map = generateMap(gr, { seed: 606, size: 'small', symmetry: 'mirror-x' }); + const plant = gr.buildingById.vehicleplant; + + /** A finished Vehicle Plant with the Commander parked next to it (or not). */ + const rig = (withGuard, damage = 0) => { + const st = L.createMatch(gr, { seed: 606, map, armies: [{ armyId: 'arm' }, { armyId: 'core' }] }); + const cmd = st.entities.find((e) => e.defId === 'commander' && e.army === 0); + let f = null; + for (let r = 3; r < 16 && !f; r++) { + for (let a = 0; a < 24 && !f; a++) { + const ang = (a / 24) * Math.PI * 2; + const tx = Math.round(cmd.x / st.tileSize + Math.cos(ang) * r); + const ty = Math.round(cmd.y / st.tileSize + Math.sin(ang) * r); + if (L.canPlaceAt(st, gr, tx, ty, plant).ok) { + f = L.placeBuilding(st, gr, 0, 'vehicleplant', tx, ty); + f.site = false; f.progress = 1; f.hp = plant.hp; + } + } + } + if (f && damage) f.hp = Math.max(1, plant.hp - damage); + if (f && withGuard) { + L.issueOrder(st, gr, { army: 0, unitIds: [cmd.id], order: { type: 'guard', targetId: f.id } }); + } + return { st, cmd, f }; + }; + const run = (st, sec, onTick) => { + for (let i = 0; i < sec * HZ; i++) { + st.armies[0].mass = 9999; st.armies[0].energy = 9999; + L.tick(st, gr); + if (onTick && onTick() === false) return i / HZ; + } + return sec; + }; + + { + // Repair. The guard has to close on the plant and mend it without any further order. + const { st, cmd, f } = rig(true, 3000); + check('the guard rig placed a plant', !!f); + const before = f.hp; + run(st, 60, () => f.hp < f.maxHp); + check('a guarding builder repairs the structure', f.hp > before + 500, + `${before.toFixed(0)} -> ${f.hp.toFixed(0)} of ${f.maxHp}`); + check('and holds a nanolathe link while doing it', + cmd.buildTargetId === f.id || f.hp >= f.maxHp); + } + + { + // Production assist, measured as time-to-first-tank against an unguarded control. + const timeToTank = (withGuard) => { + const { st, f } = rig(withGuard); + L.issueOrder(st, gr, { army: 0, order: { type: 'factoryEnqueue', factoryId: f.id, defId: 'tank', count: 1 } }); + let made = 0; + const t = run(st, 120, () => { + made = st.entities.filter((e) => !e.dead && e.defId === 'tank' && e.army === 0).length; + return made === 0; + }); + return made ? t : Infinity; + }; + const solo = timeToTank(false); + const helped = timeToTank(true); + check('an unguarded plant builds its tank', Number.isFinite(solo), `${solo}s`); + // Commander buildPower 100 on top of the plant's 100 should roughly halve it; the bar is + // set well short of that so a tuning change to either number doesn't make this brittle. + check('a guarding builder speeds the factory up', helped < solo * 0.8, + `${helped.toFixed(1)}s guarded vs ${solo.toFixed(1)}s alone`); + } + + { + // Priority. While the plant is hurt the guard mends it INSTEAD of pushing the queue, so a + // damaged-and-busy factory heals before its output accelerates. + const { st, f } = rig(true, 3000); + L.issueOrder(st, gr, { army: 0, order: { type: 'factoryEnqueue', factoryId: f.id, defId: 'tank', count: 1 } }); + let sawRepairFirst = true; + for (let i = 0; i < 6 * HZ; i++) { + st.armies[0].mass = 9999; st.armies[0].energy = 9999; + L.tick(st, gr); + // Any tick where the plant is still damaged must show repair power, not extra build power. + if (f.hp < f.maxHp && f._power > (gr.buildingById.vehicleplant.buildPower ?? 0)) sawRepairFirst = false; + } + check('damage is mended before production is helped', sawRepairFirst, + 'the guard pushed the queue while the factory was still hurt'); + } + + { + // An intact, idle building needs nothing — the guard must not sit there billing the + // economy for work that does not exist. + const { st, cmd, f } = rig(true); + run(st, 20); + check('guarding an idle intact building draws no build power', + cmd.buildTargetId === 0 && st.armies[0].mDrain < 1e-6, + `link ${cmd.buildTargetId}, drain ${st.armies[0].mDrain.toFixed(2)}`); + // ...and picks the work up on its own the moment there is some. + L.issueOrder(st, gr, { army: 0, order: { type: 'factoryEnqueue', factoryId: f.id, defId: 'tank', count: 1 } }); + run(st, 3); + check('and starts assisting as soon as the factory has a job', cmd.buildTargetId === f.id); + } + + { + // Guarding a mobile unit is unchanged: escort only, no nanolathe. + const { st, cmd } = rig(false); + const tank = L.spawnUnit(st, gr, 0, 'tank', cmd.x + 200, cmd.y); + tank.hp = tank.maxHp * 0.5; + L.issueOrder(st, gr, { army: 0, unitIds: [cmd.id], order: { type: 'guard', targetId: tank.id } }); + run(st, 10); + check('guarding a mobile unit still just escorts it', cmd.buildTargetId === 0, + 'buildings are the only guard target that implies work'); + } +} + // --------------------------------------------------------------------------- section('6d. Victory conditions'); // ---------------------------------------------------------------------------