File size: 48,961 Bytes
d32ddf2
 
 
 
 
 
 
 
 
 
 
 
a3867b9
d32ddf2
 
 
 
450f6c1
 
 
 
090c049
d32ddf2
 
f894a44
d32ddf2
 
 
d213ade
 
d32ddf2
450f6c1
6bd2c72
11037b3
ed2403c
450f6c1
3a48732
b88cc9c
d1cc6d6
e46bcce
5330c74
d32ddf2
 
 
 
 
ed2403c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d32ddf2
 
 
 
e46bcce
 
 
18ec8e7
 
 
cce54f5
18ec8e7
d32ddf2
18ec8e7
 
d32ddf2
 
 
1ad681c
 
 
 
 
 
 
 
 
 
a3867b9
 
 
 
 
1ad681c
 
 
 
a3867b9
1ad681c
 
a3867b9
1ad681c
d32ddf2
 
 
 
 
ed2403c
 
d32ddf2
 
 
 
53ce1fb
2fa2e12
dadc0b2
d32ddf2
 
 
 
1ff6f70
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6fec449
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1ff6f70
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
450f6c1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
090c049
450f6c1
 
090c049
 
 
 
 
450f6c1
 
090c049
 
 
 
 
450f6c1
 
 
 
 
 
 
 
 
 
 
dcbdeac
 
 
 
ed2403c
8c580c4
dcbdeac
 
 
11d7a22
 
 
ed2403c
8c580c4
11d7a22
 
 
3a48732
 
 
ed2403c
8c580c4
3a48732
 
 
cae0952
 
 
 
ed2403c
 
7e2a606
cae0952
be1f1f9
dadc0b2
7e2a606
 
 
 
 
 
 
 
 
cae0952
 
 
5df8767
 
 
d1cc6d6
 
 
 
 
ba43c95
d1cc6d6
ba43c95
5df8767
ba43c95
5df8767
 
d1cc6d6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6bd2c72
 
 
 
 
 
d1cc6d6
 
 
b88cc9c
 
 
 
 
 
d1cc6d6
 
7aab638
 
 
 
 
d1cc6d6
 
 
 
 
 
 
 
76e5854
 
 
 
 
 
 
e692ca2
 
 
 
 
 
 
 
18ec8e7
e692ca2
 
 
 
 
 
5df8767
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e692ca2
25e82b6
 
 
 
 
5df8767
 
 
 
 
d32ddf2
cf3fc9b
a4bbde9
 
 
 
 
 
 
 
cf3fc9b
 
a4bbde9
6bd2c72
a4bbde9
 
1ff6f70
 
 
 
450f6c1
 
 
 
 
 
 
 
 
 
 
 
 
1ad681c
a3867b9
d32ddf2
 
 
 
090c049
 
 
 
 
d32ddf2
 
 
090c049
 
 
 
 
 
 
d32ddf2
 
6bd2c72
d32ddf2
 
 
 
 
 
cae0952
1ff6f70
450f6c1
1ad681c
25e82b6
d32ddf2
 
1ad681c
 
 
d32ddf2
 
cae0952
d32ddf2
 
11d7a22
 
 
 
d32ddf2
dcbdeac
 
d32ddf2
 
 
 
 
 
e692ca2
1ad681c
d32ddf2
 
 
090c049
 
 
 
450f6c1
 
 
 
 
 
5df8767
 
 
 
1ad681c
f894a44
11037b3
7e2a606
b1db01d
947d9db
7acc706
947d9db
7acc706
947d9db
 
7acc706
 
947d9db
 
 
 
 
 
76e5854
 
 
 
 
 
450f6c1
 
 
 
 
 
11037b3
5df8767
f894a44
d32ddf2
 
 
 
f894a44
 
 
6bd2c72
 
 
 
 
 
 
 
d32ddf2
6bd2c72
dcbdeac
 
 
450f6c1
 
 
 
 
 
dcbdeac
76e5854
5bffdf0
450f6c1
 
 
 
 
 
5bffdf0
 
1ff6f70
 
 
 
 
 
 
dcbdeac
d32ddf2
 
 
 
 
 
11037b3
 
25e82b6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11037b3
25e82b6
7e2a606
ba43c95
d1cc6d6
ba43c95
 
25e82b6
 
 
 
 
 
 
e692ca2
 
25e82b6
 
 
 
 
 
 
 
 
 
ba43c95
11037b3
 
947d9db
 
 
 
 
 
e692ca2
947d9db
 
 
 
 
 
e692ca2
947d9db
 
e692ca2
947d9db
 
 
 
d32ddf2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8f2921c
d32ddf2
 
d213ade
 
 
 
 
 
 
 
 
 
d32ddf2
d213ade
c1ba8ce
 
 
 
 
 
 
 
53d4247
33fe16b
 
53d4247
 
33fe16b
53d4247
 
5330c74
 
 
 
 
 
 
6e6bac2
 
 
 
 
 
 
 
 
 
d32ddf2
 
 
 
 
 
 
 
 
 
 
e46bcce
 
 
 
 
 
820fa12
 
 
 
 
 
e46bcce
 
 
 
 
 
 
 
 
 
d32ddf2
 
 
 
 
 
 
 
 
 
450f6c1
 
 
 
18ec8e7
d32ddf2
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
/* ============================================================
   The running game session: startGame (fresh) and bootState (fresh OR loaded)
   wire a state to input, the camera, the fixed-timestep loop and the HUD; the
   render loop also pumps this frame's sim events into sound + visual effects and
   the under-attack alert. Split out of main.js β€” `state`/`input` now live on the
   shared session (session.js); the loop reads them live so a restart is picked
   up automatically (the previous loop is always stopped first).
   ============================================================ */

"use strict";

