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', () => {