Spaces:
Paused
Paused
File size: 25,594 Bytes
d32ddf2 2fa2e12 be1f1f9 53ce1fb 5bffdf0 53ce1fb d32ddf2 7e2a606 755b556 cae0952 d32ddf2 d2a6f14 d32ddf2 d2a6f14 d32ddf2 2fa2e12 d32ddf2 5bffdf0 1ff6f70 5bffdf0 dadc0b2 695bde6 dadc0b2 d32ddf2 be1f1f9 2fa2e12 5bffdf0 dadc0b2 695bde6 dcbdeac cae0952 dcbdeac 11d7a22 3a48732 d2a6f14 dcbdeac d32ddf2 cae0952 11d7a22 3a48732 cae0952 24f7ec5 cae0952 11d7a22 d32ddf2 d2a6f14 d32ddf2 11d7a22 cae0952 d32ddf2 cae0952 d32ddf2 cae0952 dcbdeac be1f1f9 8c580c4 dcbdeac 8c580c4 dcbdeac cae0952 dcbdeac dadc0b2 dcbdeac d32ddf2 5bffdf0 d32ddf2 3a48732 d32ddf2 cce54f5 cae0952 d32ddf2 d2a6f14 204faa9 d32ddf2 755b556 cae0952 dcbdeac d2a6f14 f80d3ea cae0952 d32ddf2 f80d3ea d32ddf2 cae0952 dcbdeac 11d7a22 dcbdeac 11d7a22 d32ddf2 2fa2e12 cae0952 7e2a606 f80d3ea 7e2a606 2fa2e12 cae0952 2fa2e12 cae0952 2fa2e12 cae0952 2fa2e12 cae0952 d32ddf2 cae0952 d32ddf2 dcbdeac d32ddf2 cae0952 d32ddf2 2fa2e12 d32ddf2 2fa2e12 cae0952 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 | /* ============================================================
The splash / map-select screen: the skirmish setup panel (difficulty,
faction, map size, resources, seed) and the battlefield cards. Owns the
shared `setup` config object that boot.js reads when it starts a game.
The initial renderMapSelect() kickoff is called by main.js; the game-over
"choose another battlefield" button (hud.js) re-invokes it via boot.
============================================================ */
"use strict";
import { mapSelectEl } from "./dom.js";
import { PLANETS } from "./data.js";
import { PLANET_MODIFIERS } from "./engine/map.js";
import { archetypeFor, PLANET_ARCHETYPE, ODYSSEY_EXTRA_ARCHETYPE } from "./engine/aiArchetypes.js";
import { STRATEGIES } from "./engine/aiStrategy.js";
import { DIFFICULTY_OPTIONS } from "./engine/aiDifficulty.js";
import { DEFAULT_MATCH_TIME_LIMIT } from "./engine/victory.js";
export { DIFFICULTY_OPTIONS }; // re-exported: boot.js and a few tests still import it from here
import { FACTIONS, PLAYABLE_FACTIONS } from "./engine/factions.js";
import { hasSave, loadGame, hasOdysseySave, loadOdyssey } from "./saveload.js";
import { APP_VERSION } from "./version.js";
import { startGame, startScenario, startRaider, startBounty, startOdyssey } from "./boot.js";
import * as sound from "./sound.js";
import { renderCompetition } from "./competition.js";
// The curated roster and its order both come from the AI archetype table, so
// the picker, the opponent temperament, and the tests all agree on one list.
// Exported: competition.js's world multi-select reuses this exact roster rather than re-deriving it.
export const MAP_CHOICES = Object.keys(PLANET_ARCHETYPE);
// Every world an Odyssey can start on: the skirmish nine PLUS the two Odyssey-only extras
// (engine/galaxy.js ODYSSEY_WORLDS builds the identical list the same way β kept independent
// here rather than imported, since setup.js is a UI-layer module and this is purely about
// which cards to draw, not the engine roster itself).
const ODYSSEY_START_CHOICES = [...MAP_CHOICES, ...Object.keys(ODYSSEY_EXTRA_ARCHETYPE)];
// Splash-screen game setup, carried across "choose another battlefield"
// restarts. sizeMult scales the map (map.js); resourceMult scales every
// deposit's amount; aiApm caps the opponent's actions per minute (ai.js).
const SIZE_OPTIONS = [
{ label: "Small", mult: 1, note: "1600Γ1000" },
{ label: "Standard", mult: 2, note: "2Γ Β· room to expand" },
{ label: "Large", mult: 3, note: "3Γ Β· long game" },
{ label: "Gigantic", mult: 4, note: "4Γ Β· sprawling war" },
];
const RESOURCE_OPTIONS = [
{ label: "Rare", mult: 0.6, note: "lean deposits" },
{ label: "Normal", mult: 1.0, note: "balanced" },
{ label: "Abundant", mult: 1.5, note: "rich deposits" },
];
// Skirmish-only (engine/victory.js's score-tiebreak clock; Odyssey has no clock at all, and a
// scripted scenario runs its own separate mission clock/budget instead). Every option is a real,
// finite override of DEFAULT_MATCH_TIME_LIMIT β never "unlimited" β so checkWinCondition's
// terminal-state guarantee (a defensive stall can't stretch to infinity, CONTRIBUTING) always
// holds. "Standard" is DEFAULT_MATCH_TIME_LIMIT itself, so picking it is byte-identical to today.
// Exported: competition.js's Gauntlet screen offers the identical three lengths for its live
// matches (docs/competitions-and-elo.md Phase 4) β reused rather than redefined, so a length can't
// exist in one picker and not the other.
export const MATCH_LENGTH_OPTIONS = [
{ label: "Quick", mult: 1200, note: "20 min" },
{ label: "Standard", mult: DEFAULT_MATCH_TIME_LIMIT, note: "40 min" },
{ label: "Marathon", mult: 3600, note: "60 min" },
];
// Population cap (engine/supply.js's popCap clamp): a hard ceiling on top of the building-derived
// supply cap, shared identically by both the player and the AI β a match rule, not a per-side
// dial. "Max" is null: today's always-uncapped-by-anything-but-buildings default, so picking it
// (the default pick, matching setup.popCap's own default below) changes nothing from before this
// option existed. Offered in both economy modes (skirmish + Odyssey) β a scripted scenario runs a
// fixed roster instead of open production, so it never queues against a cap to begin with.
// The 150 rung exists for FRAME RATE, not for balance, which is why it sits below what used to be
// the tightest option: the cap bounds both sides' armies AND the housing that feeds them (a
// Habitat stops going up once the raw building total reaches the ceiling β engine/aiEconomy.js's
// rawCapHeadroom), so it is the one dial that bounds total entity count directly. It earns its
// place now that a neighbour actually develops (docs/odyssey-ai-review.md Β§2.11): measured on a
// 60-minute ferros/aggressive Odyssey, an uncapped world reaches ~1,250 supply across ~170
// buildings, and at 150 the same world settles at a fraction of that.
export const POP_CAP_OPTIONS = [
{ label: "150", mult: 150, note: "lean economy β smoothest" },
{ label: "200", mult: 200, note: "tight economy" },
{ label: "250", mult: 250, note: "moderate economy" },
{ label: "300", mult: 300, note: "large economy" },
{ label: "Max", mult: null, note: "uncapped β housing is the only limit" },
];
// Playable factions for the setup picker β a passive-trait identity for your side
// (engine/factions.js). Each option's `mult` is the faction id, its note the short
// tagline of its edge. The AI's faction comes from the world's archetype instead.
const FACTION_OPTIONS = PLAYABLE_FACTIONS.map(id => ({
label: FACTIONS[id].short, mult: id,
note: { frontier: "faster Β· sees farther", miners: "richer Β· builds faster", syndicate: "hits harder Β· lean economy" }[id],
}));
// AI Strategy β orthogonal to the opponent's archetype/doctrine (which the world/planet card
// picks): whether and when it voluntarily attacks (engine/aiStrategy.js). Order matches STRATEGIES'
// own key order so "Adaptive" (today's pure archetype-driven play) stays the first/default pick.
// Unlike difficulty (which fans out into two separate aiApm/aiMicro dials via boot.js's
// difficultyDials), a strategy key IS already the value createGameState/createGalaxy want, so
// boot.js just forwards setup.aiStrategy straight through β no lookup table needed. Applies to
// both Skirmish and Odyssey (setup.js renderSetupPanel's `economy` gate, below).
export const STRATEGY_OPTIONS = [
{ label: "Adaptive", mult: "default", note: "plays true to its archetype" },
{ label: "Aggressive", mult: "aggressive", note: "attacks earlier, presses first" },
{ label: "Economic", mult: "economic", note: "turtles, then rearms if hit" },
{ label: "Force Parity", mult: "matching", note: "mirrors your army size" },
];
export const setup = { mode: "skirmish", difficulty: "medium", faction: "frontier", aiStrategy: "default", sizeMult: 1, resourceMult: 1, seed: null,
startWorld: null, // Odyssey: explicit start-world pick (a planet id), or null for the seed's own random draw
swapAsym: false, // skirmish: play the swapped half of an asymmetric world's matchup (Oort, Nimbus) β see engine/map.js opts.swapAsym
matchTimeLimit: DEFAULT_MATCH_TIME_LIMIT, // skirmish: Match length row (Quick/Standard/Marathon) β see engine/victory.js
popCap: null }; // economy modes: Population cap row (150/200/250/300/Max) β see engine/supply.js
// The game modes the splash toggles between.
const MODES = [
{ key: "skirmish", label: "β Skirmish", note: "Destroy the enemy base" },
{ key: "odyssey", label: "π Odyssey", note: "Open world β settle and grow, endlessly" },
{ key: "escort", label: "π Convoy Escort", note: "Protect freighters to the destination" },
{ key: "raider", label: "π΄ββ οΈ Pirate Raider", note: "Raid the convoy before it escapes" },
{ key: "bounty", label: "β Bounty Marshal", note: "Hunt pirate camps before the clock" },
{ key: "competition", label: "π Competition", note: "Pit two AI configurations against each other" },
];
// The scenario modes that pick a world from the card grid (Odyssey lands on a
// random world instead, and skirmish is a normal match).
const SCENARIOS = ["escort", "raider", "bounty"];
// The two scenarios' splash copy β the setup-panel difficulty hint, the brief
// blurb above the cards, and the screen title/subtitle. Keyed by setup.mode.
const SCENARIO_COPY = {
escort: {
title: "Convoy Escort",
diffHint: "Higher difficulty means heavier piracy, a leaner escort, a tighter clock and a smaller repair budget.",
brief: "Shepherd four freighters across a multi-leg route to the destination gate. "
+ "Pirates raid each leg by its risk; dock at a station between legs to repair from your budget; "
+ "beat the mission clock. Score rewards freighters delivered, legs survived, risk faced, and budget saved.",
subtitle: "Choose the route (world to cross)",
},
raider: {
title: "Pirate Raider",
diffHint: "Higher difficulty means a tougher, better-escorted convoy, a leaner raider fleet, and a higher kill quota.",
brief: "You are the pirates. An AI convoy runs a multi-leg route for the gate under escort; "
+ "lie in wait, then dive past the escort to sink freighters. Hit the kill quota before the convoy "
+ "escapes or the clock runs out. Score rewards freighters sunk, escorts destroyed, and raiders left alive.",
subtitle: "Choose the route (world to raid)",
},
bounty: {
title: "Bounty Marshal",
diffHint: "Higher difficulty means a leaner posse, more and tougher camps, a higher clear quota, and less time.",
brief: "You are the law. Pirate camps are scattered across the sector, each marked with its bounty. "
+ "Lead your posse from camp to camp and clear your quota before the clock runs out β pick your targets "
+ "and your order carefully. Score rewards bounty banked, camps cleared fast, and posse left standing.",
subtitle: "Choose the sector (world to hunt)",
},
odyssey: {
title: "Odyssey β open world",
diffHint: "Difficulty sets how fast and fiercely your neighbours expand β from a calm frontier to a hostile sector.",
brief: "The open-world campaign. Land on a random world with a mobile colony ship β deploy it to found your "
+ "first Command Center, your relocatable capital β then build your economy beside your neighbours, in peace or "
+ "war. Expand by building more colony ships and deploying them; only a Command Center jumps between worlds (via "
+ "a Spaceport). No clock and no victory screen: you play on while a foothold stands.",
},
};
// A one-of-N pick rendered as a row of buttons; clicking one selects it and
// stores its value via onPick. Exported: competition.js reuses this directly for its own
// Strategy/Difficulty rows rather than redefining the same button-group rendering twice.
export function optionGroup(current, options, onPick) {
const wrap = document.createElement("div");
wrap.className = "opt-group";
options.forEach(opt => {
const btn = document.createElement("button");
btn.type = "button";
btn.className = "opt-btn" + (opt.mult === current ? " active" : "");
btn.innerHTML = `<span class="opt-label">${opt.label}</span><span class="opt-note">${opt.note}</span>`;
btn.addEventListener("click", () => {
onPick(opt.mult);
wrap.querySelectorAll(".opt-btn").forEach(b => b.classList.remove("active"));
btn.classList.add("active");
});
wrap.appendChild(btn);
});
return wrap;
}
function renderSetupPanel(mode) {
// Economy modes (skirmish + Odyssey) run a full base economy, so they get the
// faction and resource dials; the scripted scenarios have neither.
const economy = mode === "skirmish" || mode === "odyssey";
const panel = document.createElement("div");
panel.className = "setup";
const diffRow = document.createElement("div");
diffRow.className = "setup-row";
const diffLabel = document.createElement("span");
diffLabel.className = "setup-label";
diffLabel.textContent = "Difficulty";
diffRow.append(diffLabel, optionGroup(setup.difficulty, DIFFICULTY_OPTIONS, key => { setup.difficulty = key; }));
panel.appendChild(diffRow);
const hint = document.createElement("p");
hint.className = "setup-hint";
hint.textContent = mode === "skirmish"
? "Easy is slow and holds formation; Medium fights at a fair pace; Hard is fast and micros its army β it focus-fires, kites, and scouts with a Ranger."
: SCENARIO_COPY[mode].diffHint;
panel.appendChild(hint);
// Faction and resources shape a base economy β offered in the economy modes
// (skirmish + Odyssey), skipped in the scripted scenarios.
if (economy) {
const facHint = document.createElement("p");
facHint.className = "setup-hint";
facHint.id = "factionHint";
// Filled now and on every faction pick, so the blurb tracks the selection.
const renderFactionHint = () => { facHint.textContent = FACTIONS[setup.faction].blurb; };
const facRow = document.createElement("div");
facRow.className = "setup-row";
const facLabel = document.createElement("span");
facLabel.className = "setup-label";
facLabel.textContent = "Faction";
facRow.append(facLabel, optionGroup(setup.faction, FACTION_OPTIONS, key => { setup.faction = key; renderFactionHint(); }));
panel.appendChild(facRow);
panel.appendChild(facHint);
renderFactionHint();
// AI Strategy: how the OPPONENT plays, independent of which world/archetype it is β
// Adaptive (today's behavior), Aggressive, Economic, or Force Parity (engine/aiStrategy.js).
const stratHint = document.createElement("p");
stratHint.className = "setup-hint";
const renderStratHint = () => { stratHint.textContent = STRATEGIES[setup.aiStrategy].desc; };
const stratRow = document.createElement("div");
stratRow.className = "setup-row";
const stratLabel = document.createElement("span");
stratLabel.className = "setup-label";
stratLabel.textContent = "AI Strategy";
stratRow.append(stratLabel, optionGroup(setup.aiStrategy, STRATEGY_OPTIONS, key => { setup.aiStrategy = key; renderStratHint(); }));
panel.appendChild(stratRow);
panel.appendChild(stratHint);
renderStratHint();
}
// Map size shapes both a skirmish and a scenario β a bigger map is a longer
// convoy route / a wider sector to hunt, with the mission clock scaled to
// match (engine/scenarios.js), so it's offered in every mode.
const sizeRow = document.createElement("div");
sizeRow.className = "setup-row";
const sizeLabel = document.createElement("span");
sizeLabel.className = "setup-label";
sizeLabel.textContent = "Map size";
sizeRow.append(sizeLabel, optionGroup(setup.sizeMult, SIZE_OPTIONS, m => { setup.sizeMult = m; }));
panel.appendChild(sizeRow);
if (economy) {
const resRow = document.createElement("div");
resRow.className = "setup-row";
const resLabel = document.createElement("span");
resLabel.className = "setup-label";
resLabel.textContent = "Resources";
resRow.append(resLabel, optionGroup(setup.resourceMult, RESOURCE_OPTIONS, m => { setup.resourceMult = m; }));
panel.appendChild(resRow);
const popRow = document.createElement("div");
popRow.className = "setup-row";
const popLabel = document.createElement("span");
popLabel.className = "setup-label";
popLabel.textContent = "Population cap";
popRow.append(popLabel, optionGroup(setup.popCap, POP_CAP_OPTIONS, m => { setup.popCap = m; }));
panel.appendChild(popRow);
}
// Match length: skirmish only (see MATCH_LENGTH_OPTIONS above for why Odyssey/scenarios don't
// get this row).
if (mode === "skirmish") {
const lenRow = document.createElement("div");
lenRow.className = "setup-row";
const lenLabel = document.createElement("span");
lenLabel.className = "setup-label";
lenLabel.textContent = "Match length";
lenRow.append(lenLabel, optionGroup(setup.matchTimeLimit, MATCH_LENGTH_OPTIONS, m => { setup.matchTimeLimit = m; }));
panel.appendChild(lenRow);
}
// Optional seed: leave blank for a fresh random map, or enter a seed (shown on
// the seed chip / game-over screen) to replay the exact same world.
const seedRow = document.createElement("div");
seedRow.className = "setup-row";
const seedLabel = document.createElement("span");
seedLabel.className = "setup-label";
seedLabel.textContent = "Seed";
const seedInput = document.createElement("input");
seedInput.type = "text"; seedInput.inputMode = "numeric"; seedInput.className = "seed-input";
seedInput.placeholder = "random";
seedInput.value = setup.seed != null ? String(setup.seed) : "";
seedInput.addEventListener("input", () => {
const v = seedInput.value.trim();
const n = Number.parseInt(v, 10);
setup.seed = (v === "" || Number.isNaN(n)) ? null : (n >>> 0);
});
seedRow.append(seedLabel, seedInput);
panel.appendChild(seedRow);
return panel;
}
// Which start function each scenario mode boots. Skirmish is handled separately.
const SCENARIO_START = { escort: startScenario, raider: startRaider, bounty: startBounty };
export function renderMapSelect() {
if (!mapSelectEl) return; // import-safe under Node (CONTRIBUTING: follow the dom.js idiom)
const isScenario = SCENARIOS.includes(setup.mode);
const odyssey = setup.mode === "odyssey";
const copy = SCENARIO_COPY[setup.mode]; // defined for scenarios + Odyssey; undefined for skirmish
mapSelectEl.innerHTML = "";
const title = document.createElement("h2");
// Competition isn't in SCENARIO_COPY (none of its other fields β diffHint/brief/subtitle β
// apply, since the branch below returns before any of them would be read), so its title is a
// direct special-case rather than a mismatched-shape table entry. It names the MODE, not one of
// its tabs: competition.js's own tab row (Quick Duel / Tournament / Roster / Standings) says
// which screen you're on, and titling the whole mode after one tab stopped being true when
// Phase 3 added tournaments.
title.textContent = setup.mode === "competition" ? "π Competition" : copy ? copy.title : "Configure the skirmish";
mapSelectEl.appendChild(title);
const ver = document.createElement("p");
ver.className = "setup-version";
ver.textContent = `Stellar Frontier v${APP_VERSION}`;
mapSelectEl.appendChild(ver);
// Mode toggle: skirmish, the open-world Odyssey, or a scripted scenario.
// Picking one re-renders this screen so the setup rows + start action match.
mapSelectEl.appendChild(optionGroup(setup.mode, MODES.map(m => ({ label: m.label, mult: m.key, note: m.note })),
key => { setup.mode = key; renderMapSelect(); }));
// Competition: a background AI-vs-AI simulation, not a playable game β no player economy, no
// map size/resources/pop cap/match length/seed rows (renderSetupPanel's own rows below don't
// apply, and its unconditional SCENARIO_COPY[mode].diffHint read would throw for a mode with no
// entry there). competition.js owns the entire rest of the screen and renders directly into
// mapSelectEl itself, the same way the cards flow below does β mirrors this function's own
// `if (odyssey) { ... return; }` branch further down: an early return partway through, after the
// shared title/version/mode-toggle chrome, for a mode whose layout genuinely diverges.
if (setup.mode === "competition") { renderCompetition(); return; }
// Offer to pick up the autosaved skirmish before starting a fresh one (skirmish only).
if (setup.mode === "skirmish" && hasSave()) {
const resume = document.createElement("button");
resume.className = "btn resume-btn";
resume.textContent = "βΆ Continue β resume autosave";
resume.title = "Resume your last skirmish from its browser autosave";
resume.addEventListener("click", loadGame);
mapSelectEl.appendChild(resume);
}
// Scenarios and Odyssey get a brief blurb above the setup.
if (copy) {
const brief = document.createElement("p");
brief.className = "setup-hint";
brief.style.maxWidth = "560px";
brief.textContent = copy.brief;
mapSelectEl.appendChild(brief);
}
mapSelectEl.appendChild(renderSetupPanel(setup.mode));
// Odyssey: a Resume button when a saved galaxy exists, then a compact world-card row β the
// game's first real decision, previously withheld ("land on a random world" was the only
// affordance). A default "Random" card preserves today's seed-derived draw exactly (same seed,
// same world) for anyone who doesn't care to pick.
if (odyssey) {
if (hasOdysseySave()) {
const resume = document.createElement("button");
resume.className = "btn resume-btn";
resume.textContent = "βΆ Continue Odyssey β resume autosave";
resume.title = "Resume your Odyssey from its browser autosave";
resume.addEventListener("click", () => { sound.unlockAudio(); mapSelectEl.classList.add("hidden"); loadOdyssey(); });
mapSelectEl.appendChild(resume);
}
const subtitle = document.createElement("h3");
subtitle.className = "cards-heading";
subtitle.textContent = "Choose your starting world β or land at random";
mapSelectEl.appendChild(subtitle);
// One click both picks the world (or Random) AND begins, mirroring the skirmish cards below
// instead of adding a separate "confirm" step.
const beginOn = startId => {
sound.unlockAudio(); // a real user gesture, so it's safe to start the AudioContext here
mapSelectEl.classList.add("hidden");
setup.startWorld = startId; // null -> createGalaxy draws its own seed-derived world, exactly as before
startOdyssey();
};
const cards = document.createElement("div");
cards.className = "cards";
const randomCard = document.createElement("button");
randomCard.className = "map-card map-card-compact";
randomCard.innerHTML = `<span class="name">π² Random</span><span class="tag">Land on a random world</span>`;
randomCard.addEventListener("click", () => beginOn(null));
cards.appendChild(randomCard);
ODYSSEY_START_CHOICES.forEach(id => {
const planet = PLANETS.find(p => p.id === id);
const mod = PLANET_MODIFIERS[id];
const card = document.createElement("button");
card.className = "map-card map-card-compact";
// Reuses exactly the skirmish card's own data fields (name, deposits tag, archetype
// doctrine, industry/tech) β just a smaller layout for the wider 11-world roster.
card.innerHTML = `<span class="name">${planet.name}</span><span class="tag">${planet.tag}</span>`
+ `<span class="ai-note">${archetypeFor(id).name} neighbour</span>`
+ `<span class="sm-stats">β ${planet.industry} Β· π¬ ${planet.tech}</span>`
+ (mod ? `<span class="mod-note">${mod.label}</span>` : "");
card.addEventListener("click", () => beginOn(id));
cards.appendChild(card);
});
mapSelectEl.appendChild(cards);
return;
}
const subtitle = document.createElement("h3");
subtitle.className = "cards-heading";
subtitle.textContent = isScenario ? copy.subtitle : "Then choose a battlefield";
mapSelectEl.appendChild(subtitle);
const cards = document.createElement("div");
cards.className = "cards";
MAP_CHOICES.forEach(id => {
const planet = PLANETS.find(p => p.id === id);
const mod = PLANET_MODIFIERS[id];
const card = document.createElement("button");
card.className = "map-card";
// Skirmish cards advertise the opponent + the world modifier; scenario cards
// just pick which world the convoy crosses.
card.innerHTML = `<span class="name">${planet.name}</span><span class="tag">${planet.tag}</span><span class="desc">${planet.desc}</span>`
+ (isScenario ? "" : `<span class="ai-note">Opponent doctrine: ${archetypeFor(id).name}</span>`)
+ (mod ? `<span class="mod-note">${mod.label}</span>` : "");
// Pick your side of an ASYMMETRIC matchup (Oort, Nimbus): a plain `<span>`, not a nested
// `<button>` β a button can't validly contain another interactive control β styled
// clickable and stopping its own click from bubbling up into the card's start-the-game
// handler. A per-card LOCAL pick (not shared setup state): toggling one card's swap can
// never leak onto a different card you click instead β only wired for skirmish, since the
// scripted scenarios (setupEscort/Raider/Bounty) don't consume swapAsym.
let swapped = false;
if (mod && mod.asym && !isScenario) {
const swapBtn = document.createElement("span");
swapBtn.className = "map-card-swap";
swapBtn.textContent = "β Swap sides";
swapBtn.addEventListener("click", e => {
e.stopPropagation();
swapped = !swapped;
swapBtn.classList.toggle("active", swapped);
swapBtn.textContent = swapped ? "β Swapped β playing the other side" : "β Swap sides";
});
card.appendChild(swapBtn);
}
card.addEventListener("click", () => {
sound.unlockAudio(); // this click is a real user gesture, so it's safe to start the AudioContext here
mapSelectEl.classList.add("hidden");
setup.swapAsym = swapped;
if (isScenario) SCENARIO_START[setup.mode](id); else startGame(id);
});
cards.appendChild(card);
});
mapSelectEl.appendChild(cards);
}
|