import { game } from "./session.js";
import { canvas, ctx, minimapCtx, mapSelectEl, gameOverEl, underAttackEl, pauseBtn, MINIMAP_W, MINIMAP_H } from "./dom.js";
import { createGameState } from "./engine/state.js";
import { mulberry32 } from "./engine/rng.js";
import { createLoop } from "./engine/loop.js";
import { tick } from "./engine/sim.js";
// The self-play core (docs/competitions-and-elo.md Phase 0 made it browser-importable): the ONE
// way a spectated match gets both of its seats driven by the real AI β€” createSelfPlayState builds
// the state with a second controller for owner "player", tickSelfPlay runs it before the ordinary
// tick() that already drives owner "ai".
import { createSelfPlayState, tickSelfPlay, SELFPLAY_HZ } from "./tools/selfplay.js";
import { archetypeFor } from "./engine/aiArchetypes.js";
import { isVisibleAt } from "./engine/fog.js";
import { drawFrame, resetFacing, snapshotPositions } from "./render.js";
import { drawMinimap } from "./minimap.js";
import { clampCamera } from "./camera.js";
import { attachInput } from "./input.js";
import { addTracer, addDeathFlash, addUnderAttackPing, addFireworks, addExplosion, addFuseWarning, activePings, resetEffects, DEATH_BASE_RADIUS } from "./effects.js";
import { UNITS, BUILDINGS } from "./engine/entities.js";
import { renderHUD, resetPanelSignature } from "./hud.js";
import { observedState, exitObserverMode, enterObserverMode } from "./observer.js";
import { renderObserverPanel } from "./observerPanel.js";
import { showObjectives, hideObjectives, showSeedChip, showFactionChip, showGameOver, showScenarioEnd, showGalaxyToast } from "./overlays.js";
import { renderMapSelect, setup, DIFFICULTY_OPTIONS } from "./setup.js";
import { captureCompetitionResult, spectatedGameOverBlock } from "./competition.js";
import { setupEscort, setupRaider, setupBounty } from "./engine/scenarios.js";
import { createGalaxy, activeState, jumpCapital, sweepColonies, stepGalaxy, surrenderGalaxy, DOMINATION_TARGET, playerSpaceports, previewPlanet, canJump, canJumpTo, jumpCost } from "./engine/galaxy.js";
import { openLandingPicker } from "./landingPicker.js";
import { TECHS } from "./engine/techtree.js";
import { planetName, COM } from "./data.js";
import * as sound from "./sound.js";

const UNDER_ATTACK_THROTTLE_MS = 4000;
const UNDER_ATTACK_BANNER_MS = 2500;

// Difficulty β†’ the two AI dials, looked up in setup.js's DIFFICULTY_OPTIONS β€” the
// single list that also drives the Easy/Medium/Hard picker, so a difficulty key
// can't exist in one list and not the other (previously boot.js kept its own
// separate easy/medium/hard map; if the two ever drifted, an unrecognised key
// silently fell back to Medium instead of erroring). Computed lazily (not at
// module top level) because setup.js and boot.js import each other β€” by the
// time this runs, both modules have finished loading.
function difficultyDials(key) {
  const opt = DIFFICULTY_OPTIONS.find(o => o.mult === key) || DIFFICULTY_OPTIONS.find(o => o.mult === "medium");
  return { aiApm: opt.aiApm, aiMicro: opt.aiMicro };
}

// Resolve the seed to actually run with: the setup screen's explicit pick, or a
// fresh random one (Math.random is fine here β€” it's not the sim; everything
// downstream flows from the seeded mulberry32, so same seed => same world), coerced
// to an unsigned 32-bit int either way. One helper for every start* path (skirmish,
// the three scenarios, Odyssey) so they can never disagree on how a seed is drawn.
function resolveSeed(cfg) {
  return (cfg.seed != null ? cfg.seed : Math.floor(Math.random() * 0x100000000)) >>> 0;
}

// Runtime bookkeeping β€” module-local because only the loop / frame-event pump
// touch them (state + input live on the shared session instead).
let loop, announced, lastHud, lastUnderAttackAt, underAttackTimer;
// Highest Antimatter Gate charge milestone (25/50/75/100%) already announced, so the
// wonderCharging event (which fires every tick) toasts once per threshold, not per frame.
let gateMilestone = 0;
// Where the last under-attack alert fired lives on game.lastAttackAt (session.js, next to
// supplyBlockedUntil) rather than a module-local var β€” input.js's Backspace "jump to last
// alert" key reads it too, not just the banner click below.
if (underAttackEl) underAttackEl.addEventListener("click", () => {
  if (!game.lastAttackAt || !game.input || !game.state) return;
  const cam = game.input.getCamera();
  cam.x = game.lastAttackAt.x;
  cam.y = game.lastAttackAt.y;
  clampCamera(cam, game.state.map, canvas.clientWidth, canvas.clientHeight);
});

// --- pause -------------------------------------------------------------------
// The sim pauses while a blocking overlay is up (Help, the Home-confirm modal) and on a
// manual P toggle. Reasons are refcounted so closing Help doesn't resume a game the player
// ALSO paused with P. Pausing just gates the update() callback β€” render keeps drawing the
// frozen frame (and the camera still pans, so you can look around) with overlays on top.
// The loop's own backlog-drop (engine/loop.js: `if (acc > dtFixed) acc = 0`) bounds the
// accumulator while update() is skipped, so no sim time is lost or spiralled on resume and
// the fixed-dt tick sequence β€” hence replay determinism β€” is untouched. The PAUSED banner
// (style.css body.paused) shows only for the MANUAL pause; Help/Home carry their own UI.
const pauseReasons = new Set();
function syncPause() {
  const manual = pauseReasons.has("manual");
  document.body.classList.toggle("paused", manual);   // the centered PAUSED banner (style.css) β€” manual only
  if (pauseBtn) pauseBtn.textContent = manual ? "β–Ά Resume" : "⏸ Pause";   // the topbar affordance (touch has no P key)
}
function clearPause() { pauseReasons.clear(); syncPause(); }
export function pauseLoop(reason = "manual") { pauseReasons.add(reason); syncPause(); }
export function resumeLoop(reason = "manual") { pauseReasons.delete(reason); syncPause(); }
export function togglePause() {
  if (!game.state || game.state.over) return;   // nothing to pause on the menu or after the match has ended
  if (pauseReasons.has("manual")) resumeLoop("manual"); else pauseLoop("manual");
}
if (pauseBtn) pauseBtn.addEventListener("click", togglePause);   // touch-reachable pause (mirrors the P key)

export function startGame(planetId) {
  // Seed the sim so the match is reproducible: a player can note the seed and
  // re-enter it to replay the exact same map. The seed itself is drawn from the
  // UI layer (Math.random is fine here β€” it's not the sim); everything downstream
  // flows from the seeded mulberry32, so same seed β‡’ same world.
  const seed = resolveSeed(setup);
  const diff = difficultyDials(setup.difficulty);
  // The player picks their faction; the AI's comes from this world's archetype
  // (aiArchetypes.js), so the opponent's identity is part of the world's character.
  const aiFaction = archetypeFor(planetId).faction || "neutral";
  const fresh = createGameState({ planetId, seed, rng: mulberry32(seed),
    aiApm: diff.aiApm, aiMicro: diff.aiMicro, aiStrategy: setup.aiStrategy, difficulty: setup.difficulty,
    sizeMult: setup.sizeMult, resourceMult: setup.resourceMult, swapAsym: setup.swapAsym,
    matchTimeLimit: setup.matchTimeLimit, popCap: setup.popCap,
    playerFaction: setup.faction, aiFaction });
  bootState(fresh, { intro: true });
}

