/* ============================================================
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 = `${opt.label}${opt.note}`;
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 = `🎲 RandomLand on a random world`;
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 = `${planet.name}${planet.tag}`
+ `${archetypeFor(id).name} neighbour`
+ `⚙ ${planet.industry} · 🔬 ${planet.tech}`
+ (mod ? `${mod.label}` : "");
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 = `${planet.name}${planet.tag}${planet.desc}`
+ (isScenario ? "" : `Opponent doctrine: ${archetypeFor(id).name}`)
+ (mod ? `${mod.label}` : "");
// Pick your side of an ASYMMETRIC matchup (Oort, Nimbus): a plain ``, not a nested
// `