diff --git a/AGENTS.md b/AGENTS.md index 842778e..54c4564 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -53,7 +53,7 @@ src/ AudioManager.js / sounds.js public/ # browser runtime assets only tests/ - game.spec.js / site.spec.js # 261 Playwright browser tests + game.spec.js / site.spec.js # 260+ Playwright browser tests ``` See [docs/reference-data.md](docs/reference-data.md) for sprite coordinates, wonder IDs, city screen layout, and map file format. diff --git a/README.md b/README.md index 18dbc86..a8f12d2 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ ownership attestation locally in your browser. - Original city, research, Civilopedia, throne-room, advisor, score, and map UI - Save/load, scenarios, diplomacy, research, combat, AI, Wonders, spaceship, government, trade, and late-game mechanics -- 261 Playwright browser regression tests +- 260+ Playwright browser regression tests - Production build deployable beneath the GitHub Pages `/civ2/` project path This is a playable public beta, not a claim that every undocumented MGE edge @@ -56,7 +56,11 @@ npm run preview:pages Choose **Game → Report Bug...** inside a running game. The app creates one ZIP containing a screenshot, a restorable game-state snapshot, the current camera -and open-screen state, and browser/viewport diagnostics. +and open-screen state, browser/viewport diagnostics, and a local traceback of +the last ten canvas clicks. Each traceback entry records the screen before and +after the click, pointer and map-tile coordinates where applicable, the active +unit/city context, and resolved menu action where available. It does not record +typed city names or transmit anything automatically. - **Download ZIP** saves the report locally. - **Share / Email** uses the browser or operating system share sheet when file diff --git a/index.html b/index.html index e5c2b2f..d861fb4 100644 --- a/index.html +++ b/index.html @@ -44,7 +44,7 @@

Build a civilization
to stand the test of time.