// Start one COMPETITION fixture as a real, live skirmish (docs/competitions-and-elo.md Phase 4 β€”
// the Gauntlet's "Play Next Match"). Deliberately a near-copy of startGame above rather than a
// second code path: same resolveSeed/difficultyDials/createGameState/bootState chain, same loop,
// same HUD β€” this IS an ordinary skirmish, it just knows which fixture it belongs to. Nothing about
// the running game changes; only `game.competition` (session.js) is set, which the game-over hook
// below reads to route the result into the ledger.
//
// EVERYTHING IS TAKEN FROM THE FIXTURE, not from the setup screen: the world, the exact seed (off
// the gauntlet's own schedule β€” never a fresh random one, or a resumed run would replay a different
// map, D6), the pinned difficulty, the opponent's own strategy/archetype, the match length, and
// this fixture's swapAsym parity. The human plays owner "player" with their chosen faction, exactly
// as in a normal skirmish (D4: there is no other seat available to them).
//
// The three map dials are PINNED, not read off `setup`: a duel's own createSelfPlayState leaves
// sizeMult/resourceMult/popCap at engine defaults, so every rating already in the ladder was earned
// on that map shape. Letting a player's leftover skirmish preferences (Gigantic, Abundant) shape
// the matches that move THEIR rating in the same bracket would make the two numbers incomparable.
//
// BUT PINNING THOSE DOES NOT MAKE A HUMAN MATCH THE SAME SHAPE AS A SIMULATED ONE, and it would be
// dishonest to imply it does. Three differences remain, by construction, and none of them can be
// closed from here:
//   β€’ ONE match, not a worlds x seeds x 2 sweep β€” a human cannot play forty games (Phase 4's
//     founding constraint), so a human rating rests on far fewer, noisier samples.
//   β€’ NO side-swap, because the human can only ever hold owner "player" (D4). That is the seat the
//     13%-of-think-cycles edge is measured AGAINST, and it is the deviation the UI discloses.
//   β€’ The human HAS a faction and their simulated opponents do not β€” a self-play match has no
//     faction dial at all (see aiFaction below), so a faction's passive traits appear on exactly
//     one side of a human match and neither side of every other rated match in the bracket.
// The honest framing, and the one the screen states: a human rating is a rating in the SAME table,
// earned under a documented and bounded set of differences β€” not a number produced by an identical
// process. Do not add a compensating fudge for any of this; see D4 on why a correction nobody can
// derive is worse than a difference everybody can read.
export function startCompetitionMatch(fixture) {
  const { world, seed, difficulty, matchTimeLimit, swapAsym, aiStrategy, aiArchetype, playerFaction, competition } = fixture;
  sound.unlockAudio();   // a real user gesture (the Play button), same point setup.js's map cards unlock audio
  const resolved = resolveSeed({ seed });
  const diff = difficultyDials(difficulty);
  // The opponent's faction comes from the world's archetype, exactly as in a normal skirmish β€” a
  // roster entry's own faction stays what Phase 2 made it (flavour on the row), because a self-play
  // match has no faction dial at all, so every AI-vs-AI rating in this bracket was earned without
  // one. Giving the human's opponents a faction edge here alone would break that comparison.
  const aiFaction = archetypeFor(world).faction || "neutral";
  const fresh = createGameState({
    planetId: world, seed: resolved, rng: mulberry32(resolved),
    aiApm: diff.aiApm, aiMicro: diff.aiMicro, aiStrategy, difficulty, aiArchetype,
    sizeMult: 1, resourceMult: 1, popCap: null,
    swapAsym: !!swapAsym, matchTimeLimit,
    playerFaction: playerFaction || setup.faction, aiFaction,
  });
  bootState(fresh, { intro: true });
  game.competition = competition;   // after bootState, which clears it (see its own line)
}

// WATCH one AI-vs-AI match live (docs/competitions-and-elo.md Phase 5 β€” competition.js's Quick
// Duel "Watch" button is the only caller). Same relationship to startGame that
// startCompetitionMatch above has: one bootState, one loop, one HUD β€” what makes this a SPECTATED
// match rather than a played one is two facts parked on the session afterwards.
//
// 1. THE STATE COMES FROM createSelfPlayState, not createGameState. That is what gives owner
//    "player" a second AI controller (state.playerAi), and it is the SAME constructor
//    tools/duelCore.js's runDuelMatch feeds for every simulated, rated match β€” so what you watch
//    is configured identically to what the Worker would have run: same world, same derived seed,
//    same swapAsym parity, the same ONE pinned difficulty dial set on both seats, each entrant's
//    own strategy/archetype on its own seat. competition.js's buildWatchConfig shapes that config
//    and is unit-tested against the worker's own seed derivation.
// 2. `game.spectateMatch` then makes the loop below call tickSelfPlay instead of tick (so owner
//    "player" is actually driven), lets Observer Mode be entered without an Odyssey, and marks the
//    match un-checkpointable (saveShape.js's resumableMode).
//
// Observer Mode is entered immediately and deliberately: it is what reveals the fog, gives the
// spectator its own free camera, and β€” the part that matters most β€” makes input.js refuse to issue
// a single order (every mouse/wheel/key path already early-returns into observer.js while
// game.observerMode is on). The human watches; they do not play.
//
// EXHIBITION ONLY: nothing here writes to the ledger. `game.competition` stays null, so the
// game-over hook's captureCompetitionResult returns null and no rating moves β€” see the exhibition
// argument at competition.js's EXHIBITION_NOTE, and the honest game-over copy it feeds.
export function startSpectatedMatch(cfg) {
  const { world, seed, swapAsym, matchTimeLimit, ai, playerAi, aName, bName, difficulty, onLeave, recorded } = cfg;
  sound.unlockAudio();   // a real user gesture (the Watch button), same point startCompetitionMatch unlocks audio
  const fresh = createSelfPlayState({ planetId: world, seed, swapAsym, matchTimeLimit, ai, playerAi });
  // `selfPlay: true` is what makes this the same simulation the Worker runs, not merely the same
  // configuration: the loop steps at tools/selfplay.js's own fixed step. It is what a REPLAY needs
  // to reproduce its recorded row, and the watched path shares it so "what you watch is what the
  // Worker would have run" is true of the run and not just of the config.
  bootState(fresh, { intro: false, selfPlay: true });   // no objectives strip: that checklist is a PLAYER's to-do list
  // After bootState, which clears both (see its own lines) β€” exactly the startOdyssey/
  // startCompetitionMatch pattern.
  // `recorded` (present only for a REPLAY) is the outcome this re-run is expected to reproduce β€”
  // carried so the spectate bar can say what is being replayed and the game-over screen can state,
  // honestly, whether the determinism claim held this time. It is read-only display data; nothing
  // in the sim ever sees it.
  game.spectateMatch = { aName, bName, world, seed, difficulty, matchTimeLimit, onLeave, recorded: recorded || null };
  game.spectateSpeed = 1;
  enterObserverMode();
  // bootState's own renderHUD() ran a frame ago, while the flag above was still null β€” so the
  // selection panel and the topbar chips were built for an ordinary skirmish and their signature
  // guard (hudPanelSignature.js) would keep them frozen that way, offering "Select Army" over an
  // AI's army. The flag is set exactly once per game, right here, so one forced repaint is the
  // whole fix; nothing downstream has to re-check it every tick.
  resetPanelSignature();
  renderHUD();
}

