// Application shell: keeps the screens, the battle session and the Phaser game in sync. // It drives both the single-player skirmish and the authoritative online match. import type Phaser from 'phaser'; import { createGame, SkirmishSession, type MatchConfig, type MatchResult } from './battle.ts'; import { BattleScene } from './battle-scene.ts'; import { Screens, loadSettings, type GameSettings, type LobbyView, type MatchQuestSummary, type OnlineHostOptions, type ProfileSummary, type QuestEntryView, type WorldMapView, type CapitalView, type TechBranchView, type TechNodeView, type TechView, } from './screens.ts'; import { audio } from './audio.ts'; import { hostRoom, inviteUrl, mintInvite, NetRoom, parseInvite } from './net.ts'; import { MirrorState } from './mirror.ts'; import { NetSession } from './net-session.ts'; import type { RosterMessage } from '../shared/protocol.ts'; import { BUILDINGS, DIFFICULTY_LABELS, FACTIONS, UNITS, type Resources } from '../sim/defs.ts'; import { layoutById } from '../sim/layouts.ts'; import { SUPPLY, UPGRADES, buySupply, buyUpgrade, capitalPerks, ownedUpgrades, spendSupplies, supplyCrates, supplyStatus, upgradeById, upgradeStatus, type CapitalPerks, } from '../meta/capital.ts'; import { TECH_BRANCHES, buyTech, combineEffects, profileEffects, signatureNode, techByBranch, techById, techLine, techStatus, } from '../meta/tech.ts'; import { QUESTS, REGIONS, questAct, questById, questMatchConfig, questNeedsCommander, questOpenTo, type QuestDef } from '../meta/quests.ts'; import { gradeLine } from '../meta/grades.ts'; import { allLadders, ladderLine } from '../meta/ladder.ts'; import { commanderEffects, commanderFor, ownedCommander } from '../meta/commanders.ts'; import { applyQuestResult, browserStorage, emptyProfile, levelInfo, loadProfile, questUnlocked, saveProfile, type Profile, type QuestRewardOutcome, } from '../meta/profile.ts'; type MatchSession = SkirmishSession | NetSession; class App { private readonly gameRoot: HTMLElement; private readonly uiRoot: HTMLElement; private readonly screens: Screens; private session: MatchSession | null = null; private game: Phaser.Game | null = null; private settings: GameSettings; private lastConfig: MatchConfig | null = null; /** Set while a quest mission runs, so Rematch replays the mission rather than the map. */ private lastQuest: QuestDef | null = null; private profile: Profile = emptyProfile(); private readonly profileStore = browserStorage(); private audioUnlocked = false; // lobby state private net: NetRoom | null = null; private roomId = ''; private roomToken = ''; private inviteLink = ''; private hostToken: string | null = null; private seat = -1; private roster: RosterMessage | null = null; private startingOnline = false; constructor() { this.gameRoot = must('game-root'); this.uiRoot = must('ui-root'); const screenRoot = must('screen-root'); this.settings = loadSettings(); this.profile = loadProfile(this.profileStore); this.screens = new Screens( screenRoot, { startMatch: (config) => void this.startMatch(config), openWorldMap: () => this.openWorldMap(), startQuest: (questId) => this.startQuest(questId), profileSummary: () => this.profileSummary(), openCapital: () => this.openCapital(), buyUpgrade: (id) => this.buyCapitalUpgrade(id), buySupply: () => this.buySupplyCrate(), openTech: () => this.openTech(), buyTech: (id) => this.buyTechNode(id), quitToMenu: () => this.quitToMenu(), resume: () => this.resume(), resign: () => this.session?.resign(), rematch: () => { if (this.lastQuest) void this.startQuest(this.lastQuest.id); else if (this.lastConfig) void this.startMatch(this.lastConfig); else this.screens.showMenu(); }, settingsChanged: (next) => this.applySettings(next), openMultiplayer: () => this.screens.showOnline(), onlineHost: (options) => void this.hostOnline(options), onlineJoin: (roomId, token) => void this.joinOnline(roomId, token), onlineStart: () => this.net?.requestStart(), onlineLeave: () => this.leaveLobby(), }, this.settings, ); window.addEventListener('pointerdown', () => this.unlockAudio(), { once: true }); window.addEventListener('keydown', () => this.unlockAudio(), { once: true }); window.addEventListener('resize', () => this.resize()); window.addEventListener('beforeunload', () => this.disposeMatch()); this.applySettings(this.settings); // An invitation link (?room=…&token=…) drops straight into that room. const invite = parseInvite(window.location.search); if (invite) { this.screens.showOnline(); void this.joinOnline(invite.roomId, invite.token); } else { this.screens.showMenu(); } } private unlockAudio(): void { if (this.audioUnlocked) return; this.audioUnlocked = true; try { void audio.unlock(); this.applyAudioSettings(); } catch { /* audio is optional */ } } private applySettings(next: GameSettings): void { this.settings = next; this.applyAudioSettings(); if (this.session && this.game) { const scene = this.game.scene.getScene('battle') as BattleScene | null; scene?.setEdgeScroll(next.edgeScroll); } } private applyAudioSettings(): void { try { audio.setEnabled(this.settings.soundOn); audio.setVolume(this.settings.volume); } catch { /* ignore */ } } // ------------------------------------------------------------------ skirmish private async startMatch(config: MatchConfig, quest: QuestDef | null = null): Promise { this.disposeMatch(); this.lastConfig = config; this.lastQuest = quest; this.screens.hide(); this.uiRoot.innerHTML = ''; this.gameRoot.innerHTML = ''; // War Supply crates are the repeatable gold sink: they are spent the moment a mission is // deployed, so gold keeps buying something even after every charter is owned. const supplies = spendSupplies(this.profile); if (supplies.crates > 0) { this.profile = supplies.profile; saveProfile(this.profileStore, this.profile); } // Capital-city perks apply to every local match; the profile is read fresh so a charter // bought just before deploying a mission is in effect immediately. const basePerks: CapitalPerks = config.perks ?? capitalPerks(this.profile.capital); const resources: Partial = { ...(basePerks.resources ?? {}) }; for (const [key, amount] of Object.entries(supplies.perks?.resources ?? {})) { const k = key as keyof Resources; resources[k] = (resources[k] ?? 0) + (amount ?? 0); } const withPerks: MatchConfig = { ...config, perks: { ...basePerks, resources, // Tech nodes and the sworn commander fold into one effect set before the sim sees it. effects: combineEffects([profileEffects(this.profile), ...commanderEffects(this.profile)]), }, }; const session = new SkirmishSession(withPerks, { onMatchEnd: (result) => this.onMatchEnd(result), onMenu: () => this.openPause(), onHelp: () => this.screens.showHelp('battle'), }, quest); this.session = session; session.bindHud(this.uiRoot); if (supplies.crates > 0) { const crates = supplies.perks?.resources ?? {}; const bits = (['food', 'wood', 'stone'] as const) .filter((k) => crates[k]) .map((k) => '+' + crates[k] + ' ' + k); session.pushAlert('War Supplies unloaded: ' + bits.join(', ') + '.'); } this.bootScene(session); } // ------------------------------------------------------------------ campaign private profileSummary(): ProfileSummary { const info = levelInfo(this.profile.xp); const civ = FACTIONS[this.profile.civ]; return { name: this.profile.name, civName: civ?.name ?? this.profile.civ, level: info.level, into: info.into, span: info.span, atCap: info.atCap, gold: this.profile.gold, commander: ownedCommander(this.profile)?.name ?? null, }; } private openWorldMap(): void { // The ladder is derived from the profile's quest records on every open, so a cleared // chapter, a faster best time or a repair shows up without a migration step. const ladders = new Map(allLadders(this.profile).map((l) => [l.id, l])); this.screens.showWorldMap({ profile: this.profileSummary(), regions: REGIONS.map((r) => { const ladder = ladders.get(r.id); return { id: r.id, name: r.name, line: r.blurb, ladder: ladder ? ladderLine(ladder) : '', complete: ladder?.complete ?? false, }; }), quests: QUESTS.map((q) => this.questEntry(q)), selectedId: null, }); } private openCapital(): void { const view: CapitalView = { profile: this.profileSummary(), upgrades: UPGRADES.map((u) => ({ id: u.id, name: u.name, blurb: u.blurb, cost: u.cost, tier: u.tier ?? 1, status: upgradeStatus(this.profile, u.id), requiresName: u.requires ? upgradeById(u.requires)?.name ?? u.requires : null, })), supply: { name: SUPPLY.name, blurb: SUPPLY.blurb, cost: SUPPLY.cost, crates: supplyCrates(this.profile), cap: SUPPLY.cap, status: supplyStatus(this.profile), }, perksLine: this.capitalPerksLine(), }; this.screens.showCapital(view); } /** One sentence listing what the owned charters and loaded crates add to a mission start. */ private capitalPerksLine(): string { const owned = ownedUpgrades(this.profile); const crates = supplyCrates(this.profile); if (owned.length === 0 && crates === 0) { return 'No charters yet: every mission starts with the standard settlement.'; } const perks = capitalPerks(this.profile.capital); const bits: string[] = []; if (perks.workers) bits.push('+' + perks.workers + ' villagers'); for (const [key, amount] of Object.entries(perks.resources ?? {})) { if (amount) bits.push('+' + amount + ' ' + key); } for (const u of perks.units ?? []) bits.push(u.count + '\u00d7 ' + (UNITS[u.defId]?.name ?? u.defId)); for (const b of perks.buildings ?? []) bits.push(b.count + '\u00d7 ' + (BUILDINGS[b.defId]?.name ?? b.defId)); if (crates > 0) bits.push(crates + '\u00d7 ' + SUPPLY.name + (crates === 1 ? ' crate' : ' crates')); return 'Missions start with ' + bits.join(', ') + '.'; } private buyCapitalUpgrade(id: string): void { const outcome = buyUpgrade(this.profile, id); if (!outcome.ok) { const why = outcome.reason === 'owned' ? 'You already own that charter.' : outcome.reason === 'poor' ? 'Not enough gold yet.' : outcome.reason === 'locked' ? 'Buy its prerequisite charter first.' : 'Unknown charter.'; this.screens.toast(why); return; } this.profile = outcome.profile; saveProfile(this.profileStore, this.profile); this.screens.toast('Charter signed: ' + (outcome.upgrade?.name ?? id)); this.openCapital(); } /** Buy one War Supply crate: the repeatable gold sink. */ private buySupplyCrate(): void { const outcome = buySupply(this.profile); if (!outcome.ok) { this.screens.toast( outcome.reason === 'full' ? 'The cart is full: ' + SUPPLY.cap + ' crates is the limit.' : 'Not enough gold yet.', ); return; } this.profile = outcome.profile; saveProfile(this.profileStore, this.profile); this.screens.toast('War Supplies loaded: ' + outcome.crates + ' crate' + (outcome.crates === 1 ? '' : 's') + '.'); this.openCapital(); } // ------------------------------------------------------------------ tech tree private openTech(): void { const view: TechView = { profile: this.profileSummary(), branches: TECH_BRANCHES.map((b): TechBranchView => ({ id: b.id, name: b.name, line: b.line, nodes: techByBranch(b.id).map((n) => this.techNodeView(n.id)), })), signature: signatureNode(this.profile.civ) ? this.techNodeView(signatureNode(this.profile.civ)!.id) : null, effectsLine: techLine(this.profile), }; this.screens.showTech(view); } private techNodeView(id: string): TechNodeView { const def = techById(id); if (!def) return { id, name: id, blurb: '', cost: 0, tier: 1, status: 'locked', requiresName: null }; return { id, name: def.name, blurb: def.blurb, cost: def.cost, tier: def.tier, status: techStatus(this.profile, id), requiresName: def.requires ? techById(def.requires)?.name ?? def.requires : null, }; } private buyTechNode(id: string): void { const outcome = buyTech(this.profile, id); if (!outcome.ok) { const why = outcome.reason === 'owned' ? 'That node is already researched.' : outcome.reason === 'poor' ? 'Not enough gold yet.' : outcome.reason === 'foreign' ? 'That signature charter belongs to another realm.' : outcome.reason === 'locked' ? 'Research its prerequisite node first.' : 'Unknown node.'; this.screens.toast(why); return; } this.profile = outcome.profile; saveProfile(this.profileStore, this.profile); this.screens.toast('Node charted: ' + (outcome.node?.name ?? id)); this.openTech(); } private questEntry(q: QuestDef): QuestEntryView { const rec = this.profile.quests[q.id]; const unlocked = questUnlocked(this.profile, q); const parent = q.unlockedBy?.map((id) => questById(id)).find((x) => !!x) ?? null; const openers = (q.unlockedByAny ?? []).map((id) => questById(id)).filter((x) => !!x); // Ground is either generated from the map type or hand-placed in src/sim/layouts.ts. const ground = q.map ? layoutById(q.map) : null; const limit = q.timeLimitSeconds > 0 ? Math.round(q.timeLimitSeconds / 60) + ' minute limit' : 'no time limit'; const rivals = q.opponents.length === 1 ? '1 rival' : q.opponents.length + ' rivals'; // A civ chapter is visible to every commander but only fightable by its own faction. const foreign = !questOpenTo(q, this.profile.civ); const civName = q.civ ? FACTIONS[q.civ]?.name ?? q.civ : null; // Act III finals need a sworn commander: the last chapter of each realm is an oath, not a shop. const gateCiv = q.requiresCommander ? FACTIONS[q.requiresCommander]?.name ?? q.requiresCommander : null; const needsCommander = questNeedsCommander(q) && !foreign && !ownedCommander(this.profile); return { id: q.id, regionId: q.region, regionName: REGIONS.find((r) => r.id === q.region)?.name ?? q.region, title: q.title, blurb: q.blurb, brief: q.brief, level: q.level, objectives: q.objectives.map((o) => o.label), rewardXp: q.rewards.xp, rewardGold: q.rewards.gold, repeatable: !!q.repeatable, locked: !unlocked || foreign || needsCommander, lockedNote: foreign ? 'Foreign chapter: only a sworn ' + civName + ' commander can fight it.' : needsCommander ? 'Only a sworn ' + gateCiv + ' commander may open this chapter.' : unlocked ? null : parent ? 'Clear "' + parent.title + '" to open this mission.' : openers.length > 0 ? 'Clear any realm finale to open this chapter.' : 'Locked.', civName, foreign, act: questAct(q), grade: rec?.bestGrade ?? null, parLine: gradeLine(q), completedTimes: rec?.completed ?? 0, bestSeconds: rec && rec.bestSeconds > 0 ? rec.bestSeconds : null, // Plain-text fields: screens.ts escapes them, so real characters — not entities. opponents: rivals + ' · ' + q.opponents.map((o) => o.name + ' (' + DIFFICULTY_LABELS[o.difficulty] + ')').join(', '), mapLine: (ground ? ground.name : MAP_LABEL[q.mapType]) + ' · ' + q.mapSize + ' tiles · ' + limit, }; } private async startQuest(questId: string): Promise { const quest = questById(questId); if (!quest) { this.screens.toast('Unknown quest: ' + questId); return; } if (!questOpenTo(quest, this.profile.civ)) { this.screens.toast('That chapter belongs to another realm.'); return; } if (questNeedsCommander(quest) && !ownedCommander(this.profile)) { this.screens.toast('Only a sworn commander may open this chapter.'); return; } if (!questUnlocked(this.profile, quest)) { this.screens.toast('Clear the earlier mission first.'); return; } const cfg = questMatchConfig(quest, this.profile.name, this.profile.civ); await this.startMatch(cfg, quest); } /** Bank a finished quest: profile progress, rewards and the next mission to offer. */ private applyQuest(quest: QuestDef, result: MatchResult): MatchQuestSummary { const report = result.quest!; const cleared = report.done && result.playerWon; const outcome = applyQuestResult(this.profile, quest, { done: cleared, time: result.time }); this.profile = outcome.profile; saveProfile(this.profileStore, this.profile); // Clearing a civ chapter swears that realm's named commander in for every later mission. let commanderName: string | null = null; if (cleared && quest.civ) { const def = commanderFor(quest.civ); if (def && def.questId === quest.id && !this.profile.commanders.includes(def.id)) { this.profile = { ...this.profile, commanders: [...this.profile.commanders, def.id], updatedAt: Date.now() }; saveProfile(this.profileStore, this.profile); commanderName = def.name; this.screens.toast('Commander sworn: ' + def.name + ', ' + def.title + '.'); } } return { title: quest.title, objectives: report.objectives.map((o) => ({ label: o.label + (o.kind === 'gather' || o.kind === 'train' || o.kind === 'build' || o.kind === 'destroy' ? ' (' + Math.min(o.current, o.goal) + '/' + o.goal + ')' : ''), done: o.done, })), gainedXp: outcome.gainedXp, gainedGold: outcome.gainedGold, leveledUp: outcome.leveledUp, levelAfter: outcome.levelAfter, firstClear: outcome.firstClear, repeatable: !!quest.repeatable, nextQuestId: this.nextQuestId(quest), commanderName, grade: outcome.grade, bestGrade: outcome.bestGrade, improvedGrade: outcome.improvedGrade, bountyGold: outcome.bountyGold, }; } private nextQuestId(quest: QuestDef): string | null { for (const q of QUESTS) { if (!q.unlockedBy?.includes(quest.id)) continue; if (!questOpenTo(q, this.profile.civ)) continue; if (questNeedsCommander(q) && !ownedCommander(this.profile)) continue; if (!questUnlocked(this.profile, q)) continue; if ((this.profile.quests[q.id]?.completed ?? 0) === 0) return q.id; } return null; } // ------------------------------------------------------------------ online private async hostOnline(options: OnlineHostOptions): Promise { try { const hosted = await hostRoom(options); this.roomId = hosted.roomId; this.roomToken = hosted.token; this.hostToken = hosted.token; // The host token stays private: guests get a minted invite token instead. let shareToken = hosted.token; try { shareToken = await mintInvite(hosted.roomId, hosted.token); } catch { /* fall back to sharing the host token: playable, just not revocable */ } this.inviteLink = inviteUrl({ roomId: hosted.roomId, token: shareToken }); await this.openLobby(options.name); } catch (err) { this.screens.toast(String(err instanceof Error ? err.message : err)); this.screens.showOnline(); } } private async joinOnline(roomId: string, token: string): Promise { this.roomId = roomId; this.roomToken = token; this.hostToken = null; this.inviteLink = inviteUrl({ roomId, token }); await this.openLobby(undefined); } private async openLobby(name?: string): Promise { this.leaveRoom(); this.seat = -1; this.roster = null; this.startingOnline = false; try { this.net = await NetRoom.join( this.roomId, this.roomToken, { onWelcome: (data) => { this.seat = data.seat; this.renderLobby(); }, onRoster: (data) => { this.roster = data; this.inviteLink = this.inviteLink || inviteUrl({ roomId: this.roomId, token: this.roomToken }); this.renderLobby(); }, onStarted: () => void this.startOnlineMatch(), onClosed: (code) => { if (this.session instanceof NetSession) return; this.net = null; this.screens.toast(code === 4000 ? 'The room was closed' : 'Disconnected from the room'); this.screens.showMenu(); }, onError: (err) => this.screens.toast(err.message || 'Connection error'), }, name, ); this.renderLobby(); } catch (err) { this.net = null; this.screens.toast('Could not join the room: ' + String(err instanceof Error ? err.message : err)); this.screens.showOnline(); } } private renderLobby(): void { const net = this.net; if (!net) return; const roster = this.roster; const seats = roster ? roster.seats : []; // The roster lists every seat in the room, taken or not, so its length is the seat total. const total = seats.length || 2; const joined = seats.filter((s) => s.connected).length; const isHost = !!roster && roster.hostSeat === this.seat; let status: string; if (!roster) status = 'Connected — waiting for the room state…'; else if (seats.length === 0) status = 'The room has no seats yet.'; else if (joined < total) status = joined + ' of ' + total + ' seats taken. Empty seats are handed to the AI; the room starts automatically when full, and the host can start sooner.'; else status = 'Room is full. The match starts momentarily.'; const view: LobbyView = { roomId: this.roomId, inviteLink: this.inviteLink, seats: seats.map((s) => ({ name: s.name, colorCss: s.colorCss, faction: s.faction, isAI: s.isAI, connected: s.connected })), youSeat: this.seat, isHost, status: isHost ? 'You are the host. ' + status : status, canStart: true, }; this.screens.showLobby(view); } private startOnlineMatch(): void { if (this.startingOnline) return; const net = this.net; const roster = this.roster; if (!net || !roster || this.seat < 0) { // Roster can land a moment after 'started' on a slow link. window.setTimeout(() => this.startOnlineMatch(), 200); return; } this.startingOnline = true; this.disposeMatch(); const mirror = new MirrorState(roster.settings, roster.seats, this.seat); const session = new NetSession(net, mirror, { onMatchEnd: (result) => this.onMatchEnd(result), onMenu: () => this.openPause(), onHelp: () => this.screens.showHelp('battle'), }); this.session = session; this.screens.hide(); this.uiRoot.innerHTML = ''; this.gameRoot.innerHTML = ''; session.bindHud(this.uiRoot); this.bootScene(session); } private leaveLobby(): void { this.leaveRoom(); this.screens.showMenu(); this.clearRoomQuery(); } private leaveRoom(): void { if (this.net && !(this.session instanceof NetSession)) this.net.leave(); this.net = null; } private clearRoomQuery(): void { try { const url = new URL(window.location.href); if (!url.searchParams.has('room')) return; url.searchParams.delete('room'); url.searchParams.delete('token'); window.history.replaceState({}, '', url.pathname + url.search); } catch { /* history is optional */ } } // ------------------------------------------------------------------ shared scene boot private bootScene(session: MatchSession): void { const width = Math.max(320, this.gameRoot.clientWidth || window.innerWidth); const height = Math.max(240, this.gameRoot.clientHeight || window.innerHeight); try { const scene = new BattleScene(session); const game = createGame(scene, this.gameRoot, width, height); this.game = game; session.attach(game); scene.setEdgeScroll(this.settings.edgeScroll); this.applyAudioSettings(); } catch (err) { console.error('Failed to start the battle scene', err); this.screens.showMenu(); this.screens.toast('Your browser could not start the renderer: ' + String(err)); } } private onMatchEnd(result: MatchResult): void { if (this.session && !this.session.isPaused()) this.session.togglePause(); let summary: MatchQuestSummary | undefined; if (result.quest) { const quest = questById(result.quest.questId); if (quest) summary = this.applyQuest(quest, result); } this.screens.showEnd(result, summary); } private openPause(): void { const session = this.session; if (!session) return; if (!session.isPaused()) session.togglePause(); this.screens.showPause(session.speedLabel()); } private resume(): void { const session = this.session; if (!session) return; if (session.isPaused()) session.togglePause(); this.screens.hide(); } private quitToMenu(): void { const wasOnline = this.session instanceof NetSession; this.disposeMatch(); if (wasOnline) this.leaveLobby(); else { this.leaveRoom(); this.screens.showMenu(); } this.clearRoomQuery(); } private disposeMatch(): void { if (this.session) { this.session.destroy(); this.session = null; this.startingOnline = false; } if (this.game) { try { this.game.destroy(true); } catch { /* already gone */ } this.game = null; } this.uiRoot.innerHTML = ''; this.gameRoot.innerHTML = ''; } private resize(): void { const game = this.game; if (!game) return; const width = Math.max(320, this.gameRoot.clientWidth || window.innerWidth); const height = Math.max(240, this.gameRoot.clientHeight || window.innerHeight); game.scale.resize(width, height); } } const MAP_LABEL: Record = { grassland: 'Grassland', woodland: 'Woodland', riverland: 'Riverland', highlands: 'Highlands', }; function must(id: string): HTMLElement { const el = document.getElementById(id); if (!el) throw new Error('Missing #' + id + ' in index.html'); return el; } declare global { interface Window { alderyn?: { app: App }; } } const app = new App(); window.alderyn = { app }; export {};