-
261browser tests
+
260+browser tests
59original movies supported
7world maps
BSD-3project code
diff --git a/src/audio/AudioManager.js b/src/audio/AudioManager.js index f9889c1..69eec7c 100644 --- a/src/audio/AudioManager.js +++ b/src/audio/AudioManager.js @@ -80,6 +80,8 @@ export class AudioManager { this._musicTrackIdx = 0; /** @type {boolean} */ this._musicReady = false; + this._musicRetryTimer = null; + this._musicFailureCount = 0; } _loadSettings() { @@ -267,7 +269,11 @@ export class AudioManager { * @param {number} [fadeOut=1000] fade-out duration in ms */ stopMusic(fadeOut = 1000) { - if (!this._musicSource || !this._musicGain || !this._ctx) return; + if (this._musicAudio) this._stopMusic(); + if (!this._musicSource || !this._musicGain || !this._ctx) { + this._currentMusic = null; + return; + } const gain = this._musicGain; const source = this._musicSource; @@ -287,7 +293,7 @@ export class AudioManager { } /** @returns {boolean} */ - get musicPlaying() { return this._currentMusic !== null; } + get musicPlaying() { return this._currentMusic !== null || this._musicAudio !== null; } /** @returns {string|null} */ get currentTrackName() { return this._currentMusic; } @@ -336,6 +342,7 @@ export class AudioManager { audio.addEventListener('canplaythrough', () => { if (this._musicAudio !== audio) return; + this._musicFailureCount = 0; audio.play().catch(err => { console.warn('AudioManager: failed to play track:', err.message); }); @@ -351,11 +358,17 @@ export class AudioManager { }); audio.addEventListener('error', (e) => { + if (this._musicAudio !== audio) return; console.warn(`AudioManager: error playing ${trackName}.mp3`, e); this._musicTrackIdx++; const tracks = MUSIC_TRACKS[this._currentEra]; - if (tracks) { - setTimeout(() => this._playMusicTrack(tracks[this._musicTrackIdx % tracks.length]), 1000); + this._musicFailureCount++; + if (tracks && this._musicFailureCount < tracks.length) { + clearTimeout(this._musicRetryTimer); + this._musicRetryTimer = setTimeout(() => { + this._musicRetryTimer = null; + if (this._musicAudio === audio) this._playMusicTrack(tracks[this._musicTrackIdx % tracks.length]); + }, 1000); } }); @@ -364,10 +377,14 @@ export class AudioManager { } _stopMusic() { + clearTimeout(this._musicRetryTimer); + this._musicRetryTimer = null; if (this._musicAudio) { - this._musicAudio.pause(); - this._musicAudio.src = ''; + const audio = this._musicAudio; this._musicAudio = null; + audio.pause(); + audio.removeAttribute('src'); + audio.load(); } } diff --git a/src/engine/Civ2SaveLoader.js b/src/engine/Civ2SaveLoader.js index 555fedf..11e61b8 100644 --- a/src/engine/Civ2SaveLoader.js +++ b/src/engine/Civ2SaveLoader.js @@ -124,16 +124,35 @@ export class Civ2SaveLoader { // Diplomatic relations (7 bytes × 8 civs, packed in 4-byte groups) const relations = new Map(); + const contacts = new Set(); for (let other = 0; other < 8; other++) { const t0 = b[base + 32 + 4 * other]; const t1 = b[base + 32 + 4 * other + 1]; + if (bit(t0, 0)) contacts.add(other); const isWar = bit(t1, 5); relations.set(other, isWar ? 'war' : 'peace'); } + const savedUnitTypeCount = isMGE ? 62 : 54; + const late = 216 + 3 * savedUnitTypeCount + 592; + const hasSpaceship = bit(b[base + late + 30], 0); + const spaceship = { + structural: u16(b, base + late + 38), + propulsion: u16(b, base + late + 40), + fuel: u16(b, base + late + 42), + habitation: u16(b, base + late + 44), + lifeSupport: u16(b, base + late + 46), + solar: u16(b, base + late + 48), + unassignedComponents: 0, + unassignedModules: 0, + launched: hasSpaceship, + launchYear: hasSpaceship ? u16(b, base + late + 34) : null, + arrivalYear: hasSpaceship ? u16(b, base + late + 32) : null, + }; + savCivs.push({ savId, tribeId, gold, beakers, currentResearch, - sciRate, taxRate, govtId, advances, relations, + sciRate, taxRate, govtId, advances, relations, contacts, spaceship, alive: civsInPlay[savId], }); } @@ -402,6 +421,10 @@ export class Civ2SaveLoader { civ.relations.set(civMapping.get(otherSavId), rel); } } + civ.contacts = new Set( + [...sd.contacts].filter(otherSavId => civMapping.has(otherSavId)).map(otherSavId => civMapping.get(otherSavId)) + ); + civ.spaceship = { ...civ.spaceship, ...sd.spaceship }; civArray[ourId] = civ; } gs.civs = civArray.filter(Boolean); diff --git a/src/engine/Civilization.js b/src/engine/Civilization.js index 58d493c..a211023 100644 --- a/src/engine/Civilization.js +++ b/src/engine/Civilization.js @@ -51,6 +51,24 @@ export class Civilization { /** Set of civIds with which this civ has established an embassy. */ this.embassies = new Set(); + /** Set of civIds this civilization has actually encountered. */ + this.contacts = new Set(); + + /** Civilization II spaceship construction and flight state. */ + this.spaceship = { + structural: 0, + propulsion: 0, + fuel: 0, + habitation: 0, + lifeSupport: 0, + solar: 0, + unassignedComponents: 0, + unassignedModules: 0, + launched: false, + launchYear: null, + arrivalYear: null, + }; + /** Custom leader name set by the player (null = use default from civs.js). */ this.leaderNameOverride = null; diff --git a/src/engine/GameState.js b/src/engine/GameState.js index ae7a2ca..41162c7 100644 --- a/src/engine/GameState.js +++ b/src/engine/GameState.js @@ -196,6 +196,9 @@ export class GameState { /** Whether the UN election (id=63) has already been called this game. */ this._unElectionUsed = false; + /** First spaceship launch changes the calendar to one year per turn. */ + this._spaceRaceLaunchTurn = null; + this._spaceRaceLaunchYear = null; /** * Territory history for end-game replay map. @@ -233,11 +236,28 @@ export class GameState { /** Game options — persisted in save data. */ this._gameOptions = { - animations: true, - autoSave: true, + music: true, + soundEffects: true, + alwaysWait: true, + showEnemyMoves: true, + noPauseEnemyMoves: false, + fastPieceSlide: false, + instantAdvice: false, tutorialHelp: false, - productionAlerts: true, - endOfTurnMessages: true, + enterAdvances: true, + showMovePaths: true, + autoSave: true, + }; + this._graphicOptions = { + throneRoom: true, diplomacyScreen: true, animatedHeralds: true, + civilopediaAdvances: true, highCouncil: true, wonderMovies: true, + hdSprites: false, + }; + this._cityReportOptions = { + warnBuildCity: true, showCityImprovements: true, showNonCombatUnits: true, + showInitialBuildInstructions: true, announceDiscoveries: true, + announceOrder: true, announceWeLoveKing: true, warnFoodDecrease: true, + warnPollution: true, warnChangingProduction: true, zoomToCombat: true, }; /** Casualty log: each entry { turn, unitTypeId, defenderCivId, killerCivId }. */ @@ -655,7 +675,12 @@ export class GameState { } } - get year() { return this._gameYear(this.turn); } + get year() { + if (this._spaceRaceLaunchTurn != null && this._spaceRaceLaunchYear != null) { + return this._spaceRaceLaunchYear + Math.max(0, this.turn - this._spaceRaceLaunchTurn); + } + return this._gameYear(this.turn); + } // ─── Visibility ──────────────────────────────────────────────────────────── // (Moved to MapLogic.js mixin) @@ -788,6 +813,11 @@ export class GameState { this.activeUnit = reactivated[0]; } } + if (civIdx === 0 && !this.activeUnit && this._gameOptions?.alwaysWait === false) { + queueMicrotask(() => { + if (!this.gameOver && this.activeCivIdx === 0 && !this.activeUnit) this.endTurn(); + }); + } } /** Barbarian AI: no city production, no diplomacy — just move units to attack. */ @@ -1768,6 +1798,9 @@ export class GameState { if (!candidates.length) return; const pick = candidates[Math.floor(this.rng() * candidates.length)]; this._tileImprovements[pick.r][pick.c].pollution = true; + if (city.civId === 0 && this._cityReportOptions?.warnPollution !== false) { + this._emit('pollutionCreated', { city, col: pick.c, row: pick.r }); + } } /** Check total pollution count and potentially degrade terrain. */ @@ -2004,10 +2037,10 @@ export class GameState { // Infrastructure limits: Aqueduct needed above size 8, Sewer above size 12 if (city.size >= COSMIC.sewerLimit && !city.improvements.has(23)) { city.food = foodNeeded - 1; - this._addLog(`${city.name} needs a Sewer System to grow!`); + if (this._cityReportOptions?.warnBuildCity !== false) this._addLog(`${city.name} needs a Sewer System to grow!`); } else if (city.size >= COSMIC.aqueductLimit && !city.improvements.has(9)) { city.food = foodNeeded - 1; - this._addLog(`${city.name} needs an Aqueduct to grow!`); + if (this._cityReportOptions?.warnBuildCity !== false) this._addLog(`${city.name} needs an Aqueduct to grow!`); } else { const hasGranary = city.improvements.has(3) || this._civHasWonder(city.civId, 39); city.food = hasGranary ? Math.floor(foodNeeded / 2) : 0; @@ -2017,6 +2050,9 @@ export class GameState { this._emit('cityGrowth', { city }); } } else if (city.food < 0) { + if (city.civId === 0 && this._cityReportOptions?.warnFoodDecrease !== false) { + this._emit('foodWarning', { city }); + } if (city.size > 1) { city.size--; this._autoRemoveWorker(city); @@ -2040,7 +2076,9 @@ export class GameState { const next = queued ?? (same.length ? same.reduce((a, b) => this._productionCost(a) < this._productionCost(b) ? a : b) : avail[0] || null); if (next) { city.production = { type: next.type, id: next.id }; - this._addLog(`${city.name}: switched to ${next.name || 'new item'}`); + if (this._cityReportOptions?.showInitialBuildInstructions !== false) { + this._addLog(`${city.name}: switched to ${next.name || 'new item'}`); + } } else { city.production = null; } @@ -2577,6 +2615,21 @@ export class GameState { // SS parts (35-37) are stackable — track in ssParts, not improvements if (id === 35 || id === 36 || id === 37) { city.ssParts[id] = (city.ssParts[id] ?? 0) + 1; + const ship = this.civs[city.civId]?.spaceship; + if (ship) { + if (id === 35) ship.structural++; + if (id === 36) ship.unassignedComponents++; + if (id === 37) ship.unassignedModules++; + } + if (city.civId !== 0 && id === 36) { + const progress = this.spaceshipProgress(city.civId); + this.assignSpaceshipPart(city.civId, progress.propulsion <= progress.fuel ? 'propulsion' : 'fuel'); + } + if (city.civId !== 0 && id === 37) { + const progress = this.spaceshipProgress(city.civId); + const system = ['habitation', 'lifeSupport', 'solar'].sort((a, b) => progress[a] - progress[b])[0]; + this.assignSpaceshipPart(city.civId, system); + } } else { city.improvements.add(id); } @@ -2608,7 +2661,7 @@ export class GameState { for (const adv of picked) { civ.advances.add(adv.id); this._addLog(`Darwin's Voyage: ${civ?.data?.adjective ?? ''} discovers ${adv.name}!`); - this._emit('advance', { civ, advId: adv.id }); + this._emit('advance', { civ, civId: civ.id, advId: adv.id }); } if (civ.currentResearch && civ.advances.has(civ.currentResearch)) { civ.currentResearch = null; @@ -2641,6 +2694,7 @@ export class GameState { for (const other of this.civs) { if (!other || !other.alive || other.id === city.civId) continue; ownerCiv.embassies.add(other.id); + this.establishContact(city.civId, other.id); } this._addLog(`Marco Polo's Embassy establishes relations with all civilizations!`); } @@ -2663,13 +2717,18 @@ export class GameState { } } + const completed = { type, id }; const nextQueued = this._dequeueNextProduction(city); if (nextQueued) { city.production = { type: nextQueued.type, id: nextQueued.id }; } else if (city.civId !== 0) { this._aiPickProduction(city); } else { - city.production = null; + // Civ2 repeats the completed item until the player chooses a new one. + const available = this.availableProduction(city); + city.production = available.some(p => p.type === type && p.id === id) + ? completed + : (available[0] ? { type: available[0].type, id: available[0].id } : null); } return true; } @@ -2860,7 +2919,7 @@ export class GameState { if (advId === 100) { civ.futureTechCount++; this._addLog(`${CIVS[civ.id]?.adjective ?? 'Player'} discovers Future Technology ${civ.futureTechCount}!`); - this._emit('advance', { civ, advId: 100 }); + this._emit('advance', { civ, civId: civ.id, advId: 100 }); civ.currentResearch = 100; // auto-continue researching future tech return; } @@ -2870,7 +2929,7 @@ export class GameState { civ.advances.add(advId); if (civ.researchGoal === advId) civ.researchGoal = null; this._addLog(`${CIVS[civ.id]?.adjective ?? 'Player'} discovers ${adv?.name ?? '?'}!`); - this._emit('advance', { civ, advId }); + this._emit('advance', { civ, civId: civ.id, advId }); civ.currentResearch = null; // Scenario event: received technology @@ -2926,7 +2985,7 @@ export class GameState { if (knownByCount >= 2) { civ.advances.add(advId); this._addLog(`The Great Library grants ${ADVANCES[advId]?.name ?? '?'} to ${CIVS[civ.id]?.adjective ?? 'the player'}!`); - this._emit('advance', { civ, advId }); + this._emit('advance', { civ, civId: civ.id, advId }); if (civ.currentResearch === advId) civ.currentResearch = null; } } @@ -2997,11 +3056,33 @@ export class GameState { // ─── Diplomacy ───────────────────────────────────────────────────────────── + hasContact(civA, civB) { + if (civA === civB) return true; + return !!this.civs[civA]?.contacts?.has(civB); + } + + establishContact(civA, civB) { + if (civA === civB || civA === this.barbarianCivIdx || civB === this.barbarianCivIdx) return false; + const a = this.civs[civA]; + const b = this.civs[civB]; + if (!a || !b) return false; + const isNew = !a.contacts.has(civB) || !b.contacts.has(civA); + a.contacts.add(civB); + b.contacts.add(civA); + if (isNew && (civA === 0 || civB === 0)) { + const other = civA === 0 ? b : a; + this._addLog(`Contact established with the ${other.data?.plural ?? 'foreign civilization'}.`); + this._emit('firstContact', { civId: other.id }); + } + return isNew; + } + isAtWar(civA, civB) { return this.civs[civA]?.relations.get(civB) === 'war'; } _declareWarInternal(civA, civB) { + this.establishContact(civA, civB); if (this.isAtWar(civA, civB)) return; this.civs[civA]?.relations.set(civB, 'war'); this.civs[civB]?.relations.set(civA, 'war'); @@ -3536,7 +3617,7 @@ export class GameState { myCiv.advances.add(stolenId); const advName = ADVANCES[stolenId]?.name ?? `Advance ${stolenId}`; this._addLog(`Stole ${advName} from the ${theirCiv.data?.plural ?? 'enemy'}!`); - this._emit('advance', { civ: myCiv, advId: stolenId }); + this._emit('advance', { civ: myCiv, civId: myCiv.id, advId: stolenId }); // Diplomat always consumed; Spy has 50% survival chance if (uData.id === 47 && this.rng() < 0.5) { @@ -4788,6 +4869,7 @@ export class GameState { ); this._worldPeaceTurns = worldAtPeace ? (this._worldPeaceTurns ?? 0) + 1 : 0; this._processTurn(); + this._emit('turnStart', { turn: this.turn, year: this.year }); } this._beginCivTurn(this.activeCivIdx); if (this.activeCivIdx !== 0) this._doAiTurn(this.activeCivIdx); @@ -4902,40 +4984,6 @@ export class GameState { return; } - // UN Diplomatic Victory: if an AI civ built the UN and calls an election and wins, - // that triggers a loss for the human. The AI auto-calls the election when building. - // (Human election is triggered manually via proposeUnElection.) - // Check if any AI civ holds the UN and the election hasn't happened yet. - if (!this._unElectionUsed) { - for (const civ of this.civs.slice(1)) { - if (!civ || !civ.alive || civ.id === this.barbarianCivIdx) continue; - if (!this._civHasWonder(civ.id, 63)) continue; - // AI built UN: auto-call election - this._unElectionUsed = true; - const livingAI = this.civs.slice(1).filter(c => c && c.alive && c.id !== this.barbarianCivIdx && c.id !== civ.id); - const humanCiv = this.civs[0]; - let forVotes = 0, againstVotes = 0; - for (const voter of livingAI) { - if ((voter.attitude.get(civ.id) ?? 0) >= 0) forVotes++; - else againstVotes++; - } - // Human player also votes — negative attitude means voting against AI - const humanAtt = humanCiv ? (humanCiv.attitude.get(civ.id) ?? 0) : 0; - if (humanAtt >= 0) forVotes++; else againstVotes++; - const won = forVotes > againstVotes; - if (won) { - this._addLog(`${civ.data.plural} win the UN election and become World Leader!`); - this.gameOver = { result: 'diplomatic-lose', score: this.score() }; - this._emit('gameOver', this.gameOver); - this._emit('unElection', { won: true, winner: civ.id }); - return; - } else { - this._addLog(`${civ.data.plural} called a UN election but failed to gain majority.`); - this._emit('unElection', { won: false, winner: civ.id }); - } - } - } - // Elimination victory: all AI civs extinct (no cities, no units) // Barbarians are excluded — you don't need to eliminate them to win const allAiDead = this.civs.slice(1).every(civ => { @@ -4950,19 +4998,19 @@ export class GameState { return; } - // Space race: check if any civ has met the spaceship quota + // Space race: AI launches a viable ship; victory occurs only on arrival. if (this._apolloBuilt && !this.gameOver) { - // Check AI civs first (if AI wins, human loses) for (const civ of this.civs.slice(1)) { - if (!civ.alive) continue; - if (this.spaceshipReady(civ.id)) { - this.launchSpaceship(civ.id); - return; - } + if (!civ.alive || civ.id === this.barbarianCivIdx) continue; + if (!civ.spaceship?.launched && this.spaceshipReady(civ.id)) this.launchSpaceship(civ.id); } - // Check human - if (this.spaceshipReady(0)) { - this.launchSpaceship(0); + + const arrived = this.civs + .filter(c => c?.alive && c.id !== this.barbarianCivIdx && c.spaceship?.launched && c.spaceship.arrivalYear <= this.year) + .sort((a, b) => a.spaceship.arrivalYear - b.spaceship.arrivalYear || a.id - b.id)[0]; + if (arrived) { + this.gameOver = { result: arrived.id === 0 ? 'space-win' : 'space-lose', score: this.score() }; + this._emit('gameOver', this.gameOver); return; } } @@ -5466,7 +5514,7 @@ export class GameState { declareWar(targetCivId) { const player = this.civs[0]; const target = this.civs[targetCivId]; - if (!player || !target || !target.alive) return; + if (!player || !target || !target.alive || !this.hasContact(0, targetCivId)) return; // Reputation penalty for breaking existing treaties const prevRel = player.relations.get(targetCivId) ?? 'peace'; @@ -5497,7 +5545,7 @@ export class GameState { proposePeace(targetCivId) { const player = this.civs[0]; const target = this.civs[targetCivId]; - if (!player || !target || !target.alive) return false; + if (!player || !target || !target.alive || !this.hasContact(0, targetCivId)) return false; const rel = player.relations.get(targetCivId) ?? 'peace'; if (rel === 'peace' || rel === 'alliance') return false; // already at peace @@ -5527,7 +5575,7 @@ export class GameState { proposeCeasefire(targetCivId) { const player = this.civs[0]; const target = this.civs[targetCivId]; - if (!player || !target || !target.alive) return false; + if (!player || !target || !target.alive || !this.hasContact(0, targetCivId)) return false; const rel = player.relations.get(targetCivId) ?? 'peace'; if (rel !== 'war') return false; // ceasefire only from war @@ -5555,7 +5603,7 @@ export class GameState { proposeAlliance(targetCivId) { const player = this.civs[0]; const target = this.civs[targetCivId]; - if (!player || !target || !target.alive) return false; + if (!player || !target || !target.alive || !this.hasContact(0, targetCivId)) return false; const rel = player.relations.get(targetCivId) ?? 'peace'; if (rel === 'war' || rel === 'ceasefire') return false; @@ -5585,7 +5633,7 @@ export class GameState { payTribute(targetCivId, amount) { const player = this.civs[0]; const target = this.civs[targetCivId]; - if (!player || !target || !target.alive) return false; + if (!player || !target || !target.alive || !this.hasContact(0, targetCivId)) return false; if (player.gold < amount) return false; player.gold -= amount; @@ -5597,47 +5645,9 @@ export class GameState { return true; } - /** - * Propose a UN election. The human must own the United Nations (id=63). - * Each living AI civ votes: +1 for owner if attitude >= 0, -1 against. - * Barbarians don't vote. Owner wins if votes_for > votes_against. - * Can only be called once per game (one election per UN build). - * - * Returns an object: { eligible, alreadyUsed, votes, forVotes, againstVotes, won } - */ + /** Civ2 MGE has no diplomatic-victory election. Kept as a safe legacy API. */ proposeUnElection() { - const ownsUN = this._civHasWonder(0, 63); - if (!ownsUN) return { eligible: false }; - if (this._unElectionUsed) return { eligible: true, alreadyUsed: true }; - - this._unElectionUsed = true; - const player = this.civs[0]; - - let forVotes = 0; - let againstVotes = 0; - const votes = []; - const livingAI = this.civs.slice(1).filter(c => c && c.alive && c.id !== this.barbarianCivIdx); - - for (const civ of livingAI) { - const att = civ.attitude.get(0) ?? 0; - const votes_for = att >= 0; - if (votes_for) forVotes++; - else againstVotes++; - votes.push({ civId: civ.id, name: civ.data.plural, attitude: att, for: votes_for }); - } - - const won = forVotes > againstVotes && livingAI.length > 0; - - if (won) { - this._addLog(`UN Election: The world votes you World Leader! Diplomatic Victory!`); - this.gameOver = { result: 'diplomatic-win', score: this.score() }; - this._emit('gameOver', this.gameOver); - } else { - this._addLog(`UN Election: The world rejects your candidacy (${forVotes} for, ${againstVotes} against).`); - } - - this._emit('unElection', { won, forVotes, againstVotes, votes }); - return { eligible: true, alreadyUsed: false, votes, forVotes, againstVotes, won }; + return { eligible: false, unsupported: true }; } /** @@ -5650,7 +5660,7 @@ export class GameState { offerTechTrade(targetCivId, myAdvId, theirAdvId) { const player = this.civs[0]; const target = this.civs[targetCivId]; - if (!player || !target || !target.alive) return false; + if (!player || !target || !target.alive || !this.hasContact(0, targetCivId)) return false; const accepted = this.aiWillAccept(targetCivId, 'techTrade', { myAdvId, theirAdvId }); if (!accepted) { @@ -5676,7 +5686,7 @@ export class GameState { shareMap(targetCivId) { const player = this.civs[0]; const target = this.civs[targetCivId]; - if (!player || !target || !target.alive) return false; + if (!player || !target || !target.alive || !this.hasContact(0, targetCivId)) return false; this.adjustAttitude(0, targetCivId, 5); this._addLog(`Shared maps with the ${target.data.plural}.`); @@ -5686,12 +5696,17 @@ export class GameState { // ─── Space Race ──────────────────────────────────────────────────────────── - /** - * Count how many SS parts a civ has built across all its cities. - * Returns { structural, component, module }. - * Quota: 8 structural + 4 component + 4 module. - */ + /** Return the detailed MGE spaceship state, including unassigned parts. */ spaceshipProgress(civIdx) { + const civ = this.civs[civIdx]; + if (!civ) return null; + const ship = civ.spaceship ?? (civ.spaceship = { + structural: 0, propulsion: 0, fuel: 0, habitation: 0, lifeSupport: 0, + solar: 0, unassignedComponents: 0, unassignedModules: 0, + launched: false, launchYear: null, arrivalYear: null, + }); + + // Reconcile old saves that only stored aggregate city part counts. let structural = 0, component = 0, module = 0; for (const city of this.cities) { if (city.civId !== civIdx) continue; @@ -5699,26 +5714,71 @@ export class GameState { component += city.ssParts?.[36] ?? 0; module += city.ssParts?.[37] ?? 0; } - return { structural, component, module }; + ship.structural = Math.max(ship.structural ?? 0, structural); + const assignedComponents = (ship.propulsion ?? 0) + (ship.fuel ?? 0); + const assignedModules = (ship.habitation ?? 0) + (ship.lifeSupport ?? 0) + (ship.solar ?? 0); + ship.unassignedComponents = Math.max(ship.unassignedComponents ?? 0, component - assignedComponents); + ship.unassignedModules = Math.max(ship.unassignedModules ?? 0, module - assignedModules); + component = Math.max(component, assignedComponents + ship.unassignedComponents); + module = Math.max(module, assignedModules + ship.unassignedModules); + return { ...ship, component, module }; + } + + assignSpaceshipPart(civIdx, system) { + const ship = this.spaceshipProgress(civIdx); + if (!ship || ship.launched) return false; + const component = system === 'propulsion' || system === 'fuel'; + const module = system === 'habitation' || system === 'lifeSupport' || system === 'solar'; + if ((!component && !module) || ship[system] >= (component ? 8 : 4)) return false; + const source = component ? 'unassignedComponents' : 'unassignedModules'; + const state = this.civs[civIdx].spaceship; + if (state[source] <= 0) return false; + state[source]--; + state[system]++; + return true; } - /** Returns true if a civ meets the quota to launch their spaceship. */ - spaceshipReady(civIdx) { + spaceshipStats(civIdx) { const p = this.spaceshipProgress(civIdx); - return p.structural >= 8 && p.component >= 4 && p.module >= 4; + if (!p) return null; + const pairs = Math.min(p.propulsion, p.fuel); + const supported = Math.min(p.habitation, p.lifeSupport, p.solar); + const population = p.habitation * 10000; + const support = p.habitation > 0 ? Math.min(100, Math.round(p.lifeSupport / p.habitation * 100)) : 0; + const energy = (p.habitation + p.lifeSupport) > 0 + ? Math.min(100, Math.round((p.solar * 2) / (p.habitation + p.lifeSupport) * 100)) : 0; + const connected = p.structural >= 15; + const success = connected && pairs > 0 && supported > 0 ? Math.min(support, energy) : 0; + const fusion = this.civs[civIdx]?.advances?.has(32); + const baseYears = fusion ? 36.1 : 48.4; + const massFactor = Math.sqrt(Math.max(1, (p.structural + p.component * 2 + p.module * 4) / 23)); + const flightYears = pairs > 0 ? Math.max(1, Math.round((baseYears * massFactor / Math.pow(pairs, 0.75)) * 10) / 10) : Infinity; + return { ...p, population, support, energy, success, flightYears, connected }; + } + + /** A viable ship needs connected structure and one of every working system. */ + spaceshipReady(civIdx) { + const p = this.spaceshipStats(civIdx); + return !!p && !p.launched && p.connected && p.propulsion >= 1 && p.fuel >= 1 && + p.habitation >= 1 && p.lifeSupport >= 1 && p.solar >= 1 && p.success > 0; } - /** Launch the spaceship for the given civ, triggering the appropriate victory/defeat. */ + /** Launch begins the journey; victory is awarded only on Alpha Centauri arrival. */ launchSpaceship(civIdx) { - if (this.gameOver) return; - if (civIdx === 0) { - this.gameOver = { result: 'space-win', score: this.score() }; - } else { - // AI civ wins — human loses (unless human already launched) - this.gameOver = { result: 'space-lose', score: this.score() }; - } - this._emit('gameOver', this.gameOver); - this._emit('spaceshipLaunched', { civIdx }); + if (this.gameOver || !this.spaceshipReady(civIdx)) return false; + const stats = this.spaceshipStats(civIdx); + const ship = this.civs[civIdx].spaceship; + const launchYear = this.year; + ship.launched = true; + ship.launchYear = launchYear; + ship.arrivalYear = launchYear + Math.ceil(stats.flightYears); + if (this._spaceRaceLaunchTurn == null) { + this._spaceRaceLaunchTurn = this.turn; + this._spaceRaceLaunchYear = launchYear; + } + this._addLog(`${this.civs[civIdx].data?.plural ?? 'A civilization'} launch a spaceship! Estimated arrival: ${ship.arrivalYear}.`); + this._emit('spaceshipLaunched', { civIdx, launchYear, arrivalYear: ship.arrivalYear }); + return true; } // ─── Unit Upgrades ───────────────────────────────────────────────────────── @@ -5810,6 +5870,8 @@ export class GameState { apolloBuilt: this._apolloBuilt, manhattanBuilt: this._manhattanBuilt, unElectionUsed: this._unElectionUsed, + spaceRaceLaunchTurn: this._spaceRaceLaunchTurn, + spaceRaceLaunchYear: this._spaceRaceLaunchYear, tiles: this.tiles.map(row => row.map(t => t?.id ?? 0)), resources: this._resources.map(row => Array.from(row)), @@ -5840,6 +5902,8 @@ export class GameState { attitude: [...civ.attitude.entries()], reputation: civ.reputation, embassies: [...civ.embassies], + contacts: [...civ.contacts], + spaceship: { ...civ.spaceship }, futureTechCount: civ.futureTechCount ?? 0, })), @@ -5898,6 +5962,8 @@ export class GameState { maxTurns: this._maxTurns ?? 0, scenarioEvents: this._scenarioEvents ?? [], gameOptions: { ...(this._gameOptions ?? {}) }, + graphicOptions: { ...(this._graphicOptions ?? {}) }, + cityReportOptions: { ...(this._cityReportOptions ?? {}) }, casualties: [...(this._casualties ?? [])], powerHistory: [...(this._powerHistory ?? [])], }; @@ -5959,6 +6025,8 @@ export class GameState { gs._apolloBuilt = data.apolloBuilt ?? false; gs._manhattanBuilt = data.manhattanBuilt ?? false; gs._unElectionUsed = data.unElectionUsed ?? false; + gs._spaceRaceLaunchTurn = data.spaceRaceLaunchTurn ?? null; + gs._spaceRaceLaunchYear = data.spaceRaceLaunchYear ?? null; gs.onEvent = null; // ── Civilizations ─────────────────────────────────────────────────────── @@ -5989,6 +6057,8 @@ export class GameState { civ.attitude = new Map((cd.attitude ?? []).map(([k, v]) => [parseInt(k, 10), v])); civ.reputation = cd.reputation ?? 50; civ.embassies = new Set(cd.embassies ?? []); + civ.contacts = new Set(cd.contacts ?? []); + civ.spaceship = { ...civ.spaceship, ...(cd.spaceship ?? {}) }; civ.futureTechCount = cd.futureTechCount ?? 0; return civ; }); @@ -6059,12 +6129,23 @@ export class GameState { gs._scenarioName = data.scenarioName ?? null; gs._maxTurns = data.maxTurns ?? 0; gs._scenarioEvents = data.scenarioEvents ?? []; - gs._gameOptions = data.gameOptions ?? { - animations: true, - autoSave: true, - tutorialHelp: false, - productionAlerts: true, - endOfTurnMessages: true, + gs._gameOptions = { + music: true, soundEffects: true, alwaysWait: true, showEnemyMoves: true, + noPauseEnemyMoves: false, fastPieceSlide: false, instantAdvice: false, + tutorialHelp: false, enterAdvances: true, showMovePaths: true, autoSave: true, + ...(data.gameOptions ?? {}), + }; + gs._graphicOptions = { + throneRoom: true, diplomacyScreen: true, animatedHeralds: true, + civilopediaAdvances: true, highCouncil: true, wonderMovies: true, + hdSprites: false, ...(data.graphicOptions ?? {}), + }; + gs._cityReportOptions = { + warnBuildCity: true, showCityImprovements: true, showNonCombatUnits: true, + showInitialBuildInstructions: true, announceDiscoveries: true, + announceOrder: true, announceWeLoveKing: true, warnFoodDecrease: true, + warnPollution: true, warnChangingProduction: true, zoomToCombat: true, + ...(data.cityReportOptions ?? {}), }; gs._casualties = data.casualties ?? []; gs._powerHistory = data.powerHistory ?? []; diff --git a/src/engine/mixins/MapLogic.js b/src/engine/mixins/MapLogic.js index c158123..82249e5 100644 --- a/src/engine/mixins/MapLogic.js +++ b/src/engine/mixins/MapLogic.js @@ -184,6 +184,18 @@ export function applyMapLogicMixin(GameState) { if (city.civId !== 0) continue; for (const t of this._tilesInRange(city.col, city.row, 2)) reveal(t.col, t.row); } + + // Diplomacy starts only after an opposing unit or city is encountered. + for (const unit of this.units) { + if (unit.civId !== 0 && vis[unit.row]?.[unit.col] === 2) { + this.establishContact?.(0, unit.civId); + } + } + for (const city of this.cities) { + if (city.civId !== 0 && vis[city.row]?.[city.col] === 2) { + this.establishContact?.(0, city.civId); + } + } }; GameState.prototype._landTiles = function() { diff --git a/src/render/MapRenderer.js b/src/render/MapRenderer.js index da516a3..b3e807f 100644 --- a/src/render/MapRenderer.js +++ b/src/render/MapRenderer.js @@ -26,6 +26,7 @@ import { applyCityScreenMixin } from './mixins/CityScreenMixin.js'; import { applySidebarMixin } from './mixins/SidebarMixin.js'; import { applyTerrainMixin } from './mixins/TerrainMixin.js'; import { applyBugReportMixin } from './mixins/BugReportMixin.js'; +import { applyInteractionTraceMixin } from './mixins/InteractionTraceMixin.js'; import { GameState } from '../engine/GameState.js'; import { Civ2SaveLoader } from '../engine/Civ2SaveLoader.js'; import { MapLoader } from '../engine/MapLoader.js'; @@ -189,6 +190,7 @@ export class MapRenderer { this.sprites = spriteManager; this.gameState = gameState; this.audio = audio; + this._initInteractionTrace(); this.mapCols = gameState.mapCols; this.mapRows = gameState.mapRows; @@ -245,6 +247,7 @@ export class MapRenderer { this._cityScreenNavRects = []; this._cityScreenQueueAddMode = false; this._cityScreenProductionSelection = null; + this._pendingProductionChange = null; this._shiftHeld = false; // Unit action menu state @@ -375,6 +378,7 @@ export class MapRenderer { this._menuItemRects = []; // [{x,y,w,h,action,disabled}] — current dropdown this._menuHoverIdx = null; // index into _menuItemRects this._bugReportDialog = null; + this._editorTargetTile = null; // Title screen state this._titleScreen = false; @@ -405,6 +409,7 @@ export class MapRenderer { this._throneCloseRect = null; this._throneUpgradeDialog = false; // show decoration choice dialog this._throneUpgradeRects = []; // clickable category buttons + this._pendingThroneUpgradeDialog = false; // End-game replay map this._replayMap = false; @@ -422,6 +427,7 @@ export class MapRenderer { this._graphicOptionsRects = []; this._cityReportOptionsDialog = false; this._cityReportOptionsRects = []; + this._optionsDrafts = {}; // Casualty Timeline dialog this._casualtyDialog = false; @@ -491,7 +497,7 @@ export class MapRenderer { // ─── Audio helper ────────────────────────────────────────────────────────── _play(sfx) { - if (!this.audio || !sfx) return; + if (!this.audio || !sfx || this.gameState?._gameOptions?.soundEffects === false) return; if (Array.isArray(sfx)) this.audio.playRandom(sfx); else this.audio.play(sfx); } @@ -531,20 +537,23 @@ export class MapRenderer { } case 'production': if (data.type === 'improvement') { - this._play(improvementSoundFor(data.id, data.improvData)); + if (data.improvData?.isWonder || this.gameState._cityReportOptions?.showCityImprovements !== false) { + this._play(improvementSoundFor(data.id, data.improvData)); + } // Show wonder splash for human player's wonders if (data.improvData?.isWonder && data.city?.civId === 0) { this._wonderSplash = { name: data.improvData.name, city: data.city.name, id: data.id }; - this._startWonderVideo(data.id); + if (this.gameState._graphicOptions?.wonderMovies !== false) this._startWonderVideo(data.id); } } else { - this._play(SFX.pos); + const unit = UNITS[data.id]; + if ((unit?.attack ?? 0) > 0 || this.gameState._cityReportOptions?.showNonCombatUnits !== false) this._play(SFX.pos); } break; case 'advance': this._play(advanceFanfare(ADVANCES[data.advId])); // Show tech discovery popup for human player (matches original Civ2 MGE) - if (data.civId === 0 && ADVANCES[data.advId]) { + if (data.civId === 0 && ADVANCES[data.advId] && this.gameState._graphicOptions?.civilopediaAdvances !== false) { const civAdj = this.gameState.civs[0]?.data?.adjective ?? ''; this._advancePopup = { advId: data.advId, advName: ADVANCES[data.advId].name, civAdj }; } @@ -556,7 +565,11 @@ export class MapRenderer { } break; case 'cityDisorder': - this._play(SFX.cityDisorder); + if (this.gameState._cityReportOptions?.announceDiscoveries !== false) this._play(SFX.cityDisorder); + break; + case 'foodWarning': + case 'pollutionCreated': + this._play(SFX.feedbackWarn); break; case 'cityCapture': this._captureDialog = { city: data.city }; @@ -576,6 +589,7 @@ export class MapRenderer { break; case 'spaceshipLaunched': this._play(SFX.pos); + if (data.civIdx === 0) this._playEventVideo('LAUNCH.webm'); break; case 'buildComplete': // Builder (settler/engineer) finishes road/irrigation/mine/fortress/etc. @@ -601,7 +615,8 @@ export class MapRenderer { const vis = this.gameState._visibility; const fromVis = vis?.[data.fromRow]?.[data.fromCol] ?? 0; const toVis = vis?.[data.toRow]?.[data.toCol] ?? 0; - if (fromVis === 2 || toVis === 2) { + const showEnemy = this.gameState._gameOptions?.showEnemyMoves !== false; + if ((data.unit.civId === 0 || showEnemy) && (fromVis === 2 || toVis === 2)) { this._startMoveAnim(data.unit, data.fromCol, data.fromRow, data.toCol, data.toRow); } break; @@ -620,7 +635,7 @@ export class MapRenderer { } // ── Newly wired events ────────────────────────────────────────────── case 'orderRestored': - this._play(SFX.pos); + if (this.gameState._cityReportOptions?.announceOrder !== false) this._play(SFX.pos); break; case 'improvementSold': this._play(SFX.sell); @@ -681,10 +696,8 @@ export class MapRenderer { date: new Date().toISOString().slice(0, 10), }); if (go.result === 'space-win') { - this._playEventVideo('LAUNCH.webm', () => { - this._playEventVideo('WINWIN.webm'); - }); - } else if (go.result === 'win' || go.result === 'score-win' || go.result === 'diplomatic-win') { + this._playEventVideo('WINWIN.webm'); + } else if (go.result === 'win' || go.result === 'score-win') { this._playEventVideo('WINWIN.webm'); } else { this._playEventVideo('LOSER.webm'); @@ -701,25 +714,29 @@ export class MapRenderer { break; } case 'weLoveKing': - this._play(SFX.cheers); + if (this.gameState._cityReportOptions?.announceWeLoveKing !== false) this._play(SFX.cheers); break; case 'throneUpgrade': - // Show decoration choice dialog after a brief delay (let wonder video play first) - this._throneUpgradeDialog = true; - this._throneUpgradeRects = []; + if (this.gameState._graphicOptions?.throneRoom === false) break; + if (this._wonderSplash) { + this._pendingThroneUpgradeDialog = true; + } else { + this._throneUpgradeDialog = true; + this._throneUpgradeRects = []; + } break; case 'rushBuy': this._play(SFX.stockMarket); break; + case 'turnStart': + if (this.gameState._gameOptions?.autoSave !== false) this._autoSaveGame(); + break; case 'pillage': this._play(SFX.explSmall); break; case 'airlift': this._play(SFX.pos); break; - case 'unElection': - this._play(SFX.pompCirc); - break; case 'cityGovernorChanged': this._play(SFX.menuOk); break; @@ -732,7 +749,11 @@ export class MapRenderer { this._moveAnim = { unit, fromCol, fromRow, toCol, toRow, elapsed: 0, - duration: this._MOVE_ANIM_DURATION, + duration: unit.civId !== 0 && this.gameState._gameOptions?.noPauseEnemyMoves + ? 1 + : this.gameState._gameOptions?.fastPieceSlide + ? Math.round(this._MOVE_ANIM_DURATION / 2) + : this._MOVE_ANIM_DURATION, }; // Record a dust trail at the source tile if (!this._moveTrails) this._moveTrails = []; @@ -991,7 +1012,7 @@ export class MapRenderer { // Dialogs & choosers if (this._advancePopup) { this._advancePopup = null; return true; } - if (this._researchChooser) { + if (this._researchChooser && !this._wonderSplash && !this._throneUpgradeDialog) { this._researchChooser = false; this._researchGoalDialog = false; this._researchGoalCandidates = null; @@ -1006,7 +1027,7 @@ export class MapRenderer { if (this._diplomacyScreen) { this._diplomacyScreen = false; return true; } if (this._aiPeaceProposal) { this._aiPeaceProposal = null; return true; } if (this._highCouncil) { this._stopCouncilVideo(); this._highCouncil = false; return true; } - if (this._wonderSplash) { this._stopWonderVideo(); this._wonderSplash = null; return true; } + if (this._wonderSplash) { this._dismissWonderSplash(); return true; } if (this._scenarioCivChooser) { this._scenarioCivChooser = false; this._scenarioPending = null; return true; } // Advisors & info screens @@ -1020,9 +1041,9 @@ export class MapRenderer { if (this._top5Cities) { this._top5Cities = false; return true; } if (this._hallOfFame) { this._hallOfFame = false; return true; } if (this._wondersList) { this._wondersList = false; return true; } - if (this._gameOptionsDialog) { this._gameOptionsDialog = false; return true; } - if (this._graphicOptionsDialog) { this._graphicOptionsDialog = false; return true; } - if (this._cityReportOptionsDialog) { this._cityReportOptionsDialog = false; return true; } + if (this._gameOptionsDialog) { this._gameOptionsDialog = false; delete this._optionsDrafts._gameOptions; return true; } + if (this._graphicOptionsDialog) { this._graphicOptionsDialog = false; delete this._optionsDrafts._graphicOptions; return true; } + if (this._cityReportOptionsDialog) { this._cityReportOptionsDialog = false; delete this._optionsDrafts._cityReportOptions; return true; } if (this._casualtyDialog) { this._casualtyDialog = false; return true; } if (this._spaceshipViewer) { this._spaceshipViewer = false; return true; } if (this._palaceView) { this._palaceView = false; return true; } @@ -1043,6 +1064,20 @@ export class MapRenderer { _handleGameKey(e) { const gs = this.gameState; + // Cheat shortcuts precede ordinary F-key and Shift+C bindings. + if (this._cheatMode && e.shiftKey && e.key === 'F1') { this._executeMenuAction('cheat_unit'); return true; } + if (this._cheatMode && e.shiftKey && e.key === 'F2') { this._executeMenuAction('cheat_reveal'); return true; } + if (this._cheatMode && e.shiftKey && e.key === 'F3') { this._executeMenuAction('cheat_human'); return true; } + if (this._cheatMode && e.shiftKey && e.key === 'F4') { this._executeMenuAction('cheat_year'); return true; } + if (this._cheatMode && e.shiftKey && e.key === 'F5') { this._executeMenuAction('cheat_kill'); return true; } + if (this._cheatMode && e.shiftKey && e.key === 'F6') { this._executeMenuAction('cheat_research'); return true; } + if (this._cheatMode && e.shiftKey && e.key === 'F7') { this._executeMenuAction('cheat_govt'); return true; } + if (this._cheatMode && e.shiftKey && e.key === 'F8') { this._executeMenuAction('cheat_terrain'); return true; } + if (this._cheatMode && e.shiftKey && e.key === 'F9') { this._executeMenuAction('cheat_gold'); return true; } + if (this._cheatMode && e.ctrlKey && e.shiftKey && e.key.toLowerCase() === 'c') { + e.preventDefault(); this._executeMenuAction('cheat_editcity'); return true; + } + // Shift+C → Find City (axx0: Shift+C) if (e.shiftKey && e.key === 'C') { this._executeMenuAction('kd_findcity'); return true; } @@ -1096,6 +1131,11 @@ export class MapRenderer { // Shift+X → Medium Zoom Out (axx0: Shift+X) if (e.shiftKey && e.key === 'X') { this._executeMenuAction('view_medzoomout'); return true; } + // Optional original ENTER shortcut for closing the city screen. + if (this._cityScreen && e.key === 'Enter' && this.gameState._gameOptions?.enterAdvances !== false) { + this._cityScreen = null; + return true; + } // City screen swallows all other keys if (this._cityScreen) return true; @@ -1254,7 +1294,7 @@ export class MapRenderer { if (this._moveAnimQueue.length === 0 && this._moveAnimUnit) { const au = this._moveAnimUnit; if (this.gameState.activeUnit === au && au.movesLeft > 0 && au.status === 'active') { - this._moveRangeTiles = this._calcReachableTiles(au); + this._moveRangeTiles = this.gameState._gameOptions?.showMovePaths === false ? null : this._calcReachableTiles(au); } else { this._unitMoveMode = false; this._moveRangeTiles = null; @@ -1347,7 +1387,7 @@ export class MapRenderer { // Auto-enter move mode when a new unit becomes active if (au && au.movesLeft > 0 && au.status === 'active') { this._unitMoveMode = true; - this._moveRangeTiles = this._calcReachableTiles(au); + this._moveRangeTiles = this.gameState._gameOptions?.showMovePaths === false ? null : this._calcReachableTiles(au); this._unitMenu = null; } // Flash city box when no units left to move @@ -1414,6 +1454,9 @@ export class MapRenderer { * Right-click on the map: show tile info popup (Civ2 "View Piece"). */ handleRightClick(px, py, canvasW, canvasH) { + const clickTrace = this._beginClickTrace(px, py, canvasW, canvasH, 'right-click'); + queueMicrotask(() => this._finishClickTrace(clickTrace)); + if (this._titleScreen || this._wizard || this._cityScreen) return; if (this.gameState.gameOver) return; @@ -1435,6 +1478,9 @@ export class MapRenderer { * mini-map, or tile game-logic as appropriate. */ handleRawClick(px, py, canvasW, canvasH) { + const clickTrace = this._beginClickTrace(px, py, canvasW, canvasH); + queueMicrotask(() => this._finishClickTrace(clickTrace)); + // ── Title screen takes highest priority ─────────────────────────────────── if (this._titleScreen) { if (this._hallOfFame) { this._handleHallOfFameClick(px, py); return; } @@ -1516,11 +1562,8 @@ export class MapRenderer { // High Council — click anywhere to dismiss if (this._highCouncil) { this._stopCouncilVideo(); this._highCouncil = false; return; } - // Advance discovery popup — click anywhere to dismiss - if (this._advancePopup) { this._advancePopup = null; return; } - // Wonder splash — click anywhere to dismiss - if (this._wonderSplash) { this._stopWonderVideo(); this._wonderSplash = null; return; } + if (this._wonderSplash) { this._dismissWonderSplash(); return; } // Scenario civ chooser if (this._scenarioCivChooser) { this._handleScenarioCivClick(px, py); return; } @@ -1528,6 +1571,9 @@ export class MapRenderer { // Throne upgrade dialog — pick a category if (this._throneUpgradeDialog) { this._handleThroneUpgradeClick(px, py); return; } + // Advance discovery popup — click anywhere to dismiss + if (this._advancePopup) { this._advancePopup = null; return; } + // Palace view — click anywhere to close if (this._palaceView) { this._palaceView = false; return; } @@ -1689,15 +1735,26 @@ export class MapRenderer { if (this[dialogFlag] && this[rectsKey]) { const hit = this[rectsKey].find(r => px >= r.x && px < r.x + r.w && py >= r.y && py < r.y + r.h); if (hit) { - if (hit.key === '_close') { + if (hit.key === '_cancel') { + delete this._optionsDrafts[optsKey]; + this[dialogFlag] = false; + } else if (hit.key === '_ok') { + const oldOptions = this.gameState[optsKey] ?? {}; + const options = { ...(this._optionsDrafts[optsKey] ?? oldOptions) }; + this.gameState[optsKey] = options; + delete this._optionsDrafts[optsKey]; this[dialogFlag] = false; + if (optsKey === '_graphicOptions' && oldOptions.hdSprites !== options.hdSprites) { + this.sprites.setHdMode(!!options.hdSprites); + } + if (optsKey === '_gameOptions' && oldOptions.music !== options.music) { + this._currentMusicEra = null; + if (options.music) this._startEraMusic(this.gameState.year); + else this.audio?.stopMusic?.(0); + } } else { - const opts = this.gameState[optsKey] ?? (this.gameState[optsKey] = {}); + const opts = this._optionsDrafts[optsKey] ?? (this._optionsDrafts[optsKey] = { ...(this.gameState[optsKey] ?? {}) }); opts[hit.key] = !opts[hit.key]; - // Sync HD sprites toggle with SpriteManager - if (hit.key === 'hdSprites') { - this.sprites.setHdMode(!!opts[hit.key]); - } } return; } @@ -1714,7 +1771,9 @@ export class MapRenderer { // Spaceships Viewer dialog if (this._spaceshipViewer && this._spaceshipViewerRects) { const hit = this._spaceshipViewerRects.find(r => px >= r.x && px < r.x + r.w && py >= r.y && py < r.y + r.h); - if (hit && hit.key === '_close') { this._spaceshipViewer = false; } + if (hit?.key === '_close') this._spaceshipViewer = false; + else if (hit?.key === '_launch') this.gameState.launchSpaceship(0); + else if (hit?.key?.startsWith('_assign:')) this.gameState.assignSpaceshipPart(0, hit.key.slice(8)); return; // block click-through to map } @@ -1826,6 +1885,17 @@ export class MapRenderer { if (gs.activeCivIdx !== 0) return; const { col, row } = tile; + this._editorTargetTile = { col, row }; + + // Both MGE interaction modes open an owned city before considering units + // or the generic tile-information action. + const city = gs.cityAt(col, row); + if (city && city.civId === 0) { + this._cityScreen = city; + this._cityScreenTab = 'units'; + this._cityScreenScroll = 0; + return; + } // In View Pieces mode, show info popup instead of activating units if (this._viewOnlyMode) { @@ -1853,22 +1923,17 @@ export class MapRenderer { // Different tile — select unit and enter move mode by default unit = friendlies.find(u => u === gs.activeUnit) ?? friendlies[0]; gs.selectUnit(unit); + if (gs._gameOptions?.tutorialHelp) { + gs._addLog(`Tip: click ${UNITS[unit.typeId]?.name ?? 'the unit'} again for its orders menu.`); + } if (unit.movesLeft > 0) { this._unitMoveMode = true; - this._moveRangeTiles = this._calcReachableTiles(unit); + this._moveRangeTiles = this.gameState._gameOptions?.showMovePaths === false ? null : this._calcReachableTiles(unit); } } return; } - // Own city with no units on it → open city screen - const city = gs.cityAt(col, row); - if (city && city.civId === 0) { - this._cityScreen = city; - this._cityScreenTab = 'units'; - this._cityScreenScroll = 0; - return; - } } // ─── Unit action menu ────────────────────────────────────────────────────── @@ -2000,6 +2065,7 @@ export class MapRenderer { } _executeUnitMenuAction(id) { + this._annotateCurrentClickTrace('unit-menu', id); const gs = this.gameState; const unit = this._unitMenu.unit; this._unitMenu = null; @@ -2008,7 +2074,7 @@ export class MapRenderer { switch (id) { case 'move': this._unitMoveMode = true; - this._moveRangeTiles = this._calcReachableTiles(unit); + this._moveRangeTiles = this.gameState._gameOptions?.showMovePaths === false ? null : this._calcReachableTiles(unit); break; case 'goto': this._gotoMode = true; @@ -2669,7 +2735,7 @@ export class MapRenderer { if (this._wizard) this._drawNewGameWizard(ctx, canvasW, canvasH); // Game-over screen - if (!this._wizard && !this._retireStage && this.gameState.gameOver) this._drawGameOver(ctx, canvasW, canvasH); + if (!this._wizard && !this._retireStage && !this._replayMap && this.gameState.gameOver) this._drawGameOver(ctx, canvasW, canvasH); // Modern support feature, intentionally topmost so its controls cannot be // obscured by the MGE screens included in the captured report. @@ -2678,7 +2744,7 @@ export class MapRenderer { _drawGameOver(ctx, canvasW, canvasH) { const go = this.gameState.gameOver; - const win = go.result === 'win' || go.result === 'score-win' || go.result === 'space-win' || go.result === 'diplomatic-win'; + const win = go.result === 'win' || go.result === 'score-win' || go.result === 'space-win'; @@ -2698,10 +2764,8 @@ export class MapRenderer { const title = go.result === 'win' ? 'VICTORY!' : go.result === 'score-win' ? 'SCORE VICTORY!' : go.result === 'space-win' ? 'SPACE VICTORY!' - : go.result === 'diplomatic-win' ? 'DIPLOMATIC VICTORY!' : go.result === 'lose' ? 'DEFEAT' : go.result === 'space-lose' ? 'SPACE DEFEAT' - : go.result === 'diplomatic-lose' ? 'DIPLOMATIC DEFEAT' : 'SCORE DEFEAT'; ctx.fillStyle = win ? CLR.WIN_COLOR : CLR.LOSS_COLOR; ctx.font = FONT.TITLE_HUGE; @@ -2718,10 +2782,6 @@ export class MapRenderer { ? 'YOUR CIVILIZATION REACHED THE STARS!' : go.result === 'space-lose' ? 'An enemy civilization reached Alpha Centauri first…' - : go.result === 'diplomatic-win' - ? 'The United Nations has elected you World Leader!' - : go.result === 'diplomatic-lose' - ? 'A rival civilization was elected World Leader.' : 'The year is 2050 A.D. — a rival civilization surpassed you.'; ctx.font = FONT.LABEL_TIMES; this._panelText(ctx, sub, canvasW / 2, py + 100); @@ -2738,14 +2798,14 @@ export class MapRenderer { // Replay Map button — Win95 style const RBW = 120, RBH = 24; const rbx = Math.round(canvasW / 2 - RBW / 2); - const rby = py + 168; + const rby = py + 164; this._drawWin95Button(ctx, rbx, rby, RBW, RBH, 'Replay Map'); this._gameOverReplayRect = { x: rbx, y: rby, w: RBW, h: RBH }; // New Game button — Win95 style const BW = 140, BH = 28; const bx = Math.round(canvasW / 2 - BW / 2); - const by = py + PH - 44; + const by = py + PH - 36; ctx.fillStyle = CLR.WIN95_FACE; ctx.fillRect(bx, by, BW, BH); ctx.fillStyle = CLR.WIN95_LIGHT; ctx.fillRect(bx, by, BW, 1); ctx.fillRect(bx, by, 1, BH); ctx.fillStyle = CLR.WIN95_SHADOW; ctx.fillRect(bx, by + BH - 1, BW, 1); ctx.fillRect(bx + BW - 1, by, 1, BH); @@ -3092,7 +3152,8 @@ export class MapRenderer { return; } - const disabled = !!(item.needsUnit && !(au && au.civId === 0)); + const disabled = !!(item.needsUnit && !(au && au.civId === 0)) || + (!!item.action?.startsWith('cheat_') && item.action !== 'cheat_toggle' && !this._cheatMode); const hovered = !disabled && this._menuHoverIdx === this._menuItemRects.length; if (hovered) { @@ -3143,6 +3204,7 @@ export class MapRenderer { } _executeMenuAction(action) { + this._annotateCurrentClickTrace('menu', action); if (this._executeGameAction(action)) return; if (this._executeViewAction(action)) return; if (this._executeKingdomAction(action)) return; @@ -3181,14 +3243,17 @@ export class MapRenderer { case 'game_options': this._gameOptionsDialog = true; this._gameOptionsRects = []; + this._optionsDrafts._gameOptions = { ...(this.gameState._gameOptions ?? {}) }; return true; case 'game_graphicoptions': this._graphicOptionsDialog = true; this._graphicOptionsRects = []; + this._optionsDrafts._graphicOptions = { ...(this.gameState._graphicOptions ?? {}) }; return true; case 'game_cityreportoptions': this._cityReportOptionsDialog = true; this._cityReportOptionsRects = []; + this._optionsDrafts._cityReportOptions = { ...(this.gameState._cityReportOptions ?? {}) }; return true; case 'game_music': this._showMusicPicker(); @@ -3206,6 +3271,12 @@ export class MapRenderer { case 'view_center': if (au) this.centerOn(au.col, au.row, this._canvasW, this._canvasH); return true; + case 'view_arrange': + this._tileInfoPopup = null; + this._unitMenu = null; + this._openMenu = null; + if (au) this.centerOn(au.col, au.row, this._canvasW, this._canvasH); + return true; case 'view_zoomin': this._setZoomLevel(Math.min(4, this._zoomLevel + 1)); return true; @@ -3353,6 +3424,10 @@ export class MapRenderer { _executeAdvisorsAction(action) { switch (action) { + case 'adv_chat': + this._diplomacyScreen = true; + this._diplomacyScreenRects = []; + return true; case 'adv_science': this._scienceAdvisor = !this._scienceAdvisor; this._scienceAdvisorRects = []; @@ -3381,6 +3456,10 @@ export class MapRenderer { this._attitudeRects = []; return true; case 'adv_council': + if (this.gameState._graphicOptions?.highCouncil === false) { + this.gameState._addLog('The High Council display is disabled in Graphic Options.'); + return true; + } if (this._highCouncil) { this._stopCouncilVideo(); this._highCouncil = false; } else { this._highCouncil = true; this._startCouncilVideo(); } return true; @@ -3409,16 +3488,6 @@ export class MapRenderer { case 'wld_hof': this._hallOfFame = !this._hallOfFame; return true; - case 'wld_unelect': { - const result = gs.proposeUnElection(); - if (!result.eligible) { - gs._addLog('You must own the United Nations to call an election.'); - } else if (result.alreadyUsed) { - gs._addLog('The UN Election has already been held this game.'); - } - // Victory/defeat is set inside proposeUnElection if applicable. - return true; - } case 'wld_spaceships': this._spaceshipViewer = !this._spaceshipViewer; this._spaceshipViewerRects = []; @@ -3442,27 +3511,46 @@ export class MapRenderer { const gs = this.gameState; switch (action) { + case 'cheat_toggle': + this._cheatMode = !this._cheatMode; + this._editorLog(`Cheat mode ${this._cheatMode ? 'enabled' : 'disabled'}.`); + return true; + case 'cheat_unit': { + if (!this._cheatMode) return true; + const tile = this._getEditorCursorTile(); + if (!tile) { this._editorLog('Select a map tile first.'); return true; } + const typeId = gs.activeUnit?.typeId ?? 0; + const unit = gs._spawnUnit(typeId, 0, tile.col, tile.row); + gs.selectUnit(unit); + gs._updateVisibility(); + this._editorLog(`Created ${UNITS[typeId]?.name ?? 'unit'}.`); + return true; + } case 'cheat_reveal': + if (!this._cheatMode) return true; for (let r = 0; r < gs.mapRows; r++) for (let c = 0; c < gs.mapCols; c++) gs._visibility[r][c] = 2; gs.log.unshift('Map revealed.'); return true; case 'cheat_gold': + if (!this._cheatMode) return true; if (gs.civs[0]) { gs.civs[0].gold = (gs.civs[0].gold ?? 0) + 1000; gs.log.unshift('+1000 gold added.'); } return true; case 'cheat_research': + if (!this._cheatMode) return true; if (gs.civs[0] && gs.civs[0].currentResearch != null) { gs.civs[0].beakers = gs.advanceCost(gs.civs[0]); gs.log.unshift('Research completed next turn.'); } return true; case 'cheat_destroyunits': { + if (!this._cheatMode) return true; // Destroy all units at the cursor position (axx0: Ctrl+Shift+D) - const vt = this._hoveredTile; + const vt = this._getEditorCursorTile(); if (vt) { const toRemove = gs.units.filter(u => u.col === vt.col && u.row === vt.row); for (const u of toRemove) gs._removeUnit(u); @@ -3470,16 +3558,90 @@ export class MapRenderer { } return true; } + case 'cheat_govt': { + if (!this._cheatMode || !gs.civs[0]) return true; + gs.civs[0].government = (gs.civs[0].government + 1) % GOVERNMENTS.length; + gs.civs[0].anarchyTurnsLeft = 0; + this._editorLog(`Government changed to ${GOVERNMENTS[gs.civs[0].government]?.name}.`); + return true; + } + case 'cheat_terrain': { + if (!this._cheatMode) return true; + const tile = this._getEditorCursorTile(); + if (!tile) return true; + const terrains = Object.values(TERRAIN); + const current = terrains.indexOf(gs.tiles[tile.row][tile.col]); + gs.tiles[tile.row][tile.col] = terrains[(current + 1) % terrains.length]; + this._editorLog(`Terrain changed to ${gs.tiles[tile.row][tile.col].name}.`); + return true; + } + case 'cheat_year': { + if (!this._cheatMode) return true; + const raw = window.prompt('Set game year (negative for B.C.)', String(gs.year)); + if (raw == null) return true; + const wanted = Number.parseInt(raw, 10); + if (!Number.isFinite(wanted)) return true; + let bestTurn = gs.turn, bestDiff = Infinity; + for (let turn = 1; turn <= 1000; turn++) { + const diff = Math.abs(gs._gameYear(turn) - wanted); + if (diff < bestDiff) { bestDiff = diff; bestTurn = turn; } + } + gs.turn = bestTurn; + gs._spaceRaceLaunchTurn = null; + gs._spaceRaceLaunchYear = null; + this._editorLog(`Game year set to ${gs.year}.`); + return true; + } + case 'cheat_kill': { + if (!this._cheatMode) return true; + const choices = gs.civs.filter(c => c.id !== 0 && c.id !== gs.barbarianCivIdx && c.alive); + if (!choices.length) return true; + const raw = window.prompt(`Civilization id to eliminate: ${choices.map(c => `${c.id}=${c.data.plural}`).join(', ')}`, String(choices[0].id)); + const civId = Number.parseInt(raw, 10); + const civ = choices.find(c => c.id === civId); + if (!civ) return true; + gs.units = gs.units.filter(u => u.civId !== civId); + gs.cities = gs.cities.filter(c => c.civId !== civId); + civ.alive = false; + this._editorLog(`${civ.data.plural} eliminated.`); + return true; + } + case 'cheat_human': { + if (!this._cheatMode) return true; + const choices = gs.civs.filter(c => c.id !== gs.barbarianCivIdx && c.alive); + const raw = window.prompt(`Human civilization id: ${choices.map(c => `${c.id}=${c.data.plural}`).join(', ')}`, '0'); + const targetId = Number.parseInt(raw, 10); + if (targetId !== 0 && choices.some(c => c.id === targetId)) this._swapHumanCivilization(targetId); + return true; + } + case 'cheat_scenario': { + if (!this._cheatMode) return true; + const name = window.prompt('Scenario name', gs._scenarioName ?? 'New Scenario'); + if (name == null) return true; + const turns = window.prompt('Maximum turns (0 for none)', String(gs._maxTurns ?? 0)); + gs._isScenario = true; + gs._scenarioName = name.trim() || 'New Scenario'; + gs._maxTurns = Math.max(0, Number.parseInt(turns, 10) || 0); + this._editorLog(`Scenario parameters updated: ${gs._scenarioName}.`); + return true; + } + case 'cheat_savescen': + if (this._cheatMode) this._downloadScenario(); + return true; case 'cheat_edittechs': + if (!this._cheatMode) return true; this._openEditTechsDialog(); return true; case 'cheat_editunit': + if (!this._cheatMode) return true; this._openEditUnitDialog(); return true; case 'cheat_editcity': + if (!this._cheatMode) return true; this._openEditCityDialog(); return true; case 'cheat_editking': + if (!this._cheatMode) return true; this._openEditKingDialog(); return true; default: @@ -3487,6 +3649,43 @@ export class MapRenderer { } } + _swapHumanCivilization(targetId) { + const gs = this.gameState; + if (!gs.civs[targetId] || targetId === 0) return false; + const remap = id => id === 0 ? targetId : id === targetId ? 0 : id; + [gs.civs[0], gs.civs[targetId]] = [gs.civs[targetId], gs.civs[0]]; + for (let i = 0; i < gs.civs.length; i++) gs.civs[i].id = i; + for (const unit of gs.units) unit.civId = remap(unit.civId); + for (const city of gs.cities) city.civId = remap(city.civId); + for (const civ of gs.civs) { + civ.relations = new Map([...civ.relations].map(([id, value]) => [remap(id), value])); + civ.attitude = new Map([...civ.attitude].map(([id, value]) => [remap(id), value])); + civ.contacts = new Set([...civ.contacts].map(remap)); + civ.embassies = new Set([...civ.embassies].map(remap)); + } + gs.barbarianCivIdx = remap(gs.barbarianCivIdx); + gs.activeCivIdx = remap(gs.activeCivIdx); + gs.activeUnit = gs.units.find(u => u.civId === 0 && u.movesLeft > 0) ?? null; + gs._updateVisibility(); + this._editorLog(`Human player changed to ${gs.civs[0].data.plural}.`); + return true; + } + + _downloadScenario() { + const data = this.gameState.toSaveData(); + data.isScenario = true; + const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `${(this.gameState._scenarioName || 'scenario').replace(/[^a-z0-9_-]+/gi, '_')}.civ2-scenario.json`; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); + this._editorLog('Scenario saved.'); + } + _executeCivilopediaAction(action) { switch (action) { case 'cpd_advances': case 'cpd_improv': case 'cpd_units': case 'cpd_terrain': @@ -3789,6 +3988,14 @@ export class MapRenderer { } } + _autoSaveGame() { + try { + localStorage.setItem('civ2_autosave', JSON.stringify(this.gameState.toSaveData())); + } catch (e) { + console.warn('Autosave failed:', e.message); + } + } + _saveGameAsSav() { try { const buffer = this.gameState.exportSav(); @@ -3820,7 +4027,7 @@ export class MapRenderer { _loadGame() { try { - const raw = localStorage.getItem('civ2_save'); + const raw = localStorage.getItem('civ2_save') ?? localStorage.getItem('civ2_autosave'); if (!raw) { this.gameState.log.unshift('No saved game found.'); if (this.gameState.log.length > 8) this.gameState.log.length = 8; @@ -3897,6 +4104,7 @@ export class MapRenderer { this._throneRoom = false; this._throneUpgradeDialog = false; this._throneUpgradeRects = []; + this._pendingThroneUpgradeDialog = false; this._scenarioCivChooser = false; this._scenarioPending = null; this._scenarioCivRects = []; @@ -3923,6 +4131,13 @@ export class MapRenderer { this._combatAnim = null; this._gameOptionsDialog = false; this._gameOptionsRects = []; + this._graphicOptionsDialog = false; + this._graphicOptionsRects = []; + this._cityReportOptionsDialog = false; + this._cityReportOptionsRects = []; + this._optionsDrafts = {}; + this._editorTargetTile = null; + this._pendingProductionChange = null; this._casualtyDialog = false; this._casualtyDialogRects = []; this._showGrid = false; @@ -3950,6 +4165,7 @@ export class MapRenderer { /** Determine the music era from game year and start the matching CD track. */ _startEraMusic(year) { + if (this.gameState?._gameOptions?.music === false) return; let era; if (year < 0) era = 'ancient'; else if (year < 1500) era = 'renaissance'; @@ -4151,8 +4367,8 @@ export class MapRenderer { const dx = (canvasW - DW) / 2, dy = (canvasH - DH) / 2; ctx.fillStyle = 'rgba(0,0,0,0.55)'; ctx.fillRect(0, 0, canvasW, canvasH); // Version string as panel title (matches original Civ2 MGE Game Options dialog) - this._drawCiv2Panel(ctx, dx, dy, DW, DH, 'Civilization II Multiplayer Gold 5.4.0f Multiplayer 26-March-99 Patch 3'); - const opts = this.gameState._gameOptions ?? {}; + this._drawCiv2Panel(ctx, dx, dy, DW, DH, 'Civilization II Multiplayer Gold 5.4.0f'); + const opts = this._optionsDrafts._gameOptions ?? this.gameState._gameOptions ?? {}; this._gameOptionsRects = []; ctx.textAlign = 'left'; labels.forEach(([key, label], i) => { @@ -4167,9 +4383,9 @@ export class MapRenderer { const bw = 80, bh = 24; const btnY = dy + DH - 38; this._drawWin95Button(ctx, dx + DW / 2 - bw - 10, btnY, bw, bh, 'OK'); - this._gameOptionsRects.push({ x: dx + DW / 2 - bw - 10, y: btnY, w: bw, h: bh, key: '_close' }); + this._gameOptionsRects.push({ x: dx + DW / 2 - bw - 10, y: btnY, w: bw, h: bh, key: '_ok' }); this._drawWin95Button(ctx, dx + DW / 2 + 10, btnY, bw, bh, 'Cancel'); - this._gameOptionsRects.push({ x: dx + DW / 2 + 10, y: btnY, w: bw, h: bh, key: '_close' }); + this._gameOptionsRects.push({ x: dx + DW / 2 + 10, y: btnY, w: bw, h: bh, key: '_cancel' }); ctx.textAlign = 'left'; } @@ -4190,7 +4406,7 @@ export class MapRenderer { const dx = (canvasW - DW) / 2, dy = (canvasH - DH) / 2; ctx.fillStyle = 'rgba(0,0,0,0.55)'; ctx.fillRect(0, 0, canvasW, canvasH); this._drawCiv2Panel(ctx, dx, dy, DW, DH, 'Select Graphic Options'); - const opts = this.gameState._graphicOptions ?? {}; + const opts = this._optionsDrafts._graphicOptions ?? this.gameState._graphicOptions ?? {}; this._graphicOptionsRects = []; ctx.textAlign = 'left'; const DEFAULTS_OFF = new Set(['hdSprites']); // these default to unchecked @@ -4205,9 +4421,9 @@ export class MapRenderer { const bw = 80, bh = 24; const btnY = dy + DH - 38; this._drawWin95Button(ctx, dx + DW / 2 - bw - 10, btnY, bw, bh, 'OK'); - this._graphicOptionsRects.push({ x: dx + DW / 2 - bw - 10, y: btnY, w: bw, h: bh, key: '_close' }); + this._graphicOptionsRects.push({ x: dx + DW / 2 - bw - 10, y: btnY, w: bw, h: bh, key: '_ok' }); this._drawWin95Button(ctx, dx + DW / 2 + 10, btnY, bw, bh, 'Cancel'); - this._graphicOptionsRects.push({ x: dx + DW / 2 + 10, y: btnY, w: bw, h: bh, key: '_close' }); + this._graphicOptionsRects.push({ x: dx + DW / 2 + 10, y: btnY, w: bw, h: bh, key: '_cancel' }); ctx.textAlign = 'left'; } @@ -4232,7 +4448,7 @@ export class MapRenderer { const dx = (canvasW - DW) / 2, dy = (canvasH - DH) / 2; ctx.fillStyle = 'rgba(0,0,0,0.55)'; ctx.fillRect(0, 0, canvasW, canvasH); this._drawCiv2Panel(ctx, dx, dy, DW, DH, 'Select City Report Options'); - const opts = this.gameState._cityReportOptions ?? {}; + const opts = this._optionsDrafts._cityReportOptions ?? this.gameState._cityReportOptions ?? {}; this._cityReportOptionsRects = []; ctx.textAlign = 'left'; labels.forEach(([key, label], i) => { @@ -4246,9 +4462,9 @@ export class MapRenderer { const bw = 80, bh = 24; const btnY = dy + DH - 38; this._drawWin95Button(ctx, dx + DW / 2 - bw - 10, btnY, bw, bh, 'OK'); - this._cityReportOptionsRects.push({ x: dx + DW / 2 - bw - 10, y: btnY, w: bw, h: bh, key: '_close' }); + this._cityReportOptionsRects.push({ x: dx + DW / 2 - bw - 10, y: btnY, w: bw, h: bh, key: '_ok' }); this._drawWin95Button(ctx, dx + DW / 2 + 10, btnY, bw, bh, 'Cancel'); - this._cityReportOptionsRects.push({ x: dx + DW / 2 + 10, y: btnY, w: bw, h: bh, key: '_close' }); + this._cityReportOptionsRects.push({ x: dx + DW / 2 + 10, y: btnY, w: bw, h: bh, key: '_cancel' }); ctx.textAlign = 'left'; } @@ -4294,39 +4510,85 @@ export class MapRenderer { _drawSpaceshipViewer(ctx, canvasW, canvasH) { if (!this._spaceshipViewer) return; - + const gs = this.gameState; - const DW = 400, DH = 320; + const DW = 540, DH = 410; const dx = (canvasW - DW) / 2, dy = (canvasH - DH) / 2; ctx.fillStyle = 'rgba(0,0,0,0.55)'; ctx.fillRect(0, 0, canvasW, canvasH); - this._drawCiv2Panel(ctx, dx, dy, DW, DH, 'Spaceship Status'); + this._drawCiv2Panel(ctx, dx, dy, DW, DH, 'Spaceship'); ctx.textAlign = 'left'; - const hdrY = dy + 42; - ctx.font = FONT.SMALL_BOLD; - this._panelText(ctx, 'Civilization', dx + 10, hdrY); this._panelText(ctx, 'Structural', dx + 150, hdrY); - this._panelText(ctx, 'Component', dx + 230, hdrY); this._panelText(ctx, 'Module', dx + 310, hdrY); - ctx.fillStyle = CLR.WIN95_SHADOW; ctx.fillRect(dx + 6, hdrY + 4, DW - 12, 1); - const rowH = 22; - let ri = 0; - for (const civ of gs.civs) { - if (!civ || !civ.alive) continue; - const progress = gs.spaceshipProgress(civ.id); - const ry = hdrY + 10 + ri * rowH; - if (ry + rowH > dy + DH - 40) break; - ctx.fillStyle = ri % 2 === 0 ? '#b8b8b8' : CLR.WIN95_FACE; ctx.fillRect(dx + 6, ry, DW - 12, rowH); - ctx.font = FONT.BODY_SMALL; ctx.fillStyle = '#000'; - ctx.fillText((civ.data?.plural ?? `Civ#${civ.id}`).slice(0, 18), dx + 10, ry + 15); - ctx.fillStyle = progress.structural >= 8 ? CLR.WIN_COLOR : '#000'; ctx.fillText(`${progress.structural}/8`, dx + 165, ry + 15); - ctx.fillStyle = progress.component >= 4 ? CLR.WIN_COLOR : '#000'; ctx.fillText(`${progress.component}/4`, dx + 245, ry + 15); - ctx.fillStyle = progress.module >= 4 ? CLR.WIN_COLOR : '#000'; ctx.fillText(`${progress.module}/4`, dx + 320, ry + 15); - if (gs.spaceshipReady(civ.id)) { ctx.fillStyle = CLR.WIN_COLOR; ctx.font = FONT.TINY_BOLD; ctx.fillText('READY', dx + 365, ry + 15); } - ri++; - } - if (ri === 0) { - ctx.font = FONT.BODY_ITALIC; ctx.fillStyle = '#666'; ctx.textAlign = 'center'; - ctx.fillText('No civilizations with spaceship parts.', dx + DW / 2, dy + DH / 2); ctx.textAlign = 'left'; - } this._spaceshipViewerRects = []; + const stats = gs.spaceshipStats(0); + const civ = gs.civs[0]; + ctx.font = FONT.SMALL_BOLD; + this._panelText(ctx, `${civ?.data?.plural ?? 'Your'} Spaceship`, dx + 12, dy + 40); + + const systems = [ + ['Structural', 'structural', null], + ['Propulsion', 'propulsion', 'component'], + ['Fuel', 'fuel', 'component'], + ['Habitation', 'habitation', 'module'], + ['Life Support', 'lifeSupport', 'module'], + ['Solar Panel', 'solar', 'module'], + ]; + systems.forEach(([label, key, source], i) => { + const y = dy + 56 + i * 27; + ctx.fillStyle = i % 2 ? CLR.WIN95_FACE : '#b8b8b8'; + ctx.fillRect(dx + 10, y, 250, 23); + ctx.font = FONT.BODY_SMALL; ctx.fillStyle = '#000'; + ctx.fillText(label, dx + 18, y + 16); + ctx.textAlign = 'right'; + ctx.fillText(`${stats?.[key] ?? 0}${key === 'structural' ? '/39' : source === 'component' ? '/8' : '/4'}`, dx + 170, y + 16); + ctx.textAlign = 'left'; + if (source && !stats?.launched) { + const available = source === 'component' ? stats.unassignedComponents : stats.unassignedModules; + const bx = dx + 184; + this._drawWin95Button(ctx, bx, y + 1, 68, 21, available > 0 ? 'Add' : 'None'); + if (available > 0) this._spaceshipViewerRects.push({ x: bx, y: y + 1, w: 68, h: 21, key: `_assign:${key}` }); + } + }); + + const infoX = dx + 282; + ctx.font = FONT.BODY_SMALL; + const flight = Number.isFinite(stats?.flightYears) ? `${stats.flightYears.toFixed(1)} years` : '—'; + const details = [ + `Components waiting: ${stats?.unassignedComponents ?? 0}`, + `Modules waiting: ${stats?.unassignedModules ?? 0}`, + `Population: ${(stats?.population ?? 0).toLocaleString()}`, + `Support: ${stats?.support ?? 0}%`, + `Energy: ${stats?.energy ?? 0}%`, + `Probability: ${stats?.success ?? 0}%`, + `Flight time: ${flight}`, + ]; + details.forEach((line, i) => this._panelText(ctx, line, infoX, dy + 62 + i * 23)); + + const launchX = infoX, launchY = dy + 230; + if (stats?.launched) { + ctx.font = FONT.SMALL_BOLD; + this._panelText(ctx, `Launched ${stats.launchYear}`, launchX, launchY); + this._panelText(ctx, `Estimated arrival ${stats.arrivalYear}`, launchX, launchY + 22); + } else { + const ready = gs.spaceshipReady(0); + this._drawWin95Button(ctx, launchX, launchY, 220, 28, ready ? 'LAUNCH SPACESHIP' : 'SHIP NOT READY'); + if (ready) this._spaceshipViewerRects.push({ x: launchX, y: launchY, w: 220, h: 28, key: '_launch' }); + } + + ctx.fillStyle = CLR.WIN95_SHADOW; ctx.fillRect(dx + 10, dy + 286, DW - 20, 1); + ctx.font = FONT.SMALL_BOLD; + this._panelText(ctx, 'Space Race', dx + 12, dy + 307); + let ri = 0; + for (const rival of gs.civs) { + if (!rival || !rival.alive || rival.id === gs.barbarianCivIdx) continue; + const p = gs.spaceshipStats(rival.id); + const ry = dy + 316 + ri * 18; + ctx.font = FONT.TINY; ctx.fillStyle = '#000'; + const status = p.launched ? `arrival ${p.arrivalYear}` : `${p.structural}S ${p.component}C ${p.module}M`; + ctx.fillText((rival.data?.plural ?? `Civ ${rival.id}`).slice(0, 22), dx + 16, ry + 12); + ctx.fillText(status, dx + 190, ry + 12); + ri++; + if (ri >= 3) break; + } + const bx = dx + DW / 2 - 40, by = dy + DH - 36, bw = 80, bh = 24; this._drawWin95Button(ctx, bx, by, bw, bh, 'Close'); this._spaceshipViewerRects.push({ x: bx, y: by, w: bw, h: bh, key: '_close' }); @@ -4355,9 +4617,10 @@ export class MapRenderer { } _getEditorCursorTile() { - if (this._hoveredTile) return this._hoveredTile; + if (this._editorTargetTile) return this._editorTargetTile; const au = this.gameState?.activeUnit; - return au ? { col: au.col, row: au.row } : null; + if (au) return { col: au.col, row: au.row }; + return this._hoveredTile ?? null; } _drawEditorInputBox(ctx, x, y, w, h, text, cursor = null, active = false) { @@ -4531,7 +4794,7 @@ export class MapRenderer { unit, typeId: unit.typeId, hpValue: this._clampEditorInt((unit.hp ?? 1) * 10, 10, 100), - movesLeft: this._clampEditorInt(unit.movesLeft ?? 0, 0, 99), + movesLeft: this._clampEditorInt(Math.ceil((unit.movesLeft ?? 0) / COSMIC.roadMultiplier), 0, 33), veteran: !!unit.veteran, homeCityId: unit.homeCity ?? null, typeScroll: Math.max(0, unit.typeId - 5), @@ -4557,8 +4820,8 @@ export class MapRenderer { unit.typeId = d.typeId; unit.hp = hp; unit.maxHp = Math.max(hp, data?.hp ?? 1); - unit.movesLeft = this._clampEditorInt(d.movesLeft, 0, 99); - unit.maxMoves = Math.max(data?.move ?? 0, unit.movesLeft); + unit.movesLeft = this._clampEditorInt(d.movesLeft, 0, 33) * COSMIC.roadMultiplier; + unit.maxMoves = Math.max((data?.move ?? 0) * COSMIC.roadMultiplier, unit.movesLeft); unit.veteran = !!d.veteran; unit.homeCity = d.homeCityId ?? null; unit.fuel = data?.domain === 1 && (data?.range ?? 0) > 0 ? data.range : 0; @@ -4656,7 +4919,7 @@ export class MapRenderer { this._panelText(ctx, `Role ${selectedType?.role ?? 0} Domain ${selectedType?.domain ?? 0}`, rx, py + 64); this._drawEditorStepper(ctx, rx, py + 90, 'HP', d.hpValue, d.rects, 'hpValue', 10, 100, 10); - this._drawEditorStepper(ctx, rx, py + 122, 'Moves Left', d.movesLeft, d.rects, 'movesLeft', 0, 99, 1); + this._drawEditorStepper(ctx, rx, py + 122, 'Moves Left', d.movesLeft, d.rects, 'movesLeft', 0, 33, 1); const cbY = py + 160; this._drawCiv2Checkbox(ctx, rx, cbY, d.veteran); @@ -5090,4 +5353,5 @@ applyWizardMixin(MapRenderer); applyDialogsMixin(MapRenderer); applyAdvisorsMixin(MapRenderer); applyInfoScreensMixin(MapRenderer); +applyInteractionTraceMixin(MapRenderer); applyBugReportMixin(MapRenderer); diff --git a/src/render/mixins/AdvisorsMixin.js b/src/render/mixins/AdvisorsMixin.js index 9084bb1..a1c4f14 100644 --- a/src/render/mixins/AdvisorsMixin.js +++ b/src/render/mixins/AdvisorsMixin.js @@ -597,22 +597,10 @@ export function applyAdvisorsMixin(MapRenderer) { } const sorted = [...groups.entries()].sort((a, b) => b[1] - a[1]); - // Gather visible enemy info - const enemyInfo = []; - for (const civ of gs.civs) { - if (civ.id === 0 || !civ.alive) continue; - const visCount = gs.units.filter(u => u.civId === civ.id && - gs._visibility[u.row]?.[u.col] === 2).length; - if (visCount > 0 || gs.civs[0]?.relations.get(civ.id)) { - enemyInfo.push({ civ, visCount }); - } - } - const ROW_H = 22; const PW = Math.min(500, canvasW - 40); const forceRows = sorted.length; - const enemyRows = enemyInfo.length; - const PH = 90 + forceRows * ROW_H + 40 + enemyRows * ROW_H + 60; + const PH = 90 + forceRows * ROW_H + 70; const px = Math.round((canvasW - PW) / 2); const py = Math.round((canvasH - Math.min(PH, canvasH - 40)) / 2); @@ -665,36 +653,6 @@ export function applyAdvisorsMixin(MapRenderer) { cy += sorted.length * ROW_H + 10; - // Section: Known Enemy Forces - ctx.font = FONT.SMALL_BOLD; ctx.fillStyle = CLR.GOLD; - ctx.fillText('Known Enemy Forces', px + 10, cy + 14); - cy += 22; - - for (let i = 0; i < enemyInfo.length; i++) { - const { civ, visCount } = enemyInfo[i]; - const ry = cy + i * ROW_H; - const civColor = CIV_COLORS[civ.data.color ?? 1] ?? '#888'; - - ctx.fillStyle = i % 2 === 0 ? '#8e8e8e' : '#878787'; - ctx.fillRect(px + 6, ry, PW - 12, ROW_H - 1); - - // Color strip - ctx.fillStyle = civColor; - ctx.fillRect(px + 7, ry + 1, 4, ROW_H - 3); - - ctx.font = FONT.BODY_SMALL; ctx.fillStyle = '#000000'; - ctx.fillText(`${civ.data.plural ?? civ.data.adjective}`, px + 16, ry + 15); - ctx.fillText(`${visCount} visible unit${visCount !== 1 ? 's' : ''}`, px + 200, ry + 15); - } - - if (enemyInfo.length === 0) { - ctx.font = FONT.BODY_SMALL; ctx.fillStyle = '#666666'; - ctx.fillText('No enemy forces visible.', px + 16, cy + 14); - cy += ROW_H; - } else { - cy += enemyInfo.length * ROW_H; - } - // Footer cy += 8; ctx.fillStyle = '#606060'; diff --git a/src/render/mixins/BugReportMixin.js b/src/render/mixins/BugReportMixin.js index c496a7c..e778709 100644 --- a/src/render/mixins/BugReportMixin.js +++ b/src/render/mixins/BugReportMixin.js @@ -95,13 +95,14 @@ export function applyBugReportMixin(MapRenderer) { mapRows: gameState.mapRows, }, rendererState, + recentClicks: this._interactionTraceForReport(), }; const instructions = [ 'Civilization II Browser Recreation - Bug Report', '', 'screenshot.png Exact visible state when Report Bug was chosen.', 'game-state.json Full restorable GameState.toSaveData() snapshot.', - 'report.json View/camera state, browser details, and summary.', + 'report.json View/camera state, browser details, summary, and last ten clicks.', '', 'Attach this ZIP to a GitHub issue or email. Do not unpack it first.', 'The screenshot may contain original MGE artwork; share only for bug diagnosis.', @@ -221,7 +222,7 @@ export function applyBugReportMixin(MapRenderer) { const dialog = this._bugReportDialog; if (!dialog) return; const PW = Math.min(680, canvasW - 40); - const PH = 250; + const PH = 272; const px = Math.round((canvasW - PW) / 2); const py = Math.round((canvasH - PH) / 2); ctx.fillStyle = 'rgba(0,0,0,0.48)'; @@ -234,15 +235,16 @@ export function applyBugReportMixin(MapRenderer) { 'Screenshot of the exact visible game', 'Full restorable game-state snapshot', 'Camera, zoom, open-screen, browser, and viewport details', + 'Last ten canvas clicks, including screen and resolved action', ]; lines.forEach((line, index) => this._panelText(ctx, `\u2022 ${line}`, px + 38, py + 82 + index * 22)); ctx.font = FONT.BODY_SMALL; this._panelText(ctx, 'GitHub opens a prepared issue. Share / Email uses system sharing or opens an email draft.', - px + 24, py + 156); + px + 24, py + 178); ctx.font = FONT.SMALL_BOLD; ctx.fillStyle = dialog.status === 'error' ? '#a00000' : '#000000'; - ctx.fillText(dialog.message, px + 24, py + 184, PW - 48); + ctx.fillText(dialog.message, px + 24, py + 206, PW - 48); const labels = ['Download ZIP', 'Share / Email', 'GitHub Issue', 'Close']; const gap = 10; @@ -268,6 +270,7 @@ export function applyBugReportMixin(MapRenderer) { if (!dialog) return; const hit = dialog.rects.find(rect => px >= rect.x && px < rect.x + rect.w && py >= rect.y && py < rect.y + rect.h); if (!hit) return; + this._annotateCurrentClickTrace('bug-report', hit.action); if (hit.action === 'close') { this._bugReportDialog = null; return; diff --git a/src/render/mixins/CityScreenMixin.js b/src/render/mixins/CityScreenMixin.js index a636dee..6cebff3 100644 --- a/src/render/mixins/CityScreenMixin.js +++ b/src/render/mixins/CityScreenMixin.js @@ -1075,7 +1075,8 @@ export function applyCityScreenMixin(MapRenderer) { ctx.font = `bold ${vs(11)}px ${FONT_TIMES}`; ctx.fillStyle = isSelected ? '#ffffff' : '#222222'; ctx.textAlign = 'right'; - ctx.fillText(facts, vx(OPX + OPW - 9), vy(iy + 15)); + const factsRight = items.length > rowsVis ? OPX + OPW - 26 : OPX + OPW - 9; + ctx.fillText(facts, vx(factsRight), vy(iy + 15)); ctx.textAlign = 'left'; this._cityScreenItemRects.push({ ...vfr(OPX + 6, iy, OPW - 12, rowH), item }); } @@ -1158,11 +1159,17 @@ export function applyCityScreenMixin(MapRenderer) { gs.sellImprovement(city, this._pendingSellImpId); this._pendingSellImpId = null; } + if (this._pendingProductionChange) { + gs.changeProduction(city, this._pendingProductionChange); + this._pendingProductionChange = null; + this._cityScreenProdList = false; + } this._cityPopupText = null; return; } if (hit(this._cityPopupNoRect)) { this._pendingSellImpId = null; + this._pendingProductionChange = null; this._cityPopupText = null; return; } @@ -1250,7 +1257,16 @@ export function applyCityScreenMixin(MapRenderer) { } if (hit(this._cityScreenOkRect)) { const selected = this._cityScreenProductionSelection; - if (selected) gs.changeProduction(this._cityScreen, { type: selected.type, id: selected.id }); + if (selected) { + const next = { type: selected.type, id: selected.id }; + const changedCategory = city.production && city.production.type !== next.type && city.shields > 0; + if (changedCategory && gs._cityReportOptions?.warnChangingProduction !== false) { + this._pendingProductionChange = next; + this._showCityPopup('Changing production will waste shields. Continue?', true); + return; + } + gs.changeProduction(this._cityScreen, next); + } this._cityScreenProdList = false; return; } diff --git a/src/render/mixins/DialogsMixin.js b/src/render/mixins/DialogsMixin.js index 9f7020e..83c42f5 100644 --- a/src/render/mixins/DialogsMixin.js +++ b/src/render/mixins/DialogsMixin.js @@ -294,6 +294,7 @@ export function applyDialogsMixin(MapRenderer) { MapRenderer.prototype._startHeraldVideo = function(civId) { this._stopHeraldVideo(); + if (this.gameState?._graphicOptions?.animatedHeralds === false) return; const code = HERALD_FILES[civId]; if (!code) return; const video = document.createElement('video'); @@ -321,7 +322,7 @@ export function applyDialogsMixin(MapRenderer) { const gs = this.gameState; const FA = "'Tahoma','Arial','Arimo',sans-serif"; // Barbarians have no diplomacy screen or herald in MGE. - const others = gs.civs.filter(c => c.id !== 0 && c.id !== gs.barbarianCivIdx && c.alive); + const others = gs.civs.filter(c => c.id !== 0 && c.id !== gs.barbarianCivIdx && c.alive && gs.hasContact(0, c.id)); const ROW_H = 52; const PW = Math.min(460, canvasW - 40); @@ -476,7 +477,8 @@ export function applyDialogsMixin(MapRenderer) { const PW = 480, PH = 380; const HERALD_W = 190, HERALD_GAP = 8; - const showHerald = canvasW >= PW + HERALD_W + HERALD_GAP + 20; + const showHerald = this.gameState._graphicOptions?.diplomacyScreen !== false && + canvasW >= PW + HERALD_W + HERALD_GAP + 20; const totalW = PW + (showHerald ? HERALD_W + HERALD_GAP : 0); const px = Math.round((canvasW - totalW) / 2) + (showHerald ? HERALD_W + HERALD_GAP : 0); const py = Math.round((canvasH - PH) / 2); @@ -862,11 +864,26 @@ export function applyDialogsMixin(MapRenderer) { if (this._wonderSplash) { this._stopWonderVideo(); this._wonderSplash = null; + if (this._pendingThroneUpgradeDialog) { + this._pendingThroneUpgradeDialog = false; + this._throneUpgradeDialog = true; + this._throneUpgradeRects = []; + } } }); this._wonderVideo = vid; } + MapRenderer.prototype._dismissWonderSplash = function() { + this._stopWonderVideo(); + this._wonderSplash = null; + if (this._pendingThroneUpgradeDialog) { + this._pendingThroneUpgradeDialog = false; + this._throneUpgradeDialog = true; + this._throneUpgradeRects = []; + } + } + MapRenderer.prototype._stopWonderVideo = function() { if (this._wonderVideo) { this._wonderVideo.pause(); diff --git a/src/render/mixins/InteractionTraceMixin.js b/src/render/mixins/InteractionTraceMixin.js new file mode 100644 index 0000000..e4f062f --- /dev/null +++ b/src/render/mixins/InteractionTraceMixin.js @@ -0,0 +1,156 @@ +import { screenToTile } from '../../utils/IsoMath.js'; +import { SB_W, TOP_H } from '../renderConstants.js'; + +const TRACE_LIMIT = 10; +const MENU_NAMES = ['Game', 'Kingdom', 'View', 'Orders', 'Advisors', 'World', 'Cheat', 'Civilopedia']; + +/** @param {typeof import('../MapRenderer.js').MapRenderer} MapRenderer */ +export function applyInteractionTraceMixin(MapRenderer) { + MapRenderer.prototype._initInteractionTrace = function() { + this._interactionTrace = []; + this._interactionTraceSequence = 0; + this._activeClickTrace = null; + }; + + MapRenderer.prototype._interactionScreen = function() { + if (this._titleScreen) { + if (this._hallOfFame) return { name: 'hall-of-fame' }; + if (this._creditsScreen) return { name: 'credits' }; + return { name: 'title' }; + } + if (this._wizard) return { name: 'new-game-wizard', step: this._wizard.step ?? null }; + if (this._bugReportDialog) return { name: 'bug-report', status: this._bugReportDialog.status }; + if (this._retireStage) return { name: 'retirement', stage: this._retireStage }; + if (this.gameState?.gameOver) return { name: this._replayMap ? 'replay-map' : 'game-over' }; + if (this._openMenu !== null) return { name: 'menu', menu: MENU_NAMES[this._openMenu] ?? `menu-${this._openMenu}` }; + if (this._tradeDialog) return { name: 'trade-dialog' }; + if (this._diplomatDialog) return { name: 'diplomat-dialog' }; + if (this._aiPeaceProposal) return { name: 'peace-proposal' }; + if (this._highCouncil) return { name: 'high-council' }; + if (this._advancePopup) return { name: 'advance-discovered', advanceId: this._advancePopup.advId ?? null }; + if (this._wonderSplash) return { name: 'wonder-splash', wonderId: this._wonderSplash.id ?? null }; + if (this._scenarioCivChooser) return { name: 'scenario-civilization-chooser' }; + if (this._throneUpgradeDialog) return { name: 'throne-room-upgrade' }; + if (this._palaceView) return { name: 'palace-view' }; + if (this._throneRoom) return { name: 'throne-room' }; + if (this._replayMap) return { name: 'replay-map' }; + if (this._cityNamingDialog) return { name: 'city-naming' }; + if (this._cityFoundedDialog) return { name: 'city-founded' }; + if (this._findCityDialog) return { name: 'find-city' }; + if (this._editTechsDialog) return { name: 'edit-technologies' }; + if (this._editUnitDialog) return { name: 'edit-unit' }; + if (this._editCityDialog) return { name: 'edit-city' }; + if (this._editKingDialog) return { name: 'edit-king' }; + if (this._captureDialog) return { name: 'city-captured', cityId: this._captureDialog.city?.id ?? null }; + if (this._tileInfoPopup) return { name: 'tile-information', col: this._tileInfoPopup.col, row: this._tileInfoPopup.row }; + if (this._cityScreen) { + return { + name: this._cityScreenProdList ? 'city-production' : 'city', + cityId: this._cityScreen.id ?? null, + tab: this._cityScreenTab, + }; + } + if (this._unitMenu) return { name: 'unit-menu', unitId: this._unitMenu.unit?.id ?? null }; + if (this._govtChooser) return { name: 'government-chooser' }; + if (this._rateDialog) return { name: 'tax-rate' }; + if (this._negotiationScreen) return { name: 'negotiation', phase: this._negotiationScreen.phase ?? null }; + if (this._diplomacyScreen) return { name: 'foreign-advisor' }; + if (this._scienceAdvisor) return { name: 'science-advisor' }; + if (this._tradeAdvisor) return { name: 'trade-advisor' }; + if (this._domesticAdvisor) return { name: 'city-status' }; + if (this._militaryAdvisor) return { name: 'defense-minister' }; + if (this._attitudeAdvisor) return { name: 'attitude-advisor' }; + if (this._demographicsScreen) return { name: 'demographics' }; + if (this._top5Cities) return { name: 'top-five-cities' }; + if (this._hallOfFame) return { name: 'hall-of-fame' }; + if (this._wondersList) return { name: 'wonders-of-the-world' }; + if (this._gameOptionsDialog) return { name: 'game-options' }; + if (this._graphicOptionsDialog) return { name: 'graphic-options' }; + if (this._cityReportOptionsDialog) return { name: 'city-report-options' }; + if (this._casualtyDialog) return { name: 'casualty-timeline' }; + if (this._spaceshipViewer) return { name: 'spaceships' }; + if (this._civilopedia) { + return { + name: 'civilopedia', + tab: this._civilopedia.tab, + mode: this._civilopedia.mode ?? null, + }; + } + if (this._researchChooser) { + return { name: this._researchGoalDialog ? 'research-goal' : 'research-chooser' }; + } + return { name: 'map' }; + }; + + MapRenderer.prototype._interactionContext = function() { + const gs = this.gameState; + const active = gs?.activeUnit; + return { + turn: gs?.turn ?? null, + activeCivilization: gs?.activeCivIdx ?? null, + activeUnitId: active?.id ?? null, + activeUnitTile: active ? { col: active.col, row: active.row } : null, + cityScreenId: this._cityScreen?.id ?? null, + }; + }; + + MapRenderer.prototype._interactionMapTile = function(px, py, canvasW, screen) { + if (screen.name !== 'map' || px >= canvasW - SB_W || py < TOP_H) return null; + const raw = screenToTile(px, py, this.viewX, this.viewY, this._getZoomScale()); + if (!raw || raw.row < 0 || raw.row >= this.mapRows) return null; + return { + col: ((raw.col % this.mapCols) + this.mapCols) % this.mapCols, + row: raw.row, + }; + }; + + MapRenderer.prototype._beginClickTrace = function(px, py, canvasW, canvasH, kind = 'click') { + if (!this._interactionTrace) this._initInteractionTrace(); + const screenBefore = this._interactionScreen(); + const entry = { + sequence: ++this._interactionTraceSequence, + at: new Date().toISOString(), + kind, + screenBefore, + screenAfter: null, + pointer: { x: Math.round(px), y: Math.round(py), canvasWidth: canvasW, canvasHeight: canvasH }, + mapTile: this._interactionMapTile(px, py, canvasW, screenBefore), + contextBefore: this._interactionContext(), + contextAfter: null, + action: null, + }; + this._interactionTrace.push(entry); + if (this._interactionTrace.length > TRACE_LIMIT) this._interactionTrace.splice(0, this._interactionTrace.length - TRACE_LIMIT); + this._activeClickTrace = entry; + return entry; + }; + + MapRenderer.prototype._finishClickTrace = function(entry) { + entry.screenAfter = this._interactionScreen(); + entry.contextAfter = this._interactionContext(); + if (this._activeClickTrace === entry) this._activeClickTrace = null; + }; + + MapRenderer.prototype._annotateCurrentClickTrace = function(source, id) { + if (this._activeClickTrace) this._activeClickTrace.action = { source, id }; + }; + + MapRenderer.prototype._interactionTraceForReport = function() { + return (this._interactionTrace ?? []).map(entry => ({ + ...entry, + screenBefore: { ...entry.screenBefore }, + screenAfter: entry.screenAfter ? { ...entry.screenAfter } : null, + pointer: { ...entry.pointer }, + mapTile: entry.mapTile ? { ...entry.mapTile } : null, + contextBefore: { + ...entry.contextBefore, + activeUnitTile: entry.contextBefore.activeUnitTile ? { ...entry.contextBefore.activeUnitTile } : null, + }, + contextAfter: entry.contextAfter ? { + ...entry.contextAfter, + activeUnitTile: entry.contextAfter.activeUnitTile ? { ...entry.contextAfter.activeUnitTile } : null, + } : null, + action: entry.action ? { ...entry.action } : null, + })); + }; +} diff --git a/src/render/mixins/WizardMixin.js b/src/render/mixins/WizardMixin.js index 6412202..fdcbe26 100644 --- a/src/render/mixins/WizardMixin.js +++ b/src/render/mixins/WizardMixin.js @@ -436,7 +436,8 @@ export function applyWizardMixin(MapRenderer) { if (finished) return; finished = true; if (video.parentNode) video.parentNode.removeChild(video); - document.removeEventListener('keydown', skipOnKey); + document.removeEventListener('keydown', skipOnKey, true); + this._eventVideoSkipHandler = null; this._eventVideo = null; // Resume CD music after video if (wasPlayingMusic) { @@ -448,8 +449,12 @@ export function applyWizardMixin(MapRenderer) { video.addEventListener('ended', finish); video.addEventListener('click', finish); - const skipOnKey = (e) => { if (e.key === 'Escape' || e.key === ' ' || e.key === 'Enter') finish(); }; - document.addEventListener('keydown', skipOnKey); + const skipOnKey = (e) => { + e.preventDefault(); + e.stopImmediatePropagation(); + if (e.key === 'Escape' || e.key === ' ' || e.key === 'Enter') finish(); + }; + document.addEventListener('keydown', skipOnKey, true); this._eventVideoSkipHandler = skipOnKey; video.addEventListener('canplay', async () => { @@ -468,7 +473,7 @@ export function applyWizardMixin(MapRenderer) { MapRenderer.prototype._stopEventVideo = function() { if (this._eventVideoSkipHandler) { - document.removeEventListener('keydown', this._eventVideoSkipHandler); + document.removeEventListener('keydown', this._eventVideoSkipHandler, true); this._eventVideoSkipHandler = null; } if (this._eventVideo) { @@ -1007,10 +1012,13 @@ export function applyWizardMixin(MapRenderer) { // Step 6: Select Your Tribe — 3-column grid with color swatches MapRenderer.prototype._drawWizardStep6 = function(ctx, cw, ch) { const FS = "'Times New Roman','Tinos',Times,serif"; - const PW = 920, PH = 304; + // MGE's tribe chooser fits inside an 800×600 desktop. Keep the wider + // reconstruction at large sizes, but contract the three equal columns + // instead of allowing the dialog to run off either edge. + const PW = Math.min(920, Math.max(620, cw - 20)), PH = 304; const { px, py } = this._drawWizardPanel(ctx, cw, ch, PW, PH, 'Select Your Tribe'); - const COL_W = 300, ROW_H = 32; + const COL_W = Math.floor((PW - 28) / 3), ROW_H = 32; const ROWS = 7; const startX = px + 14, startY = py + 34; diff --git a/tests/game.spec.js b/tests/game.spec.js index 7c0e3b2..37b5ec8 100644 --- a/tests/game.spec.js +++ b/tests/game.spec.js @@ -952,6 +952,26 @@ test.describe('New Game Dialog', () => { expect(await renderAt(0, { customizeStep: 0 })).toEqual({ x: 390, y: 311, w: 500, h: 178 }); }); + test('tribe selector fits the original 800-pixel desktop width', async ({ page }) => { + await page.setViewportSize({ width: 800, height: 600 }); + await openWizard(page); + const layout = await page.evaluate(() => { + const screen = window.__civ2.mapScreen; + const canvas = document.getElementById('game-canvas'); + screen._wizard.step = 6; + screen.render(canvas.getContext('2d'), canvas.width, canvas.height); + return { + dialog: screen._wizardDialogRect, + controls: screen._wizardRects.map(r => ({ x: r.x, right: r.x + r.w })), + width: canvas.width, + }; + }); + + expect(layout.dialog.x).toBeGreaterThanOrEqual(0); + expect(layout.dialog.x + layout.dialog.w).toBeLessThanOrEqual(layout.width); + expect(layout.controls.every(r => r.x >= 0 && r.right <= layout.width)).toBe(true); + }); + test('Cancel returns to title screen', async ({ page }) => { await openWizard(page); // Pressing Escape on step 0 returns to title screen @@ -1418,13 +1438,11 @@ test.describe('Bug reports', () => { const screen = window.__civ2.mapScreen; const canvas = document.getElementById('game-canvas'); - // Render the Game dropdown to prove the feature is exposed as a genuine - // menu item, then close it before capturing the underlying game scene. + // Render and click the genuine Game-menu item. The click itself must be + // present in the diagnostic traceback while the menu stays out of the PNG. screen._openMenu = 0; screen.render(canvas.getContext('2d'), canvas.width, canvas.height); const menuItem = screen._menuItemRects.find(item => item.action === 'game_reportbug'); - screen._openMenu = null; - screen._menuItemRects = []; screen.viewX = 96; screen.viewY = 48; @@ -1437,7 +1455,7 @@ test.describe('Bug reports', () => { reportDrawsDuringCapture++; return originalDraw.apply(this, args); }; - screen._executeMenuAction(menuItem.action); + screen.handleRawClick(menuItem.x + menuItem.w / 2, menuItem.y + menuItem.h / 2, canvas.width, canvas.height); const drawsBeforeNextTask = reportDrawsDuringCapture; const file = await screen._bugReportDialog.promise; screen._drawBugReportDialog = originalDraw; @@ -1478,6 +1496,7 @@ test.describe('Bug reports', () => { restoredTurn: restored.turn, capturedTurn: stateBefore.turn, rendererState: report.rendererState, + recentClicks: report.recentClicks, reportFormat: report.format, drawsBeforeNextTask, }; @@ -1497,10 +1516,64 @@ test.describe('Bug reports', () => { expect(result.stateMatches).toBe(true); expect(result.restoredTurn).toBe(result.capturedTurn); expect(result.rendererState).toMatchObject({ viewX: 96, viewY: 48, zoomLevel: 2 }); + expect(result.recentClicks.at(-1)).toMatchObject({ + kind: 'click', + screenBefore: { name: 'menu', menu: 'Game' }, + screenAfter: { name: 'bug-report', status: 'capturing' }, + action: { source: 'menu', id: 'game_reportbug' }, + }); expect(result.reportFormat).toBe('civ2-web-bug-report'); expect(result.drawsBeforeNextTask, 'report UI must not appear inside its own screenshot').toBe(0); }); + test('click traceback keeps only ten screen transitions and excludes typed city names', async ({ page }) => { + const result = await page.evaluate(async () => { + const screen = window.__civ2.mapScreen; + const canvas = document.getElementById('game-canvas'); + screen._initInteractionTrace(); + + // Safe title-bar clicks exercise the real canvas click entry point without + // changing game state. + for (let i = 0; i < 12; i++) { + screen.handleRawClick(20 + i, 5, canvas.width, canvas.height); + await Promise.resolve(); + } + + // A city name is user-entered text and must never be copied into the trace. + const privateName = 'PRIVATE CITY NAME MUST NOT LEAK'; + screen._cityNamingDialog = { name: privateName, cursor: privateName.length }; + screen.handleRawClick(40, 5, canvas.width, canvas.height); + await Promise.resolve(); + screen._cityNamingDialog = null; + + // Include one ordinary map context click to verify resolved tile + // coordinates and right-click coverage. + screen.gameState.activeCivIdx = 0; + screen.handleRightClick(600, 400, canvas.width, canvas.height); + await Promise.resolve(); + + const trace = screen._interactionTraceForReport(); + return { + trace, + serialized: JSON.stringify(trace), + privateName, + }; + }); + + expect(result.trace).toHaveLength(10); + expect(result.trace[0].sequence).toBe(5); + expect(result.trace.at(-1).sequence).toBe(14); + expect(result.trace.at(-2).screenBefore).toEqual({ name: 'city-naming' }); + expect(result.trace.at(-1).mapTile).toEqual({ + col: expect.any(Number), + row: expect.any(Number), + }); + expect(result.trace.at(-1).kind).toBe('right-click'); + expect(result.trace.at(-1).screenBefore).toEqual({ name: 'map' }); + expect(result.trace.at(-1).screenAfter.name).toBe('tile-information'); + expect(result.serialized).not.toContain(result.privateName); + }); + test('Share / Email passes the ZIP to the system share sheet and GitHub opens a prepared issue', async ({ page }) => { const result = await page.evaluate(async () => { const screen = window.__civ2.mapScreen; @@ -2260,6 +2333,7 @@ test.describe('Diplomacy System', () => { // Find a living AI civ const aiCiv = gs.civs.find(c => c.id !== 0 && c.alive); if (!aiCiv) return { skip: true }; + gs.establishContact(0, aiCiv.id); // Force war gs.civs[0].relations.set(aiCiv.id, 'war'); gs.civs[aiCiv.id].relations.set(0, 'war'); @@ -2284,6 +2358,7 @@ test.describe('Diplomacy System', () => { const gs = window.__civ2.mapScreen.gameState; const aiCiv = gs.civs.find(c => c.id !== 0 && c.alive); if (!aiCiv) return { skip: true }; + gs.establishContact(0, aiCiv.id); // Ensure peace gs.civs[0].relations.set(aiCiv.id, 'peace'); gs.civs[aiCiv.id].relations.set(0, 'peace'); @@ -2327,6 +2402,7 @@ test.describe('Diplomacy System', () => { const gs = window.__civ2.mapScreen.gameState; const aiCiv = gs.civs.find(c => c.id !== 0 && c.alive); if (!aiCiv) return { skip: true }; + gs.establishContact(0, aiCiv.id); gs.civs[0].gold = 200; gs.civs[aiCiv.id].attitude.set(0, 0); // start at neutral const goldBefore = gs.civs[0].gold; @@ -3121,6 +3197,7 @@ test.describe('Zone of Control', () => { const mover = gs._spawnUnit(2, 0, pos.src.col, pos.src.row); mover.movesLeft = 30; gs._spawnUnit(2, 1, pos.enemy.col, pos.enemy.row); // enemy adjacent to both src & dst + gs.establishContact(0, 1); gs.declareWar(1); const moved = gs.moveUnit(mover, pos.dst.col, pos.dst.row); return { moved, movedCol: mover.col, srcCol: pos.src.col }; @@ -3156,6 +3233,7 @@ test.describe('Zone of Control', () => { const mover = gs._spawnUnit(46, 0, pos.src.col, pos.src.row); mover.movesLeft = 30; gs._spawnUnit(2, 1, pos.enemy.col, pos.enemy.row); + gs.establishContact(0, 1); gs.declareWar(1); const moved = gs.moveUnit(mover, pos.dst.col, pos.dst.row); return { moved, dstCol: pos.dst.col, movedCol: mover.col }; @@ -3191,6 +3269,7 @@ test.describe('Zone of Control', () => { mover.movesLeft = 30; gs._spawnUnit(2, 0, pos.dst.col, pos.dst.row); // friendly unit at destination gs._spawnUnit(2, 1, pos.enemy.col, pos.enemy.row); + gs.establishContact(0, 1); gs.declareWar(1); const moved = gs.moveUnit(mover, pos.dst.col, pos.dst.row); return { moved }; @@ -4119,107 +4198,22 @@ test.describe('Score Formula', () => { }); }); -test.describe('Diplomatic Victory', () => { - test('proposeUnElection wins when majority civs have positive attitude', async ({ page }) => { - await gotoGame(page); - await startTestGame(page); - - const result = await page.evaluate(() => { - const gs = window.__civ2.mapScreen.gameState; - const settler = gs.units.find(u => u.civId === 0); - gs.foundCity(settler); - const city = gs.cities.find(c => c.civId === 0); - // Give human the UN wonder - city.improvements.add(63); - - // Set all AI civs to positive attitude toward human - for (const civ of gs.civs.slice(1)) { - if (!civ || !civ.alive) continue; - civ.attitude.set(0, 50); // positive - } - - const electionResult = gs.proposeUnElection(); - return { - eligible: electionResult.eligible, - won: electionResult.won, - gameOverResult: gs.gameOver?.result, - }; - }); - - expect(result.eligible).toBe(true); - expect(result.won).toBe(true); - expect(result.gameOverResult).toBe('diplomatic-win'); - }); - - test('proposeUnElection fails when majority civs have negative attitude', async ({ page }) => { +test.describe('MGE Victory Conditions', () => { + test('United Nations cannot trigger a non-MGE diplomatic victory', async ({ page }) => { await gotoGame(page); await startTestGame(page); - const result = await page.evaluate(() => { const gs = window.__civ2.mapScreen.gameState; const settler = gs.units.find(u => u.civId === 0); gs.foundCity(settler); - const city = gs.cities.find(c => c.civId === 0); - city.improvements.add(63); - - // Set all AI civs to negative attitude toward human - for (const civ of gs.civs.slice(1)) { - if (!civ || !civ.alive) continue; - civ.attitude.set(0, -50); // negative - } - - const electionResult = gs.proposeUnElection(); - return { - eligible: electionResult.eligible, - won: electionResult.won, - gameOver: gs.gameOver, - }; + gs.cities.find(c => c.civId === 0).improvements.add(63); + const election = gs.proposeUnElection(); + gs._checkVictory(); + return { election, gameOver: gs.gameOver }; }); - - expect(result.eligible).toBe(true); - expect(result.won).toBe(false); + expect(result.election).toMatchObject({ eligible: false, unsupported: true }); expect(result.gameOver).toBeNull(); }); - - test('proposeUnElection requires owning UN wonder', async ({ page }) => { - await gotoGame(page); - await startTestGame(page); - - const result = await page.evaluate(() => { - const gs = window.__civ2.mapScreen.gameState; - const electionResult = gs.proposeUnElection(); - return { eligible: electionResult.eligible }; - }); - - expect(result.eligible).toBe(false); - }); - - test('election can only be called once', async ({ page }) => { - await gotoGame(page); - await startTestGame(page); - - const result = await page.evaluate(() => { - const gs = window.__civ2.mapScreen.gameState; - const settler = gs.units.find(u => u.civId === 0); - gs.foundCity(settler); - const city = gs.cities.find(c => c.civId === 0); - city.improvements.add(63); - - // Set positive attitudes - for (const civ of gs.civs.slice(1)) { - if (civ?.alive) civ.attitude.set(0, 50); - } - - gs.proposeUnElection(); // first call wins - gs.gameOver = null; // reset to test second call - gs._unElectionUsed; // should be true - const secondResult = gs.proposeUnElection(); - return { alreadyUsed: secondResult.alreadyUsed, unElectionUsed: gs._unElectionUsed }; - }); - - expect(result.alreadyUsed).toBe(true); - expect(result.unElectionUsed).toBe(true); - }); }); test.describe('Transport / Sea Units', () => { @@ -6308,6 +6302,7 @@ test.describe('AI Improvements', () => { } if (!humanCity) return { hasTarget: false }; // Declare war so AI targets human + gs.establishContact(0, 1); gs.declareWar(1); // Spawn AI military unit on a land tile const landTile = gs._landTiles()[0]; @@ -6942,6 +6937,222 @@ test.describe('City Report Options', () => { }); }); +test.describe('Human playthrough regressions', () => { + test('research completion emits civId and opens the discovery report', async ({ page }) => { + await gotoGame(page); + await startTestGame(page); + const result = await page.evaluate(() => { + const ms = window.__civ2.mapScreen; + const gs = ms.gameState; + const civ = gs.civs[0]; + const advance = gs.availableAdvances(0)[0]; + civ.currentResearch = advance.id; + civ.beakers = gs.advanceCost(civ); + let payload = null; + const rendererHandler = gs.onEvent; + gs.onEvent = (type, data) => { + if (type === 'advance') payload = { civId: data.civId, advId: data.advId }; + rendererHandler?.(type, data); + }; + gs._doResearch(civ); + return { payload, popup: ms._advancePopup }; + }); + expect(result.payload.civId).toBe(0); + expect(result.popup).toMatchObject({ advId: result.payload.advId }); + }); + + test('Foreign Advisor lists only civilizations that have been contacted', async ({ page }) => { + await gotoGame(page); + await startTestGame(page, { numCivs: 3 }); + const result = await page.evaluate(() => { + const ms = window.__civ2.mapScreen; + const gs = ms.gameState; + const canvas = document.createElement('canvas'); + canvas.width = 1280; canvas.height = 800; + ms._drawDiplomacyScreen(canvas.getContext('2d'), canvas.width, canvas.height); + const before = ms._diplomacyScreenRects.filter(r => r.action === 'contact').map(r => r.civId); + gs.establishContact(0, 1); + ms._drawDiplomacyScreen(canvas.getContext('2d'), canvas.width, canvas.height); + const after = ms._diplomacyScreenRects.filter(r => r.action === 'contact').map(r => r.civId); + const restored = gs.constructor.fromSaveData(gs.toSaveData()); + return { before, after, savedContact: restored.hasContact(0, 1) }; + }); + expect(result.before).toEqual([]); + expect(result.after).toEqual([1]); + expect(result.savedContact).toBe(true); + }); + + test('a garrisoned city opens before unit or tile actions in both view modes', async ({ page }) => { + await gotoGame(page); + await startTestGame(page); + const result = await page.evaluate(() => { + const ms = window.__civ2.mapScreen; + const gs = ms.gameState; + const settler = gs.units.find(u => u.civId === 0); + const city = gs.foundCity(settler); + gs._spawnUnit(2, 0, city.col, city.row); + ms._viewOnlyMode = true; + ms._handleTileClick({ col: city.col, row: city.row }); + const viewOpened = ms._cityScreen === city; + ms._cityScreen = null; + ms._viewOnlyMode = false; + ms._handleTileClick({ col: city.col, row: city.row }); + return { viewOpened, moveOpened: ms._cityScreen === city }; + }); + expect(result).toEqual({ viewOpened: true, moveOpened: true }); + }); + + test('spaceship requires configuration and explicit launch, then wins on arrival', async ({ page }) => { + await gotoGame(page); + await startTestGame(page); + const result = await page.evaluate(() => { + const gs = window.__civ2.mapScreen.gameState; + const settler = gs.units.find(u => u.civId === 0); + const city = gs.foundCity(settler); + city.ssParts = { 35: 15, 36: 2, 37: 3 }; + gs._apolloBuilt = true; + gs.spaceshipProgress(0); + gs.assignSpaceshipPart(0, 'propulsion'); + gs.assignSpaceshipPart(0, 'fuel'); + gs.assignSpaceshipPart(0, 'habitation'); + gs.assignSpaceshipPart(0, 'lifeSupport'); + gs.assignSpaceshipPart(0, 'solar'); + gs._checkVictory(); + const beforeLaunch = { launched: gs.civs[0].spaceship.launched, gameOver: gs.gameOver }; + const launched = gs.launchSpaceship(0); + const afterLaunch = { gameOver: gs.gameOver, arrivalYear: gs.civs[0].spaceship.arrivalYear }; + gs.turn += Math.ceil(gs.spaceshipStats(0).flightYears); + gs._checkVictory(); + return { beforeLaunch, launched, afterLaunch, result: gs.gameOver?.result }; + }); + expect(result.beforeLaunch).toEqual({ launched: false, gameOver: null }); + expect(result.launched).toBe(true); + expect(result.afterLaunch.gameOver).toBeNull(); + expect(result.result).toBe('space-win'); + }); + + test('completed production repeats instead of clearing to an accidental default', async ({ page }) => { + await gotoGame(page); + await startTestGame(page); + const result = await page.evaluate(() => { + const gs = window.__civ2.mapScreen.gameState; + const settler = gs.units.find(u => u.civId === 0); + const city = gs.foundCity(settler); + city.production = { type: 'unit', id: 2 }; + const completed = gs._completeProduction(city); + return { completed, production: city.production }; + }); + expect(result.completed).toBe(true); + expect(result.production).toEqual({ type: 'unit', id: 2 }); + }); + + test('option dialogs stage changes and Cancel restores the original value', async ({ page }) => { + await gotoGame(page); + await startTestGame(page); + const result = await page.evaluate(() => { + const ms = window.__civ2.mapScreen; + const canvas = document.createElement('canvas'); canvas.width = 1280; canvas.height = 800; + const ctx = canvas.getContext('2d'); + const click = r => ms.handleRawClick(r.x + 2, r.y + 2, canvas.width, canvas.height); + const initial = ms.gameState._gameOptions.soundEffects; + ms._executeMenuAction('game_options'); + ms._drawGameOptionsDialog(ctx, canvas.width, canvas.height); + click(ms._gameOptionsRects.find(r => r.key === 'soundEffects')); + click(ms._gameOptionsRects.find(r => r.key === '_cancel')); + const afterCancel = ms.gameState._gameOptions.soundEffects; + ms._executeMenuAction('game_options'); + ms._drawGameOptionsDialog(ctx, canvas.width, canvas.height); + click(ms._gameOptionsRects.find(r => r.key === 'soundEffects')); + click(ms._gameOptionsRects.find(r => r.key === '_ok')); + return { initial, afterCancel, afterOk: ms.gameState._gameOptions.soundEffects }; + }); + expect(result.afterCancel).toBe(result.initial); + expect(result.afterOk).toBe(!result.initial); + }); + + test('stopping a stale music element cannot start another retry loop', async ({ page }) => { + await gotoGame(page); + await startTestGame(page); + const result = await page.evaluate(() => { + const audioManager = window.__civ2.mapScreen.audio; + const OriginalAudio = window.Audio; + class FakeAudio extends EventTarget { + pause() {} + load() { this.dispatchEvent(new Event('error')); } + removeAttribute() {} + } + window.Audio = FakeAudio; + audioManager._musicReady = true; + audioManager._currentEra = 'ancient'; + audioManager._musicFailureCount = 0; + audioManager._playMusicTrackInternal('Broken Track'); + audioManager._stopMusic(); + const failures = audioManager._musicFailureCount; + const retryScheduled = audioManager._musicRetryTimer !== null; + window.Audio = OriginalAudio; + return { failures, retryScheduled }; + }); + expect(result).toEqual({ failures: 0, retryScheduled: false }); + }); + + test('event videos block advisor shortcuts until the video closes', async ({ page }) => { + await gotoGame(page); + await startTestGame(page); + const result = await page.evaluate(() => { + const ms = window.__civ2.mapScreen; + ms._domesticAdvisor = false; + ms._playEventVideo('ANARCHY0.webm'); + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'F1', bubbles: true, cancelable: true })); + const blocked = !ms._domesticAdvisor && !!ms._eventVideo; + ms._stopEventVideo(); + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'F1', bubbles: true, cancelable: true })); + return { blocked, openedAfter: ms._domesticAdvisor }; + }); + expect(result).toEqual({ blocked: true, openedAfter: true }); + }); + + test('replay map is rendered without the game-over dialog covering it', async ({ page }) => { + await gotoGame(page); + await startTestGame(page); + const result = await page.evaluate(() => { + const ms = window.__civ2.mapScreen; + const canvas = document.createElement('canvas'); canvas.width = 1280; canvas.height = 800; + let replayCalls = 0, gameOverCalls = 0; + const replay = ms._drawReplayMap.bind(ms); + const gameOver = ms._drawGameOver.bind(ms); + ms._drawReplayMap = (...args) => { replayCalls++; return replay(...args); }; + ms._drawGameOver = (...args) => { gameOverCalls++; return gameOver(...args); }; + ms.gameState.gameOver = { result: 'win', score: 1 }; + ms._replayMap = true; + ms.render(canvas.getContext('2d'), canvas.width, canvas.height); + return { replayCalls, gameOverCalls }; + }); + expect(result.replayCalls).toBe(1); + expect(result.gameOverCalls).toBe(0); + }); + + test('Shift+F1 creates a unit only after Cheat Mode is enabled', async ({ page }) => { + await gotoGame(page); + await startTestGame(page); + const result = await page.evaluate(() => { + const ms = window.__civ2.mapScreen; + const gs = ms.gameState; + const unit = gs.units.find(u => u.civId === 0); + ms._editorTargetTile = { col: unit.col, row: unit.row }; + const before = gs.units.length; + const event = { key: 'F1', shiftKey: true, ctrlKey: false, preventDefault() {} }; + ms._handleGameKey(event); + const whileOff = gs.units.length; + ms._executeMenuAction('cheat_toggle'); + ms._handleGameKey(event); + return { before, whileOff, after: gs.units.length, domestic: ms._domesticAdvisor }; + }); + expect(result.whileOff).toBe(result.before); + expect(result.after).toBe(result.before + 1); + expect(result.domestic).toBe(true); + }); +}); + // ─── Dialog Padding Constants ──────────────────────────────────────────────── test.describe('Dialog Padding', () => {