// Start a Convoy Escort scenario on `planetId` at the chosen difficulty. Shares
// the seed/boot machinery with a skirmish; the scenario state carries its own
// objective (engine/scenarios.js), so bootState wires it the same way.
export function startScenario(planetId) {
  const seed = resolveSeed(setup);
  const fresh = setupEscort({ planetId, seed, difficulty: setup.difficulty, sizeMult: setup.sizeMult });
  bootState(fresh, { intro: false });
}

// Start a Pirate Raider scenario on `planetId` β€” the mirror of Escort (you raid
// the AI convoy). Same boot machinery; the scenario carries its own objective.
export function startRaider(planetId) {
  const seed = resolveSeed(setup);
  const fresh = setupRaider({ planetId, seed, difficulty: setup.difficulty, sizeMult: setup.sizeMult });
  bootState(fresh, { intro: false });
}

// Start a Bounty Marshal scenario on `planetId` β€” hunt scattered pirate camps
// across the sector against a clock. Same boot machinery.
export function startBounty(planetId) {
  const seed = resolveSeed(setup);
  const fresh = setupBounty({ planetId, seed, difficulty: setup.difficulty, sizeMult: setup.sizeMult });
  bootState(fresh, { intro: false });
}

// Start an Odyssey β€” the open-world campaign. Builds a galaxy (Phase 1: one
// randomly-chosen starting world), boots its active planet, and parks the galaxy
// on the session so the HUD/credits and the later jump machinery can reach it.
export function startOdyssey() {
  const seed = resolveSeed(setup);
  const diff = difficultyDials(setup.difficulty);
  bootGalaxy(createGalaxy({
    seed, difficulty: setup.difficulty, sizeMult: setup.sizeMult, resourceMult: setup.resourceMult,
    playerFaction: setup.faction, aiApm: diff.aiApm, aiMicro: diff.aiMicro, aiStrategy: setup.aiStrategy,
    startId: setup.startWorld, popCap: setup.popCap,
  }), { intro: true });
}

// Boot a galaxy (fresh from startOdyssey, or rehydrated from a save by
// saveload.js). bootState clears game.galaxy and rewires input/camera/HUD to the
// active world; the loop reads game.galaxy live, so the background worlds resume
// on their own once it's set right after.
export function bootGalaxy(galaxy, { intro = false } = {}) {
  bootState(activeState(galaxy), { intro });
  game.galaxy = galaxy;
}

// Launch an interplanetary jump to `destId` β€” relocate the capital + staged
// units (engine/galaxy.js), then repoint the running loop at the new world. The
// loop keeps running and keeps ticking the world you left (now a background
// colony), so this only swaps what's rendered and controlled. `landingPoint`
// (world coords) is only ever consulted by jumpCapital when the destination has
// no player Spaceport of its own β€” see initiateJump below, the picker's own
// caller.
export function performJump(destId, landingPoint) {
  if (!game.galaxy) return null;
  const result = jumpCapital(game.galaxy, destId, landingPoint ? { landingPoint } : undefined);
  if (!result) return null;   // couldn't launch (no Spaceport here, or too poor for a new world)
  focusActivePlanet();
  return result;
}

// The entry point every "Jump β–Έ world" affordance should call (starmap.js, hudSelection.js's
// Spaceport panel, notifyColony's reinforce/retake toasts below) β€” NOT performJump directly.
// Decides whether the destination needs a player-chosen landing site first: engine/galaxy.js's
// landingZone lands at the player's own Spaceport when one already stands on `destId` (no pick
// needed β€” it already knows where "home" is there), and otherwise falls back to a point the
// player chooses on a blind minimap (landingPicker.js) β€” there's no beacon to home in on, and no
// scouted intel to show while picking. A Spaceport-less ORIGIN never needs a pick at all: with
// nothing to load, that's a pure control-switch hop (jumpCapital's header comment) that moves no
// units, so there's nowhere for a landing site to matter.
//
// Runs the same afford/reachability checks jumpCapital itself re-validates at launch (mirroring
// the pre-check every existing call site already did before calling performJump), so a jump that
// plain can't happen still fails fast, synchronously, with no picker ever shown. Returns null on
// that failure, the jump's result object if it launched immediately, or `true` if a picker opened
// (the jump completes later, from the picker's own Confirm button, once the player commits to a
// spot).
export function initiateJump(destId) {
  // A real jump changes galaxy.activeId β€” Observer Mode's whole guarantee is that spectating
  // never touches that (see observer.js's header comment), so it's refused here at the single
  // choke point every real-jump trigger funnels through (the starmap, a Spaceport's Jump
  // button, a colony-alert toast's "jump to defend"), not just the starmap click this file
  // also special-cases into a free spectateWorld instead.
  if (game.observerMode) return null;
  const g = game.galaxy;
  if (!g || !canJumpTo(g, destId)) return null;
  if (g.credits < jumpCost(g, destId)) return null;
  // The destination may still be DORMANT β€” only a seeded handful of worlds are alive from turn
  // one (engine/galaxy.js BACKGROUND_WORLDS), and a first jump to an unvisited world is exactly
  // the case this picker exists for. previewPlanet builds what the jump WOULD arrive at without
  // adding it to the live set, so opening this modal (or backing out of it) never wakes a world;
  // jumpCapital builds the identical state for real once the player commits.
  const dest = previewPlanet(g, destId);
  const needsPick = canJump(activeState(g)) && playerSpaceports(dest).length === 0;
  if (!needsPick) return performJump(destId);
  // A right-click-drag (or box-select, or an armed attack-move) started on the origin's canvas
  // just before this modal opens is otherwise still "live" β€” see input.js's cancelGesture β€” and
  // its eventual mouseup, landing anywhere on the picker, would read as a move order on the
  // current planet instead of a landing pick. Cancel it before the picker can steal that release.
  if (game.input) game.input.cancelGesture();
  pauseLoop("landing-pick");
  openLandingPicker(dest, planetName(destId), {
    onPick: point => { resumeLoop("landing-pick"); performJump(destId, point); },
    onCancel: () => resumeLoop("landing-pick"),
  });
  return true;
}

// Voluntarily end the Odyssey β€” the ONLY way it ends (a wipeout just sends relief). Marks the
// active seat over; the render loop's over-poll then shows the game-over (surrender) screen.
export function surrenderOdyssey() {
  if (!game.galaxy) return;
  surrenderGalaxy(game.galaxy);
}

// Per-world UI bookkeeping that must NOT carry across a world change (a fresh boot or a
// jump): the Gate-progress high-water mark, the remembered under-attack hit + banner, and
// the supply-warning window. Shared by bootState and focusActivePlanet so the next field
// added can't drift between them β€” the bug this fixes was focusActivePlanet resetting none
// of it, so after a jump the old world's Gate% swallowed the new world's toasts and the
// under-attack click panned to stale coordinates on the wrong map.
function resetWorldUiBookkeeping() {
  gateMilestone = 0;
  game.lastAttackAt = null;
  lastUnderAttackAt = -Infinity;
  game.supplyBlockedUntil = 0;
  underAttackEl.classList.add("hidden");
  clearTimeout(underAttackTimer);
}

// Repoint the view/input at the galaxy's active planet without restarting the
// loop (used after a jump). Mirrors bootState's per-state wiring, minus creating
// the loop and minus touching game.galaxy.
function focusActivePlanet() {
  const state = activeState(game.galaxy);
  game.state = state;
  if (game.input) game.input.destroy();
  game.input = attachInput(canvas, state, () => renderHUD());
  const cc = [...state.buildings.values()].find(b => b.owner === "player" && b.type === "command");
  const openAt = cc || state.map.bases.player;
  const cam = game.input.getCamera();
  cam.x = openAt.x;
  cam.y = openAt.y;
  clampCamera(cam, state.map, canvas.clientWidth, canvas.clientHeight);
  resetEffects();
  resetFacing();
  resetPanelSignature();
  resetWorldUiBookkeeping();   // don't carry the previous world's Gate%, under-attack hit, or supply window
  // The player is physically here now β€” any starmap alert badge for THIS world is stale. Only
  // this one entry: game.colonyAlerts tracks every held colony at once (unlike the per-world
  // bookkeeping resetWorldUiBookkeeping just cleared above), so a jump here must never blow away
  // what's still burning on a world the player hasn't looked at yet.
  delete game.colonyAlerts[state.planetId];
  showSeedChip(state.seed);
  showFactionChip(state);
  renderHUD();
}

// Re-open the map-select screen (the game-over "choose another battlefield"
// button, passed into overlays' showGameOver so that module needn't import setup;
// also the topbar Home button via saveload.js). Stops the running loop AND tears the
// session down: without clearing game.input/state/galaxy the old game stayed live behind
// the menu β€” its window-level hotkeys kept firing, the M key reopened the now-dead
// Odyssey starmap, and the timer/beforeunload autosave kept writing (so "Exit without
// Saving" saved anyway). destroy() aborts the input listeners; nulling the session makes
// snapshot() a no-op and the M-key/hotkey gates false. Every other game.state reader
// (minimap handlers, under-attack click, save/repair buttons) already null-guards. Callers
// that need to persist first (Save & Exit) run autoSave BEFORE this. Idempotent.
export function restartToMapSelect() {
  if (loop) loop.stop();
  if (game.input) { game.input.destroy(); game.input = null; }
  exitObserverMode();   // a dangling spectateId into a galaxy that's about to be nulled would wedge the next game's input guards
  game.state = null;
  game.galaxy = null;
  // No game, no fixture in play. Every path that leaves a LIVE competition match has already
  // settled it before reaching here β€” the game-over hook records its result, saveload.js's Home
  // confirm forfeits it β€” so this is the belt-and-suspenders clear, not the one that decides.
  game.competition = null;
  // …and no watched match either. Nothing to settle for a spectated one (it is exhibition-only and
  // writes nothing), but leaving it set would tell the NEXT game's loop to drive owner "player"
  // with an AI controller that state doesn't have, and would keep offering Observer Mode in an
  // ordinary skirmish. Same dangling-session hazard as the spectateId exitObserverMode clears above.
  game.spectateMatch = null;
  game.spectateSpeed = 1;
  // Repaint the observer UI once, now that all of the above is false: the banner, the spectate bar
  // and the stats panel are only ever hidden by this function, and the render loop that normally
  // calls it has just been stopped. Without this they survive as stale elements β€” covered by the
  // full-screen map-select, but still live, and still there under the NEXT game until its first
  // HUD tick happens to hide them. The same "don't leave the session half torn down" reasoning as
  // the exitObserverMode call above.
  renderObserverPanel();
  clearPause();   // leaving a game clears any pause + the PAUSED banner
  pauseBtn.classList.add("hidden");   // …and the topbar pause control (no game to pause)
  renderMapSelect();
  mapSelectEl.classList.remove("hidden");
}

// The sim rate ordinary play has always run at β€” engine/loop.js's own default, restated here
// because bootState now chooses between two rates and a caller reading this file should see both
// numbers side by side rather than one of them hiding in a default parameter.
const PLAY_HZ = 20;

// Wire a state β€” freshly created OR loaded from a save β€” to input, camera, the
// fixed-timestep loop, and the HUD. The single boot path both startGame and
// loadGame funnel through.
//
// `selfPlay` (docs/competitions-and-elo.md Phase 5) runs the loop at tools/selfplay.js's own fixed
// step instead of PLAY_HZ. It is set for the one kind of game whose tick sequence has to MATCH
// something already simulated β€” a watched or replayed AI-vs-AI match β€” because a fixed step is the
// simulation, not a tuning knob: same seed, different step, different game (see SELFPLAY_DT's own
// comment for the measured proof). Every other boot path is untouched and still runs at PLAY_HZ.
export function bootState(newState, { intro, selfPlay = false }) {
  if (loop) loop.stop();
  if (game.input) game.input.destroy();
  exitObserverMode();   // fresh/loaded game β†’ fresh session, same reasoning as game.groups/colonyAlerts below
  mapSelectEl.classList.add("hidden");
  gameOverEl.classList.add("hidden");
  underAttackEl.classList.add("hidden");
  clearTimeout(underAttackTimer);
  hideObjectives();

  game.galaxy = null;   // cleared by default; startOdyssey re-sets it right after this returns
  game.competition = null;   // …and likewise: startCompetitionMatch re-sets it right after this returns
  game.spectateMatch = null; game.spectateSpeed = 1;   // …and likewise: startSpectatedMatch re-sets them right after this returns
  game.groups = {};     // fresh game β†’ fresh control groups (entity ids reset per game, so stale groups would mis-select)
  game.colonyAlerts = {};   // fresh game β†’ fresh starmap alert ledger (a previous game's background-colony alerts are meaningless here)
  game.state = newState;
  const state = newState;   // alias for the synchronous setup below (identical to the original)
  // A scenario shows the scenario bar at the top-center; the body class drops the
  // under-attack banner below it (style.css) so a raid alert isn't hidden behind the bar.
  document.body.classList.toggle("scenario", !!state.scenario);
  showSeedChip(state.seed);
  showFactionChip(state);
  if (intro) showObjectives(state.endless);
  game.input = attachInput(canvas, state, () => renderHUD());
  const input = game.input;
  // Open on the player's own ships β€” the escort/convoy start station, the raider
  // fleet's ambush point, or the player's base in a skirmish β€” never the map
  // centre, which on a big map is empty space.
  const openAt = state.scenario ? (state.scenario.playerStart || state.scenario.route[0]) : state.map.bases.player;
  const cam = input.getCamera();
  cam.x = openAt.x;
  cam.y = openAt.y;
  clampCamera(cam, state.map, canvas.clientWidth, canvas.clientHeight);
  resetEffects();
  resetFacing();
  announced = false;
  lastHud = 0;
  resetPanelSignature();
  resetWorldUiBookkeeping();
  clearPause();   // a fresh game is never born paused (and clears a stale PAUSED banner)
  let lastFrame = performance.now();

  loop = createLoop({
    // The fixed step (see this function's own `selfPlay` note). Read ONCE, at construction, and
    // deliberately not a live getter like `speed` below: the timestep must not move mid-match, or
    // the run stops being the deterministic thing a replay is comparing against.
    hz: selfPlay ? SELFPLAY_HZ : PLAY_HZ,
    // Spectate speed (docs/competitions-and-elo.md Phase 5), read LIVE so the on-screen
    // 1x/2x/4x/8x control takes effect on the very next frame without rebuilding the loop. Pinned
    // to 1 whenever no match is being watched, so an ordinary game can never inherit a leftover
    // multiplier β€” and engine/loop.js scales the SIM TIME a real second buys, never the fixed
    // timestep, so the tick sequence (and replay determinism) is exactly what it always was.
    speed: () => (game.spectateMatch ? game.spectateSpeed : 1),
    // Odyssey advances every world in the galaxy each tick (only the active one
    // is rendered), so the colonies you left keep evolving; otherwise just the
    // one match state ticks.
    update: dt => {
      if (pauseReasons.size) return;   // paused: skip the sim; render still draws the frozen frame + overlays
      snapshotPositions(game.state);   // interpolation baseline: positions BEFORE this tick moves them (render.js)
      if (game.galaxy) {
        stepGalaxy(game.galaxy, dt);   // active world full-rate, colonies on a coarser schedule
        for (const n of sweepColonies(game.galaxy, dt)) notifyColony(n);
        // Conquest progress: a freshly-razed neighbour capital β†’ a toast + a small firework.
        if (game.galaxy.pacifyNotes.length) {
          for (const id of game.galaxy.pacifyNotes) {
            showGalaxyToast(`Conquered ${planetName(id)} β€” ${game.galaxy.pacified.size}/${DOMINATION_TARGET} worlds`, "good");
            addFireworks(3);
          }
          game.galaxy.pacifyNotes.length = 0;
        }
        // Progress milestones (engine/galaxy.js): a firework show + toast for each, in place of a
        // victory screen β€” the Odyssey is a play-forever sandbox, so you keep going.
        if (game.galaxy.milestones.length) {
          for (const m of game.galaxy.milestones) celebrateMilestone(m);
          game.galaxy.milestones.length = 0;
        }
        // Relief: a total wipeout is never a defeat β€” a fresh colony ship is dispatched so you
        // can re-found. Announce it so the player finds the ship at their landing zone.
        if (game.galaxy.reliefNote) {
          game.galaxy.reliefNote = false;
          showGalaxyToast("A relief colony ship has arrived at your landing zone β€” re-found your Odyssey.", "warn");
        }
      } else if (game.spectateMatch) {
        // A WATCHED match: owner "player" is driven by state.playerAi here, then tickSelfPlay's own
        // tick() drives owner "ai" exactly as the branch below does. Deliberately the same
        // tools/selfplay.js entry point every simulated duel already runs through, so a match you
        // watch and the identical match the Worker would have simulated advance the same way.
        tickSelfPlay(game.state, dt);
      } else tick(game.state, dt);
    },
    render: (alpha) => {
      const now = performance.now();
      game.input.tickCamera((now - lastFrame) / 1000);
      lastFrame = now;

      // While paused no tick runs, so the leftover fraction wobbles with the accumulator β€”
      // pin alpha to 1 (settled/live positions) so paused units sit still instead of jittering.
      const a = pauseReasons.size ? 1 : alpha;
      // Observer Mode draws observedState() (possibly a different, backgrounded world) through
      // its own camera instead of the real game.state/game.input camera β€” see observer.js's
      // header comment. Neither is touched by the other: normal play resumes exactly where it
      // was left the moment observerMode goes back off.
      const viewState = game.observerMode ? observedState() : game.state;
      const viewCamera = game.observerMode ? game.observerCamera : game.input.getCamera();
      drawFrame(ctx, viewState, viewCamera, canvas.clientWidth, canvas.clientHeight, game.input.getDragBox(), game.input.getBuildGhost(), a, game.observerMode);
      drawMinimap(minimapCtx, viewState, viewCamera, canvas.clientWidth, canvas.clientHeight, MINIMAP_W, MINIMAP_H, activePings(), game.observerMode);
      processFrameEvents();
      if (now - lastHud > 150) { lastHud = now; renderHUD(); renderObserverPanel(); }
      if (game.state.over && !announced) {
        announced = true;
        loop.stop();
        // A WATCHED match's own ending, computed BEFORE Observer Mode is torn down (it reads
        // game.spectateMatch). Then leave Observer Mode and repaint its UI once, so the spectate
        // bar and stats panel β€” which carry explicit z-indexes and would otherwise sit ON TOP of
        // the game-over overlay β€” are gone before that overlay goes up.
        const spectate = game.spectateMatch ? spectatedGameOverBlock(game.state, game.spectateMatch) : null;
        if (spectate) { exitObserverMode(); renderObserverPanel(); }
        if (game.state.scenario) showScenarioEnd(game.state, restartToMapSelect);
        else showGameOver(game.state.winner, game.state.seed, restartToMapSelect,
          { odyssey: !!game.galaxy, wonBy: game.galaxy?.wonBy, surrendered: !!game.galaxy?.surrendered,
            // A WATCHED match's ending (Phase 5, computed just above). Neither seat is the
            // player's, so the ordinary "Victory β€” the enemy's last Command Center is destroyed"
            // copy would be a lie in both directions; competition.js names the entrant that
            // actually won and restates that the result was exhibition-only. null for every other
            // game, leaving that screen untouched.
            spectate,
            // winReason (engine/victory.js finish) + the state itself, so showGameOver can branch
            // its copy honestly and, for a score decision, show the bank/army/structures breakdown.
            winReason: game.state.winReason, state: game.state,
            // A competition fixture's result is recorded HERE, at the one point the match is
            // genuinely over (docs/competitions-and-elo.md Phase 4) β€” competition.js owns the write
            // (it holds the live ledger) and hands back the block this screen shows: the rating
            // change, the seat disclosure, and what the next fixture is with a button to play it.
            // null for every ordinary skirmish, which leaves this screen byte-identical to before.
            competition: game.competition ? captureCompetitionResult(game.state) : null });
      }
    },
  });
  loop.start();
  renderHUD();
}

// Background-colony notifications from galaxy.sweepColonies. "Under attack" is
// throttled per planet so a sustained raid pings occasionally rather than every
// tick; "lost" fires once per loss (sweepColonies only reports each transition once β€”
// retaking and losing a world again re-arms it). Both toasts are clickable β€” clicking
// jumps straight to that world to defend or retake it (a free hop, since it's a world
// you've held). If no Spaceport stands on the world you're currently on, the jump can't
// launch, so the click explains why instead.
//
// Every notification here ALSO lands on game.colonyAlerts[planetId] = {type, at}
// (session.js) β€” the starmap's live ledger (starmap.js renderStarmap reads it for a badge
// + garrison line, P6 "Starmap live colony ledger"), so a multi-colony empire can see
// "where's the fire" on the map itself instead of from memory of whichever toast scrolled
// by. Written unconditionally for all three types, independent of the toast throttle below
// β€” the badge should track the freshest real event even while the TOAST itself stays quiet
// mid-siege β€” and cleared per-world by boot.js's focusActivePlanet the instant the player
// actually arrives there. The throttle itself is untouched: it still only ever compares
// against THIS world's own previous ATTACKED alert (never a hostile/lost one that might
// have just landed on the same record), so a hostile-declaration toast can never suppress
// the very next under-attack toast.
const COLONY_NOTE_THROTTLE_MS = 9000;
export function notifyColony(n) {
  const name = planetName(n.planetId);
  const jumpThere = () => {
    if (!initiateJump(n.planetId))
      showGalaxyToast(`Build a Spaceport on your current world to jump to ${name}.`, "warn");
  };
  const now = performance.now();
  const prevAlert = game.colonyAlerts[n.planetId];
  if (n.type === "lost") {
    game.colonyAlerts[n.planetId] = { type: n.type, at: now };
    showGalaxyToast(`⚠ Your colony on ${name} has fallen β€” click to retake β–Έ`, "bad", jumpThere);
    return;
  }
  // A background world's neighbour has just declared war (fires once β€” diplomacy latches it).
  // Surface it so the first warning isn't the colony already dying; clicking jumps to reinforce.
  if (n.type === "hostile") {
    game.colonyAlerts[n.planetId] = { type: n.type, at: now };
    showGalaxyToast(`βš” The neighbour on ${name} has turned hostile β€” click to reinforce β–Έ`, "warn", jumpThere);
    return;
  }
  // "attacked": throttle against this world's own previous ATTACKED record specifically β€” NOT
  // whatever prevAlert holds if the last write here was actually the hostile/lost branch above.
  const lastAttackAt = prevAlert && prevAlert.type === "attacked" ? prevAlert.at : undefined;
  game.colonyAlerts[n.planetId] = { type: n.type, at: now };
  if (lastAttackAt !== undefined && now - lastAttackAt < COLONY_NOTE_THROTTLE_MS) return;   // undefined β‡’ first alert always fires
  showGalaxyToast(`βš” Your colony on ${name} is under attack β€” click to defend β–Έ`, "warn", jumpThere);
}

// A reached progress milestone (engine/galaxy.js checkGalaxyProgress / checkDomination) β†’
// a firework show + a celebratory toast. The Odyssey has no victory screen any more
// (play-forever); these mark how far you've come instead. The two grand milestones β€” the
// Antimatter Gate coming online and conquering the galaxy β€” get a bigger show.
function celebrateMilestone(id) {
  const [kind, arg] = id.split(":");
  const dominAll = kind === "domination" && arg === "all";   // every world pacified β€” the maximal feat
  const grand = kind === "gate" || kind === "domination";
  const msg =
      kind === "world"      ? (arg === "1" ? "β˜… First colony founded β€” your Odyssey begins!"
                                           : `β˜… Colony #${arg} established β€” your reach grows.`)
    : kind === "capital"    ? "β˜… Capital fortified β€” your anchor world stands strong."
    : kind === "gate"       ? "β˜… Antimatter Gate online β€” a triumph of industry!"
    : dominAll              ? "β˜… Every world pacified β€” the galaxy is yours!"
    : kind === "domination" ? `β˜… ${DOMINATION_TARGET} worlds conquered β€” the galaxy trembles before your fleet!`
    :                         "β˜… Milestone reached!";
  addFireworks(dominAll ? 12 : grand ? 8 : 5);
  showGalaxyToast(msg, "good");
  sound.playBuildingComplete();
}

// Stereo pan (-1..1) for a world-x, relative to the camera: a fight off the
// left edge of the view is heard on the left. Clamped, and flattened toward
// center for things near the middle so it isn't distractingly hard-panned.
function panFor(worldX) {
  const { state, input } = game;
  if (!state || !input) return 0;
  const cam = input.getCamera();
  const halfW = canvas.clientWidth / (2 * cam.zoom) || 1;
  return Math.max(-1, Math.min(1, (worldX - cam.x) / halfW)) * 0.85;
}

// A sim event plays a sound (and spawns a matching visual effect β€” see
// effects.js) if it's the player's own, or if it happened somewhere
// currently visible β€” same "you can hear what you can see" rule as fog
// of war applies to rendering. Every AI-only skirmish happening off in
// the fogged dark stays silent. An attackHit whose attacker is the AI
// necessarily means the target is the player's (only two sides exist),
// so that's also the under-attack alert's trigger.
function processFrameEvents() {
  const { state } = game;
  for (const ev of state.events) {
    if (ev.owner !== "player" && !isVisibleAt(state.fog, ev.x, ev.y)) continue;
    const pan = panFor(ev.x);   // stereo-place the sound by where it happened on screen
    switch (ev.type) {
      case "unitSpawned":
        sound.playUnitSpawned(pan);
        break;
      case "attackHit":
        (ev.heavy ? sound.playHeavyHit : sound.playAttackHit)(pan);
        addTracer(ev.fromX, ev.fromY, ev.x, ev.y, ev.unitType, ev.bonus, ev.splashRadius);
        if (ev.owner === "ai") triggerUnderAttack(ev.x, ev.y);
        break;
      // Tiered destruction (docs/improvement-proposals.md): the event's unitType/kind (engine/
      // combat.js/engine/bomb.js) let the death ring/sound scale by what actually died, instead
      // of every kill playing the identical 280ms ring + tone. Falls back to the def-less
      // baseline for an event somehow missing them (shouldn't happen post-this-change, but keeps
      // this handler from ever crashing on a stray/older-shaped event).
      case "entityKilled": {
        const def = ev.kind === "building" ? BUILDINGS[ev.unitType] : UNITS[ev.unitType];
        const radius = def?.radius || DEATH_BASE_RADIUS;
        sound.playEntityKilled(pan, radius / DEATH_BASE_RADIUS);
        addDeathFlash(ev.x, ev.y, radius, ev.kind || "unit");
        break;
      }
      // A Helium Bomb's fuse just lit (engine/bomb.js) β€” proximity or the player's own
      // "Detonate Now" command. The real blast (bombDetonated below) doesn't land for
      // another ev.delay sim-seconds; this is the warning that it's now inevitable.
      case "bombFused":
        sound.playFuseLit(pan);
        addFuseWarning(ev.x, ev.y, ev.delay * 1000);
        if (ev.owner === "ai") triggerUnderAttack(ev.x, ev.y);
        break;
      // The Helium Bomb's detonation (engine/bomb.js) β€” one event for the blast itself,
      // alongside the individual entityKilled events for everything the falloff-damaged
      // blast actually killed outright.
      case "bombDetonated":
        sound.playExplosion(pan);
        addExplosion(ev.x, ev.y, ev.radius, ev.coreRadius);
        if (ev.owner === "ai") triggerUnderAttack(ev.x, ev.y);
        break;
      // A Helium Bomb crater (engine/bomb.js) finished terraforming β€” the payoff for the
      // detonation, easy to miss if it's not called out (the deposit can land anywhere the
      // bomb went off, not necessarily somewhere the player is still looking).
      case "craterMatured":
        sound.playBuildingComplete(pan);
        showGalaxyToast(`${COM[ev.com]?.ico || "β—†"} A ${COM[ev.com]?.name || ev.com} deposit has formed in the blast crater`, "good");
        break;
      // Battle wreckage (engine/wreckage.js) finished settling into real deposits β€” one
      // node per commodity it accumulated, so the toast lists all of them at once rather
      // than firing once per node. Same payoff-callout reasoning as craterMatured above:
      // easy to miss otherwise if the fight's moved on since it happened.
      case "wreckMatured": {
        sound.playBuildingComplete(pan);
        const goods = (ev.coms || []).map(com => `${COM[com]?.ico || "β—†"} ${COM[com]?.name || com}`).join(", ");
        showGalaxyToast(`Battle wreckage has settled into a deposit: ${goods}`, "good");
        break;
      }
      case "buildingComplete":
        sound.playBuildingComplete(pan);
        break;
      // Only the player's own supply block beeps and flashes β€” a visible
      // enemy stalling on supply is their problem, not a HUD alert of ours.
      case "productionBlocked":
        if (ev.owner === "player") {
          sound.playProductionBlocked();
          game.supplyBlockedUntil = performance.now() + 800;
        }
        break;
      // Odyssey research finishing was previously silent β€” announce the unlock so
      // the player connects the wait to the reward (and notices new build options).
      case "researchComplete":
        sound.playBuildingComplete(pan);
        showGalaxyToast(`Researched ${TECHS[ev.techId]?.name || ev.techId}`, "good");
        break;
      // The neighbour just crossed from peace into war β€” a one-time heads-up so the
      // first raid doesn't land unannounced (diplomacy.js fires this once per world).
      case "neighbourHostile":
        sound.playProductionBlocked();
        showGalaxyToast("βš” Your neighbour has turned hostile β€” expect raids. Ready your defence.", "bad");
        break;
      // The Antimatter Gate charge (fires every tick) β€” toast once per 25% so the
      // multi-minute climb to the galaxy win is visible without selecting the Gate.
      case "wonderCharging": {
        const pct = Math.floor((ev.charge || 0) * 4) * 25;
        if (pct >= 25 && pct > gateMilestone) {
          gateMilestone = pct;
          showGalaxyToast(`Antimatter Gate charging β€” ${pct}%`, pct >= 75 ? "bad" : "warn");
        }
        break;
      }
    }
  }
  state.events.length = 0;
}

// Throttled independently of sound.js's own internal throttle (which
// only governs the alarm tone) so the banner and the minimap/world ping
// stay in lockstep with each other during a sustained siege instead of
// re-flashing on every single hit.
function triggerUnderAttack(x, y) {
  // Nobody's base is under attack in a WATCHED match β€” both sides are AI entrants, and "⚠ Under
  // Attack" over a match the human isn't playing is simply false. (The alarm fires off an
  // owner-"ai" attackHit, which in a spectated duel just means one entrant hit the other.)
  if (game.spectateMatch) return;
  game.lastAttackAt = { x, y };   // remembered even while throttled, so a click/Backspace always jumps to the freshest hit
  const now = performance.now();
  if (now - lastUnderAttackAt < UNDER_ATTACK_THROTTLE_MS) return;
  lastUnderAttackAt = now;

  sound.playUnderAttack();
  addUnderAttackPing(x, y);
  underAttackEl.classList.remove("hidden");
  clearTimeout(underAttackTimer);
  underAttackTimer = setTimeout(() => underAttackEl.classList.add("hidden"), UNDER_ATTACK_BANNER_MS);
}