diff --git a/AGENTS.md b/AGENTS.md
index 2fb2653..842778e 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 # 251 Playwright browser tests
+ game.spec.js / site.spec.js # 261 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/CHANGELOG.md b/CHANGELOG.md
index 776c232..91bd80b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,41 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/).
## [Unreleased]
+## [0.6.1] - 2026-08-28
+
+### Added
+
+- Added **Game → Report Bug...**, which creates one ZIP containing a PNG of the
+ current game view, a restorable game-state snapshot, renderer/view state,
+ browser diagnostics, and handling instructions.
+- Added Download, system Share/Email, email-draft fallback, and prefilled GitHub
+ issue routes for bug reports without storing GitHub credentials.
+- Added a responsive in-game preview to the project landing page and refreshed
+ its gameplay and project-page screenshots from the corrected renderer.
+
+### Changed
+
+- Restored the MGE-sized city production chooser, original silver listbox,
+ Times headings, row geometry, unit/building icons, and fixed button layout.
+- Size-one cities continue to offer Settlers and Engineers, matching MGE, but
+ retain the completed production and shields until the city grows.
+- Active units in cities now flash at the original 200ms cadence over the city
+ and garrison flag instead of using a modern selection ring.
+- Expanded browser coverage to 261 Playwright tests.
+
+### Fixed
+
+- Terrain and fog masks now respect transparent chroma-key pixels, removing the
+ black dotted diamonds that appeared around every map square.
+- Reveal-map mode no longer applies fog-edge dithering, and its minimap now
+ shows the complete terrain, including blue ocean tiles, without fog.
+- Corrected minimap terrain lookup so ocean and land use their intended MGE
+ colours in normal play as well as reveal mode.
+- Research chooser headings now widen the compact MGE dialog only when needed,
+ preventing “What discovery shall our wise men pursue?” from overflowing.
+- City garrison flags use the original marker offset and active units visibly
+ animate over occupied cities.
+
## [0.6.0] - 2026-08-28
### Added
diff --git a/README.md b/README.md
index 46c3451..18dbc86 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
-- 251 Playwright browser regression tests
+- 261 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
@@ -52,6 +52,22 @@ npm run build:pages
npm run preview:pages
```
+## Reporting bugs
+
+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.
+
+- **Download ZIP** saves the report locally.
+- **Share / Email** uses the browser or operating system share sheet when file
+ sharing is supported. Otherwise it downloads the ZIP and opens an email draft
+ that tells you which file to attach.
+- **GitHub Issue** downloads the same ZIP and opens a prepared issue. Attach the
+ downloaded file before submitting it.
+
+The static app never asks for or stores GitHub credentials. Bug-report ZIPs can
+contain original MGE artwork, so they should be shared only for diagnosis.
+
## Repository layout
```text
diff --git a/docs/screenshots/project-page.jpg b/docs/screenshots/project-page.jpg
index 323e675..ac91a04 100644
Binary files a/docs/screenshots/project-page.jpg and b/docs/screenshots/project-page.jpg differ
diff --git a/index.html b/index.html
index 6f6281f..e5c2b2f 100644
--- a/index.html
+++ b/index.html
@@ -16,14 +16,20 @@
CIVILIZATION II
-
-
Multiplayer Gold Edition · Recreated for the browser
-
Build a civilization to stand the test of time.
-
A graphics-first recreation of the 1998 classic, preserving its original interface, rules, music, movies, and unmistakable Windows-era character.
-
-
Open the game
-
Before you play
+
+
+
Multiplayer Gold Edition · Recreated for the browser
+
Build a civilization to stand the test of time.
+
A graphics-first recreation of the 1998 classic, preserving its original interface, rules, music, movies, and unmistakable Windows-era character.
+
+
+
+ Current browser build · In-game view
+
@@ -38,7 +44,7 @@
Build a civilization to stand the test of time.
- 251 browser tests
+ 261 browser tests
59 original movies supported
7 world maps
BSD-3 project code
diff --git a/package-lock.json b/package-lock.json
index eb486fe..36cd42b 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "civ2-web",
- "version": "0.6.0",
+ "version": "0.6.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "civ2-web",
- "version": "0.6.0",
+ "version": "0.6.1",
"devDependencies": {
"@playwright/test": "^1.58.2",
"sharp": "^0.35.4",
diff --git a/package.json b/package.json
index d761904..6a97087 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "civ2-web",
- "version": "0.6.0",
+ "version": "0.6.1",
"type": "module",
"scripts": {
"dev": "vite",
diff --git a/public/site/gameplay.jpg b/public/site/gameplay.jpg
new file mode 100644
index 0000000..73cb777
Binary files /dev/null and b/public/site/gameplay.jpg differ
diff --git a/src/engine/GameState.js b/src/engine/GameState.js
index 74c1413..ae7a2ca 100644
--- a/src/engine/GameState.js
+++ b/src/engine/GameState.js
@@ -2068,9 +2068,8 @@ export class GameState {
} else {
city.shields += yields.shields;
const cost = this._productionCost(city.production);
- if (city.shields >= cost) {
+ if (city.shields >= cost && this._completeProduction(city)) {
city.shields = Math.max(0, city.shields - cost);
- this._completeProduction(city);
}
}
}
@@ -2532,7 +2531,9 @@ export class GameState {
if (type === 'unit') {
let spawnCol = city.col, spawnRow = city.row;
const unitData = UNITS[id];
- if (unitData.role === 5 && city.size <= 1) return;
+ // MGE keeps Settlers/Engineers in the production chooser at size 1,
+ // but holds the completed item (and its shields) until the city grows.
+ if (unitData.role === 5 && city.size <= 1) return false;
if (unitData.domain === 2) {
// Sea units must spawn on an adjacent ocean tile (coastal city)
const oceanAdj = neighbours(city.col, city.row, this.mapCols).find(n => {
@@ -2670,6 +2671,7 @@ export class GameState {
} else {
city.production = null;
}
+ return true;
}
// ─── Research ──────────────────────────────────────────────────────────────
@@ -2723,8 +2725,6 @@ export class GameState {
if (u.obsoletedBy !== -1 && civ.advances.has(u.obsoletedBy)) continue;
// Nuclear Missile (id=45) requires Manhattan Project (62) to have been built by any civ
if (u.id === 45 && !this._manhattanBuilt) continue;
- // Settlers/Engineers (role 5) require city size > 1 (axx0 UnitProductionOrder.cs:28-31)
- if (u.role === 5 && city.size <= 1) continue;
items.push({ type: 'unit', id: u.id, name: u.name, cost: u.cost * 10 });
}
diff --git a/src/render/MapRenderer.js b/src/render/MapRenderer.js
index 66cad10..da516a3 100644
--- a/src/render/MapRenderer.js
+++ b/src/render/MapRenderer.js
@@ -25,6 +25,7 @@ import { applyWizardMixin } from './mixins/WizardMixin.js';
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 { GameState } from '../engine/GameState.js';
import { Civ2SaveLoader } from '../engine/Civ2SaveLoader.js';
import { MapLoader } from '../engine/MapLoader.js';
@@ -53,6 +54,8 @@ const MENUS = [
{ label: 'Save As MGE .SAV', action: 'game_save_sav' },
{ label: 'Load Game', action: 'game_load', shortcut: 'Ctrl+L' },
null,
+ { label: 'Report Bug...', action: 'game_reportbug' },
+ null,
{ label: 'Retire', action: 'game_retire', shortcut: 'Ctrl+R' },
{ label: 'Quit', action: 'game_quit', shortcut: 'Ctrl+Q' },
]},
@@ -371,6 +374,7 @@ export class MapRenderer {
this._menuBarRects = []; // [{x, w, menuIdx}] — populated during draw
this._menuItemRects = []; // [{x,y,w,h,action,disabled}] — current dropdown
this._menuHoverIdx = null; // index into _menuItemRects
+ this._bugReportDialog = null;
// Title screen state
this._titleScreen = false;
@@ -925,6 +929,7 @@ export class MapRenderer {
if (this._titleScreen) { this._handleTitleScreenKey(e); return; }
// ── Wizard intercepts all keys when active ──────────────────────────────
if (this._wizard) { this._handleWizardKey(e); return; }
+ if (this._bugReportDialog) { this._handleBugReportKey(e); return; }
if (this._editCityDialog) { this._handleEditCityKey(e); return; }
if (this._editTechsDialog) { this._handleEditTechsKey(e); return; }
if (this._editUnitDialog) { this._handleEditUnitKey(e); return; }
@@ -965,6 +970,7 @@ export class MapRenderer {
const gs = this.gameState;
// Modal dialogs first
+ if (this._bugReportDialog) { this._bugReportDialog = null; return true; }
if (this._civilopedia) { this._civilopedia = null; return true; }
if (this._cityScreen && this._cityScreenProdList) {
this._cityScreenProdList = false;
@@ -1443,6 +1449,11 @@ export class MapRenderer {
return;
}
+ if (this._bugReportDialog) {
+ this._handleBugReportClick(px, py);
+ return;
+ }
+
// ── Retirement flow (POWERgraph → Score → HoF) ──────────────────────────
if (this._retireStage) {
this._handleRetireFlowClick(px, py);
@@ -2468,6 +2479,20 @@ export class MapRenderer {
// Collect cities for name rendering in a separate last pass
if (!this._cityNameQueue) this._cityNameQueue = [];
this._cityNameQueue.push({ city, x, y });
+
+ // In MGE the city and garrison flag remain in the base map, while
+ // the ready unit flashes on top as a separate animation frame.
+ // Queue it for the final map pass. The original MGE waiting frames
+ // then redraw city names over the unit.
+ const active = this.gameState.activeUnit;
+ const combatAtCity = this._combatAnim &&
+ ((col === this._combatAnim.atkCol && row === this._combatAnim.atkRow) ||
+ (col === this._combatAnim.defCol && row === this._combatAnim.defRow));
+ if (v === 2 && active?.col === col && active?.row === row &&
+ (!this._moveAnim || this._moveAnim.unit !== active) && !combatAtCity) {
+ if (!this._activeCityUnitQueue) this._activeCityUnitQueue = [];
+ this._activeCityUnitQueue.push({ unit: active, x, y });
+ }
}
if (v === 2) {
@@ -2563,7 +2588,17 @@ export class MapRenderer {
ctx.strokeRect(fx - 2, fy - 2, tileWS + 4, tileHS + 4);
}
- // Pass LAST — city names drawn on top of everything (axx0 Draw.Map.cs:135-140)
+ // Active units in cities are animation elements in MGE: their sprite and
+ // shield blink over the otherwise-static city and flag.
+ if (this._activeCityUnitQueue) {
+ for (const { unit, x: ux, y: uy } of this._activeCityUnitQueue) {
+ this._drawUnit(ctx, unit, ux, uy, spritesReady);
+ }
+ this._activeCityUnitQueue = null;
+ }
+
+ // Pass LAST — city names remain readable above units, exactly as MGE's
+ // WaitingAnimation redraws them after its active-unit frame.
if (this._cityNameQueue) {
for (const { city, x: cx, y: cy } of this._cityNameQueue) {
this._drawCityName(ctx, city, cx, cy);
@@ -2635,6 +2670,10 @@ export class MapRenderer {
// Game-over screen
if (!this._wizard && !this._retireStage && 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.
+ if (this._bugReportDialog) this._drawBugReportDialog(ctx, canvasW, canvasH);
}
_drawGameOver(ctx, canvasW, canvasH) {
@@ -3136,6 +3175,9 @@ export class MapRenderer {
case 'game_load':
this._loadGame();
return true;
+ case 'game_reportbug':
+ this._openBugReportDialog();
+ return true;
case 'game_options':
this._gameOptionsDialog = true;
this._gameOptionsRects = [];
@@ -3829,6 +3871,7 @@ export class MapRenderer {
this._negotiationScreen = null;
this._aiPeaceProposal = null;
this._captureDialog = null;
+ this._bugReportDialog = null;
this._stopWonderVideo();
this._wonderSplash = null;
this._wonderVideo = null;
@@ -5047,3 +5090,4 @@ applyWizardMixin(MapRenderer);
applyDialogsMixin(MapRenderer);
applyAdvisorsMixin(MapRenderer);
applyInfoScreensMixin(MapRenderer);
+applyBugReportMixin(MapRenderer);
diff --git a/src/render/mixins/BugReportMixin.js b/src/render/mixins/BugReportMixin.js
new file mode 100644
index 0000000..c496a7c
--- /dev/null
+++ b/src/render/mixins/BugReportMixin.js
@@ -0,0 +1,285 @@
+import { FONT } from '../renderConstants.js';
+import { createStoredZip } from '../../utils/bugReportZip.js';
+
+const GITHUB_ISSUES_URL = 'https://github.com/wan0net/civ2/issues/new';
+
+function downloadFile(file) {
+ const url = URL.createObjectURL(file);
+ const anchor = document.createElement('a');
+ anchor.href = url;
+ anchor.download = file.name;
+ anchor.style.display = 'none';
+ document.body.appendChild(anchor);
+ anchor.click();
+ anchor.remove();
+ setTimeout(() => URL.revokeObjectURL(url), 0);
+}
+
+function formatBytes(bytes) {
+ if (bytes < 1024) return `${bytes} bytes`;
+ if (bytes < 1024 * 1024) return `${Math.ceil(bytes / 1024)} KB`;
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
+}
+
+/** @param {typeof import('../MapRenderer.js').MapRenderer} MapRenderer */
+export function applyBugReportMixin(MapRenderer) {
+ MapRenderer.prototype._bugReportRendererState = function() {
+ const city = this._cityScreen;
+ const active = this.gameState.activeUnit;
+ return {
+ viewX: this.viewX,
+ viewY: this.viewY,
+ zoomLevel: this._zoomLevel,
+ viewOnlyMode: this._viewOnlyMode,
+ showGrid: this._showGrid,
+ showHiddenTerrain: this._showHiddenTerrain,
+ activeUnitId: active?.id ?? null,
+ hoveredTile: this._hoveredTile ? { ...this._hoveredTile } : null,
+ cityScreenId: city?.id ?? null,
+ cityProductionListOpen: !!this._cityScreenProdList,
+ civilopedia: this._civilopedia ? {
+ tab: this._civilopedia.tab,
+ selIdx: this._civilopedia.selIdx,
+ scroll: this._civilopedia.scroll,
+ mode: this._civilopedia.mode,
+ } : null,
+ researchChooserOpen: !!this._researchChooser,
+ researchGoalOpen: !!this._researchGoalDialog,
+ openScreens: [
+ '_scienceAdvisor', '_tradeAdvisor', '_domesticAdvisor', '_militaryAdvisor',
+ '_attitudeAdvisor', '_demographicsScreen', '_top5Cities', '_wondersList',
+ '_palaceView', '_throneRoom', '_spaceshipViewer', '_highCouncil',
+ ].filter(key => !!this[key]),
+ };
+ };
+
+ MapRenderer.prototype._buildBugReportFile = async function(rendererState, createdAt = new Date()) {
+ const canvas = document.getElementById('game-canvas');
+ if (!canvas) throw new Error('Game canvas is unavailable.');
+ // Snapshot gameplay first, then render during the same JavaScript turn so
+ // the screenshot and save data describe one state rather than adjacent
+ // animation/update frames.
+ const gameState = this.gameState.toSaveData();
+ const gameYear = this.gameState.year;
+
+ // Render the exact underlying scene without the Game menu or report dialog.
+ const capture = document.createElement('canvas');
+ capture.width = canvas.width;
+ capture.height = canvas.height;
+ this.render(capture.getContext('2d'), capture.width, capture.height);
+ const screenshot = await new Promise((resolve, reject) => {
+ capture.toBlob(blob => blob ? resolve(blob) : reject(new Error('Screenshot capture failed.')), 'image/png');
+ });
+
+ const stamp = createdAt.toISOString().replace(/[:.]/g, '-');
+ const filename = `civ2-bug-report-${stamp}.zip`;
+ const report = {
+ format: 'civ2-web-bug-report',
+ formatVersion: 1,
+ createdAt: createdAt.toISOString(),
+ page: `${location.origin}${location.pathname}`,
+ browser: navigator.userAgent,
+ viewport: {
+ canvasWidth: canvas.width,
+ canvasHeight: canvas.height,
+ cssWidth: canvas.clientWidth,
+ cssHeight: canvas.clientHeight,
+ devicePixelRatio: window.devicePixelRatio,
+ },
+ summary: {
+ turn: gameState.turn,
+ year: gameYear,
+ activeCivilization: gameState.activeCivIdx,
+ activeUnitId: gameState.activeUnitId,
+ mapCols: gameState.mapCols,
+ mapRows: gameState.mapRows,
+ },
+ rendererState,
+ };
+ 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.',
+ '',
+ '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.',
+ '',
+ ].join('\n');
+ const archive = createStoredZip([
+ { name: 'screenshot.png', data: new Uint8Array(await screenshot.arrayBuffer()) },
+ { name: 'game-state.json', data: JSON.stringify(gameState, null, 2) },
+ { name: 'report.json', data: JSON.stringify(report, null, 2) },
+ { name: 'README.txt', data: instructions },
+ ], createdAt);
+ return new File([archive], filename, { type: 'application/zip', lastModified: createdAt.getTime() });
+ };
+
+ MapRenderer.prototype._openBugReportDialog = function() {
+ if (this._bugReportDialog) return;
+ const rendererState = this._bugReportRendererState();
+ // Start the async capture before the dialog exists. _buildBugReportFile()
+ // renders synchronously up to canvas.toBlob(), so the PNG contains the
+ // precise game screen beneath this support UI rather than the UI itself.
+ const capturePromise = this._buildBugReportFile(rendererState);
+ const dialog = this._bugReportDialog = {
+ status: 'capturing',
+ message: 'Capturing screenshot and exact game state...',
+ file: null,
+ rects: [],
+ };
+ dialog.promise = capturePromise
+ .then(file => {
+ if (this._bugReportDialog !== dialog) return file;
+ dialog.file = file;
+ dialog.status = 'ready';
+ dialog.message = `Ready: ${file.name} (${formatBytes(file.size)})`;
+ return file;
+ })
+ .catch(error => {
+ console.error('Bug report capture failed:', error);
+ if (this._bugReportDialog === dialog) {
+ dialog.status = 'error';
+ dialog.message = `Capture failed: ${error.message}`;
+ }
+ return null;
+ });
+ };
+
+ MapRenderer.prototype._downloadBugReport = function() {
+ const dialog = this._bugReportDialog;
+ if (!dialog?.file) return false;
+ downloadFile(dialog.file);
+ dialog.message = `Downloaded ${dialog.file.name}`;
+ return true;
+ };
+
+ MapRenderer.prototype._shareBugReport = async function() {
+ const dialog = this._bugReportDialog;
+ if (!dialog?.file) return false;
+ const shareData = {
+ title: 'Civilization II bug report',
+ text: 'Civ2 browser recreation bug report with screenshot and exact game state.',
+ files: [dialog.file],
+ };
+ try {
+ if (navigator.canShare?.(shareData) && navigator.share) {
+ await navigator.share(shareData);
+ dialog.message = 'Report shared.';
+ return true;
+ }
+ downloadFile(dialog.file);
+ const subject = 'Civilization II bug report';
+ const body = [
+ 'Please attach the downloaded Civ2 bug-report ZIP to this email.',
+ '',
+ `File: ${dialog.file.name}`,
+ `Turn: ${this.gameState.turn}`,
+ '',
+ 'The ZIP contains a screenshot, exact game state, view state, and diagnostics.',
+ ].join('\n');
+ window.open(`mailto:?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`,
+ '_blank', 'noopener,noreferrer');
+ dialog.message = 'Email draft opened. Attach the downloaded ZIP before sending.';
+ return false;
+ } catch (error) {
+ if (error?.name === 'AbortError') {
+ dialog.message = 'Sharing cancelled. The report is still ready.';
+ return false;
+ }
+ console.error('Bug report sharing failed:', error);
+ dialog.message = 'Sharing failed. Use Download instead.';
+ return false;
+ }
+ };
+
+ MapRenderer.prototype._openGitHubBugReport = function() {
+ const dialog = this._bugReportDialog;
+ if (!dialog?.file) return false;
+ downloadFile(dialog.file);
+ const reportName = dialog.file.name;
+ const turn = this.gameState.turn;
+ const body = [
+ '## Bug description',
+ '',
+ '',
+ '',
+ '## Reproduction package',
+ '',
+ `Turn: ${turn}`,
+ `Please attach the downloaded \`${reportName}\` file to this issue.`,
+ 'It contains a screenshot, exact game state, view state, and diagnostics.',
+ ].join('\n');
+ const url = `${GITHUB_ISSUES_URL}?title=${encodeURIComponent('[Bug] ')}&body=${encodeURIComponent(body)}`;
+ window.open(url, '_blank', 'noopener,noreferrer');
+ dialog.message = 'GitHub opened. Attach the downloaded ZIP before submitting.';
+ return true;
+ };
+
+ MapRenderer.prototype._drawBugReportDialog = function(ctx, canvasW, canvasH) {
+ const dialog = this._bugReportDialog;
+ if (!dialog) return;
+ const PW = Math.min(680, canvasW - 40);
+ const PH = 250;
+ const px = Math.round((canvasW - PW) / 2);
+ const py = Math.round((canvasH - PH) / 2);
+ ctx.fillStyle = 'rgba(0,0,0,0.48)';
+ ctx.fillRect(0, 0, canvasW, canvasH);
+ this._drawCiv2Panel(ctx, px, py, PW, PH, 'Report a Bug');
+
+ ctx.font = FONT.BODY_TIMES;
+ this._panelText(ctx, 'Create one support package containing:', px + 24, py + 56);
+ const lines = [
+ 'Screenshot of the exact visible game',
+ 'Full restorable game-state snapshot',
+ 'Camera, zoom, open-screen, browser, and viewport details',
+ ];
+ 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);
+
+ ctx.font = FONT.SMALL_BOLD;
+ ctx.fillStyle = dialog.status === 'error' ? '#a00000' : '#000000';
+ ctx.fillText(dialog.message, px + 24, py + 184, PW - 48);
+
+ const labels = ['Download ZIP', 'Share / Email', 'GitHub Issue', 'Close'];
+ const gap = 10;
+ const bw = Math.floor((PW - 48 - gap * 3) / 4);
+ const by = py + PH - 39;
+ dialog.rects = [];
+ labels.forEach((label, index) => {
+ const bx = px + 24 + index * (bw + gap);
+ this._drawWin95Button(ctx, bx, by, bw, 26, label);
+ dialog.rects.push({ action: ['download', 'share', 'github', 'close'][index], x: bx, y: by, w: bw, h: 26 });
+ });
+
+ if (dialog.status !== 'ready') {
+ for (const rect of dialog.rects.slice(0, 3)) {
+ ctx.fillStyle = 'rgba(192,192,192,0.58)';
+ ctx.fillRect(rect.x + 2, rect.y + 2, rect.w - 4, rect.h - 4);
+ }
+ }
+ };
+
+ MapRenderer.prototype._handleBugReportClick = function(px, py) {
+ const dialog = this._bugReportDialog;
+ 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;
+ if (hit.action === 'close') {
+ this._bugReportDialog = null;
+ return;
+ }
+ if (dialog.status !== 'ready') return;
+ if (hit.action === 'download') this._downloadBugReport();
+ if (hit.action === 'share') void this._shareBugReport();
+ if (hit.action === 'github') this._openGitHubBugReport();
+ };
+
+ MapRenderer.prototype._handleBugReportKey = function(event) {
+ if (event.key === 'Escape') this._bugReportDialog = null;
+ if (event.key === 'Enter' && this._bugReportDialog?.status === 'ready') this._downloadBugReport();
+ };
+}
diff --git a/src/render/mixins/CityScreenMixin.js b/src/render/mixins/CityScreenMixin.js
index 37c22c7..a636dee 100644
--- a/src/render/mixins/CityScreenMixin.js
+++ b/src/render/mixins/CityScreenMixin.js
@@ -973,21 +973,29 @@ export function applyCityScreenMixin(MapRenderer) {
return;
}
- // Game.txt @PRODUCTION: width=440, listbox (13 rows), Auto + Help;
- // PopupBoxReader appends the standard OK button.
+ // Game.txt @PRODUCTION: width=440, listbox, Auto + Help; PopupBoxReader
+ // appends OK. MGE displays this as a fixed-size top-level dialog rather
+ // than shrinking it to the number of currently available items. The
+ // virtual geometry below is measured from the original 680x268 dialog.
const items = this.gameState.availableProduction(city);
if (!items.some(item => this._cityScreenProductionSelection?.type === item.type && this._cityScreenProductionSelection?.id === item.id)) {
this._cityScreenProductionSelection = prod ?? items[0] ?? null;
}
- const rowH = 23;
- const rowsVis = Math.max(1, Math.min(13, items.length));
- const OPW = 440;
- const OPH = 34 + rowsVis * rowH + 34;
+ const rowH = 19;
+ const OPW = 544;
+ const OPH = 214;
+ const listTop = 28;
+ const listH = 152;
+ const rowsVis = Math.max(1, Math.min(8, Math.floor(listH / rowH)));
const OPX = (640 - OPW) / 2;
const OPY = (446 - OPH) / 2;
this._cityProductionDialogRect = vfr(OPX, OPY, OPW, OPH);
- ctx.fillStyle = '#9a9a9a';
- vfl(OPX, OPY, OPW, OPH);
+ if (this._innerWallpaper) {
+ this._tilePattern(ctx, this._innerWallpaper, vx(OPX), vy(OPY), vs(OPW), vs(OPH));
+ } else {
+ ctx.fillStyle = '#c0c0c0';
+ vfl(OPX, OPY, OPW, OPH);
+ }
const bColors = ['#e3e3e3', '#696969', '#ffffff', '#a0a0a0', '#f0f0f0', '#dfdfdf', '#434343'];
const bvx = x => vx(OPX + x), bvy = y => vy(OPY + y);
const bvr = x => vx(OPX + OPW - x), bvb = y => vy(OPY + OPH - y);
@@ -1007,16 +1015,29 @@ export function applyCityScreenMixin(MapRenderer) {
ctx.stroke();
});
- ctx.fillStyle = '#8a8a8a';
- vfl(OPX + 5, OPY + 31, OPW - 10, rowsVis * rowH);
- ctx.font = vfont(13);
+ // MGE listbox: flat silver field with a thin sunken edge. Empty space is
+ // retained below short early-game build lists.
+ ctx.fillStyle = '#c0c0c0';
+ vfl(OPX + 5, OPY + listTop, OPW - 10, listH);
+ ctx.strokeStyle = '#404040';
+ ctx.lineWidth = 1;
+ ctx.strokeRect(vx(OPX + 5) + 0.5, vy(OPY + listTop) + 0.5,
+ vs(OPW - 10) - 1, vs(listH) - 1);
+ ctx.strokeStyle = '#ffffff';
+ ctx.strokeRect(vx(OPX + 6) + 0.5, vy(OPY + listTop + 1) + 0.5,
+ vs(OPW - 12) - 1, vs(listH - 2) - 1);
+
+ // Original embossed Times title (not the gold city-screen heading).
+ ctx.font = `bold ${vs(15)}px ${FONT_TIMES}`;
ctx.textAlign = 'center';
- shc(`What shall we build in ${city.name}?`, OPX + OPW / 2, OPY + 22, CLR.GOLD, C_HDR_SH);
+ ctx.fillStyle = '#ffffff';
+ ctx.fillText(`What shall we build in ${city.name}?`, vx(OPX + OPW / 2) + 1, vy(OPY + 21) + 1);
+ ctx.fillStyle = '#3f3f3f';
+ ctx.fillText(`What shall we build in ${city.name}?`, vx(OPX + OPW / 2), vy(OPY + 21));
ctx.textAlign = 'left';
this._cityScreenQueueModeRect = null;
this._cityScreenTabRects = [];
- const listY = OPY + 31;
- const listH = rowsVis * rowH;
+ const listY = OPY + listTop;
const scroll2 = Math.max(0, Math.min(this._cityScreenScroll, Math.max(0, items.length - rowsVis)));
this._cityScreenScroll = scroll2;
this._cityScreenItemRects = [];
@@ -1024,38 +1045,39 @@ export function applyCityScreenMixin(MapRenderer) {
const item = items[i + scroll2];
const iy = listY + i * rowH;
const isSelected = this._cityScreenProductionSelection?.type === item.type && this._cityScreenProductionSelection?.id === item.id;
- ctx.fillStyle = isSelected ? '#000080' : '#878787';
- vfl(OPX + 6, iy, OPW - 12, rowH - 1);
+ ctx.fillStyle = isSelected ? '#808080' : '#c0c0c0';
+ vfl(OPX + 6, iy, OPW - 12, rowH);
try {
if (item.type === 'unit') {
const spr = this.sprites.getSprite('units', Math.floor(item.id / 9), item.id % 9);
- if (spr) ctx.drawImage(spr, vx(OPX + 8), vy(iy), vs(30), vs(22));
+ const imageShift = i % 2 === 1 ? 16 : 0;
+ if (spr) ctx.drawImage(spr, vx(OPX + 8 + imageShift), vy(iy), vs(30), vs(19));
} else {
const wonder = item.id >= 39;
const index = wonder ? item.id - 39 : item.id - 1;
const cols = wonder ? 7 : 8;
const spr = this.sprites.getRegionSprite('icons', 343 + (index % cols) * 37,
(wonder ? 106 : 1) + Math.floor(index / cols) * 21, 36, 20);
- if (spr) ctx.drawImage(spr, vx(OPX + 8), vy(iy + 1), vs(36), vs(20));
+ if (spr) ctx.drawImage(spr, vx(OPX + 8), vy(iy + 1), vs(36), vs(17));
}
} catch (e) {
_warnOnce(`production-icon-${item.type}-${item.id}`, `Production icon unavailable: ${e.message}`);
}
- ctx.font = vtfont(12);
+ ctx.font = `bold ${vs(13)}px ${FONT_TIMES}`;
ctx.fillStyle = isSelected ? '#ffffff' : '#000000';
- vtx(item.name, OPX + 49, iy + 16);
+ vtx(item.name, OPX + 70, iy + 15);
const perTurn = Math.max(1, this.gameState.cityYields(city).shields);
const turns = Math.max(1, Math.ceil(Math.max(0, item.cost - city.shields) / perTurn));
const unit = item.type === 'unit' ? UNITS[item.id] : null;
const facts = unit
? `(${turns} Turns, ADM: ${unit.attack}/${unit.defense}/${unit.move} HP: ${unit.hp}/${unit.fp})`
: `(${turns} Turns)`;
- ctx.font = vtfont(10);
+ ctx.font = `bold ${vs(11)}px ${FONT_TIMES}`;
ctx.fillStyle = isSelected ? '#ffffff' : '#222222';
ctx.textAlign = 'right';
- ctx.fillText(facts, vx(OPX + OPW - 11), vy(iy + 16));
+ ctx.fillText(facts, vx(OPX + OPW - 9), vy(iy + 15));
ctx.textAlign = 'left';
- this._cityScreenItemRects.push({ ...vfr(OPX + 6, iy, OPW - 12, rowH - 1), item });
+ this._cityScreenItemRects.push({ ...vfr(OPX + 6, iy, OPW - 12, rowH), item });
}
if (items.length > rowsVis) {
diff --git a/src/render/mixins/DialogsMixin.js b/src/render/mixins/DialogsMixin.js
index 42d512c..9f7020e 100644
--- a/src/render/mixins/DialogsMixin.js
+++ b/src/render/mixins/DialogsMixin.js
@@ -1427,9 +1427,20 @@ export function applyDialogsMixin(MapRenderer) {
? available.filter(a => goalIds.includes(a.id))
: available;
- // Game.txt @RESEARCH: width=300, listbox, Help + Goal. PopupBoxReader
- // appends the standard OK button used to accept the highlighted row.
- const PW = Math.min(300, canvasW - 20);
+ const scienceEpoch = Math.max(0, ...[...civ.advances].map(id => ADVANCES[id]?.epoch ?? 0));
+ const title = goalIds
+ ? 'Then we should research'
+ : `What discovery shall our ${scienceEpoch >= 3 ? 'scientists' : 'wise men'} pursue?`;
+
+ // Game.txt @RESEARCH requests a 300px-wide listbox, but Civ2's dynamic
+ // dialog sizing treats that as a minimum and widens the panel when its
+ // Times New Roman heading is longer. Keep the title inside the bevel while
+ // preserving the compact 300px panel for shorter headings.
+ ctx.save();
+ ctx.font = FONT.TITLE_LARGE;
+ const titleWidth = Math.ceil(ctx.measureText(title).width) + 18;
+ ctx.restore();
+ const PW = Math.min(Math.max(300, titleWidth), canvasW - 20);
const rowsVis = Math.max(1, Math.min(16, avail.length));
const ITEM_H = 23;
const HDR = 34;
@@ -1439,10 +1450,6 @@ export function applyDialogsMixin(MapRenderer) {
const py = Math.round((canvasH - PH) / 2);
this._researchChooserRect = { x: px, y: py, w: PW, h: PH };
- const scienceEpoch = Math.max(0, ...[...civ.advances].map(id => ADVANCES[id]?.epoch ?? 0));
- const title = goalIds
- ? 'Then we should research'
- : `What discovery shall our ${scienceEpoch >= 3 ? 'scientists' : 'wise men'} pursue?`;
this._drawCiv2Panel(ctx, px, py, PW, PH, title);
const listY0 = py + HDR;
diff --git a/src/render/mixins/SidebarMixin.js b/src/render/mixins/SidebarMixin.js
index a94a46e..f151efc 100644
--- a/src/render/mixins/SidebarMixin.js
+++ b/src/render/mixins/SidebarMixin.js
@@ -80,17 +80,17 @@ export function applySidebarMixin(MapRenderer) {
} else {
for (let row = 0; row < this.mapRows; row++) {
for (let col = 0; col < this.mapCols; col++) {
- const v = vis[row][col];
+ const v = this._showHiddenTerrain ? 2 : vis[row][col];
if (v === 0) continue;
const t = tiles[row][col];
- const baseColor = t.terrain?.id === 7 ? CLR.MM_OCEAN : CLR.MM_LAND;
+ const baseColor = t?.id === 7 ? CLR.MM_OCEAN : CLR.MM_LAND;
ctx.fillStyle = v === 2 ? baseColor : this._darkenHex(baseColor, 0.5);
const px = mapX + col * mmTileW + (row % 2 ? Math.floor(mmTileW / 2) : 0);
ctx.fillRect(px, mapY + row * mmTileH, mmTileW, mmTileH);
}
}
for (const city of gs.cities) {
- const v = vis[city.row]?.[city.col] ?? 0;
+ const v = this._showHiddenTerrain ? 2 : (vis[city.row]?.[city.col] ?? 0);
if (v === 0) continue;
const civ = gs.civs[city.civId];
const color = CIV_COLORS[civ?.data?.color ?? 0];
@@ -478,16 +478,21 @@ export function applySidebarMixin(MapRenderer) {
const mapCol = ((Math.floor(mapColF) % mapCols) + mapCols) % mapCols;
const tileKey = clampedRow * mapCols + mapCol;
+ const visibility = this._showHiddenTerrain
+ ? 2
+ : (gs._visibility[clampedRow]?.[mapCol] ?? 0);
let r, g, b;
- if (citySet.has(tileKey)) {
+ if (visibility === 0) {
+ r = 0; g = 0; b = 0;
+ } else if (citySet.has(tileKey)) {
const hex = cityColorMap.get(tileKey) || '#ffffff';
r = parseInt(hex.slice(1, 3), 16);
g = parseInt(hex.slice(3, 5), 16);
b = parseInt(hex.slice(5, 7), 16);
} else {
const tile = tiles[clampedRow]?.[mapCol];
- const terrainId = tile?.terrain?.id ?? 7;
+ const terrainId = tile?.id ?? 7;
if (terrainId === 7) {
r = 0; g = 0; b = 128;
} else if (terrainId === 0) {
@@ -499,7 +504,8 @@ export function applySidebarMixin(MapRenderer) {
}
}
- const shade = 0.6 + 0.4 * Math.cos((localX - spanW / 2) / (spanW / 2));
+ const exploredShade = visibility === 1 ? 0.5 : 1;
+ const shade = exploredShade * (0.6 + 0.4 * Math.cos((localX - spanW / 2) / (spanW / 2)));
r = Math.round(r * shade);
g = Math.round(g * shade);
b = Math.round(b * shade);
diff --git a/src/render/mixins/TerrainMixin.js b/src/render/mixins/TerrainMixin.js
index 53a69fa..9aecfab 100644
--- a/src/render/mixins/TerrainMixin.js
+++ b/src/render/mixins/TerrainMixin.js
@@ -191,9 +191,11 @@ export function applyTerrainMixin(MapRenderer) {
// Convert: non-transparent dark pixels → opaque white (mask pass-through)
// Everything else → transparent (mask blocked)
for (let p = 0; p < d.length; p += 4) {
- const r = d[p], g = d[p+1], b = d[p+2];
- // Dark pixels (near-black) = dither dots where neighbour shows
- const isDark = r < 80 && g < 80 && b < 80;
+ const r = d[p], g = d[p+1], b = d[p+2], a = d[p+3];
+ // Dark, opaque pixels are the dither dots where the neighbour shows.
+ // SpriteManager has already keyed the mask's magenta and grey pixels
+ // to transparent black, so alpha must participate in this test.
+ const isDark = a > 0 && r < 80 && g < 80 && b < 80;
if (isDark) {
d[p] = 255; d[p+1] = 255; d[p+2] = 255; d[p+3] = 255;
} else {
@@ -386,6 +388,11 @@ export function applyTerrainMixin(MapRenderer) {
* the only visual cue is a dithered edge at boundaries with unexplored areas.
*/
MapRenderer.prototype._drawFogDither = function(ctx, x, y, col, row) {
+ // axx0 MapImage.cs suppresses fog-edge dithering when MapRevealed is set.
+ // Otherwise every normally hidden tile sees four hidden neighbours and
+ // the revealed map becomes a distracting grid of black dotted diamonds.
+ if (this._showHiddenTerrain) return;
+
const vis = this.gameState._visibility;
const o = row % 2;
// Direct (diagonal) neighbours: NE, SE, SW, NW — matching axx0's directNeighbours order
@@ -423,8 +430,12 @@ export function applyTerrainMixin(MapRenderer) {
const maskData = maskCtx.getImageData(0, 0, hw, hh);
const d = maskData.data;
for (let p = 0; p < d.length; p += 4) {
- const r = d[p], g = d[p+1], b = d[p+2];
- const isDark = r < 80 && g < 80 && b < 80;
+ const r = d[p], g = d[p+1], b = d[p+2], a = d[p+3];
+ // SpriteManager has already chroma-keyed the magenta/grey guide
+ // pixels. Canvas reports those transparent pixels as black, so
+ // alpha must participate in the test or the whole quadrant turns
+ // into fog instead of retaining the sparse MGE dither dots.
+ const isDark = a > 0 && r < 80 && g < 80 && b < 80;
d[p] = 0; d[p+1] = 0; d[p+2] = 0;
d[p+3] = isDark ? 128 : 0;
}
@@ -775,13 +786,14 @@ export function applyTerrainMixin(MapRenderer) {
ctx.textAlign = 'left';
ctx.textBaseline = 'alphabetic';
- // Flag sprite if units are present in the city (axx0 Draw.City.cs:122-129)
- // axx0: dest.X + (flagLoc.X - 3).ZoomScale(zoom)
+ // Flag sprite if units are present in the city. The marker is the
+ // flag's left edge; only its Y position is raised by flagHeight - 5
+ // (axx0 BaseGameView.cs:244-247).
const unitsPresent = gs.unitsAt(city.col, city.row).length > 0;
if (unitsPresent && this._civFlagSprites?.[civColorIdx]) {
const flagSpr = this._civFlagSprites[civColorIdx];
ctx.drawImage(flagSpr,
- destX + (flagLoc.x - 3) * scale,
+ destX + flagLoc.x * scale,
destY + (flagLoc.y - 17) * scale,
14 * scale, 22 * scale);
}
@@ -838,8 +850,8 @@ export function applyTerrainMixin(MapRenderer) {
const isActive = unit === this.gameState.activeUnit;
const isEnemy = unit.civId !== 0;
- // Active unit blinks: 500ms on / 500ms off
- const blinkVisible = !isActive || Math.floor(this._blinkTime / 500) % 2 === 0;
+ // Original MGE waiting animation: 200ms on / 200ms off.
+ const blinkVisible = !isActive || Math.floor(this._blinkTime / 200) % 2 === 0;
if (spritesReady) {
try {
@@ -869,7 +881,6 @@ export function applyTerrainMixin(MapRenderer) {
}
if (unit.buildTask) this._drawBuildProgress(ctx, unit, x, y);
- if (isActive) this._drawSelectionRing(ctx, x, y);
return;
} catch (e) {
_warnOnce('unit:' + unit.id, 'Unit sprite unavailable: ' + e.message);
diff --git a/src/site.css b/src/site.css
index 888db04..057ec62 100644
--- a/src/site.css
+++ b/src/site.css
@@ -14,9 +14,10 @@ a { color: inherit; }
.nav a { text-decoration: none; }
.nav > div { display: flex; gap: 28px; color: #bbb7aa; font-size: 14px; }
.wordmark { font: 700 18px Tinos, 'Times New Roman', serif; letter-spacing: .16em; color: #e0bc62; }
-.hero-copy { position: relative; z-index: 2; width: min(770px, 94%); padding: 135px 0 110px; }
+.hero-content { position: relative; z-index: 2; display: grid; grid-template-columns: minmax(0, 1.02fr) minmax(420px, .98fr); gap: clamp(38px, 5vw, 76px); align-items: center; min-height: 672px; }
+.hero-copy { padding: 82px 0 110px; }
.eyebrow, .section-label { color: #d2ae58; font-size: 12px; font-weight: 700; letter-spacing: .16em; text-transform: uppercase; }
-.hero h1 { margin: 18px 0 26px; max-width: 820px; font: 700 clamp(52px, 7.2vw, 94px)/.93 Tinos, 'Times New Roman', serif; letter-spacing: -.035em; }
+.hero h1 { margin: 18px 0 26px; max-width: 680px; font: 700 clamp(48px, 5.2vw, 76px)/.94 Tinos, 'Times New Roman', serif; letter-spacing: -.035em; }
.lede { max-width: 670px; color: #c4c0b4; font-size: 20px; line-height: 1.55; }
.actions { display: flex; gap: 14px; margin-top: 38px; }
.actions a { display: inline-block; padding: 14px 23px; text-decoration: none; font-weight: 700; }
@@ -24,6 +25,10 @@ a { color: inherit; }
.primary:hover { background: #e0b554; }
.secondary { border: 1px solid #777264; color: #ded9cb; }
.secondary:hover { border-color: #d2ae58; }
+.gameplay-preview { position: relative; z-index: 1; margin: 28px 0 36px; padding: 7px; border: 1px solid rgba(224,188,98,.64); background: #1d1b15; box-shadow: 0 32px 80px rgba(0,0,0,.58), 0 0 0 1px rgba(0,0,0,.7); transform: perspective(1200px) rotateY(-2deg); transform-origin: right center; }
+.gameplay-preview::before { content: ''; position: absolute; inset: 7px; z-index: 1; pointer-events: none; box-shadow: inset 0 0 0 1px rgba(255,255,255,.16); }
+.gameplay-preview img { display: block; width: 100%; height: auto; aspect-ratio: 16 / 9; object-fit: cover; object-position: center; }
+.gameplay-preview figcaption { position: absolute; right: 15px; bottom: 15px; z-index: 2; padding: 7px 9px; background: rgba(7,8,6,.84); color: #ded7c6; font-size: 10px; font-weight: 700; letter-spacing: .1em; text-transform: uppercase; }
.hero-grid { position: absolute; right: -130px; bottom: -170px; width: 670px; height: 670px; opacity: .2; transform: rotate(30deg) skewX(-15deg); background-image: linear-gradient(#d6b863 1px, transparent 1px), linear-gradient(90deg, #d6b863 1px, transparent 1px); background-size: 48px 48px; }
main { width: min(1180px, calc(100% - 56px)); margin: auto; }
@@ -43,9 +48,20 @@ main { width: min(1180px, calc(100% - 56px)); margin: auto; }
.text-link { display: inline-block; margin-top: 18px; color: #dbb85f; font-weight: 700; text-decoration: none; }
footer { display: flex; justify-content: space-between; gap: 24px; padding: 32px max(28px, calc((100vw - 1180px) / 2)); border-top: 1px solid #2a2b26; color: #77776f; font-size: 12px; }
+@media (max-width: 1040px) {
+ .hero { min-height: auto; }
+ .hero-content { grid-template-columns: 1fr; gap: 0; padding-bottom: 72px; }
+ .hero-copy { max-width: 760px; padding: 82px 0 42px; }
+ .gameplay-preview { width: min(760px, 100%); margin: 0; transform: none; }
+}
+
@media (max-width: 760px) {
- .hero { min-height: 680px; }
- .hero-copy { padding-top: 100px; }
+ .nav > div { gap: 18px; }
+ .hero-copy { padding-top: 68px; }
+ .hero h1 { font-size: clamp(44px, 13vw, 64px); }
+ .lede { font-size: 18px; }
+ .actions { flex-wrap: wrap; }
+ .gameplay-preview figcaption { position: static; display: block; padding: 10px 4px 3px; background: transparent; }
.split { grid-template-columns: 1fr; gap: 34px; }
.stats { grid-template-columns: 1fr 1fr; gap: 34px 0; }
.stats div:nth-child(3) { padding-left: 0; border-left: 0; }
diff --git a/src/utils/bugReportZip.js b/src/utils/bugReportZip.js
new file mode 100644
index 0000000..01d43b2
--- /dev/null
+++ b/src/utils/bugReportZip.js
@@ -0,0 +1,126 @@
+/**
+ * Create a standards-compliant ZIP archive using the "stored" method.
+ * Bug reports are already dominated by a compressed PNG, so avoiding a
+ * runtime compression dependency keeps this static app small and portable.
+ */
+
+const encoder = new TextEncoder();
+
+let crcTable = null;
+
+function getCrcTable() {
+ if (crcTable) return crcTable;
+ crcTable = new Uint32Array(256);
+ for (let n = 0; n < 256; n++) {
+ let c = n;
+ for (let k = 0; k < 8; k++) c = (c & 1) ? (0xedb88320 ^ (c >>> 1)) : (c >>> 1);
+ crcTable[n] = c >>> 0;
+ }
+ return crcTable;
+}
+
+export function crc32(bytes) {
+ const table = getCrcTable();
+ let crc = 0xffffffff;
+ for (const byte of bytes) crc = table[(crc ^ byte) & 0xff] ^ (crc >>> 8);
+ return (crc ^ 0xffffffff) >>> 0;
+}
+
+function dosDateTime(date) {
+ const year = Math.max(1980, date.getFullYear());
+ return {
+ time: ((date.getHours() & 0x1f) << 11) |
+ ((date.getMinutes() & 0x3f) << 5) |
+ ((Math.floor(date.getSeconds() / 2)) & 0x1f),
+ date: (((year - 1980) & 0x7f) << 9) |
+ (((date.getMonth() + 1) & 0x0f) << 5) |
+ (date.getDate() & 0x1f),
+ };
+}
+
+function concat(parts) {
+ const length = parts.reduce((sum, part) => sum + part.length, 0);
+ const result = new Uint8Array(length);
+ let offset = 0;
+ for (const part of parts) {
+ result.set(part, offset);
+ offset += part.length;
+ }
+ return result;
+}
+
+function bytesFor(value) {
+ if (value instanceof Uint8Array) return value;
+ if (value instanceof ArrayBuffer) return new Uint8Array(value);
+ return encoder.encode(String(value));
+}
+
+/**
+ * @param {{ name: string, data: string|Uint8Array|ArrayBuffer }[]} entries
+ * @param {Date} [createdAt]
+ * @returns {Uint8Array}
+ */
+export function createStoredZip(entries, createdAt = new Date()) {
+ const localParts = [];
+ const centralParts = [];
+ const { time, date } = dosDateTime(createdAt);
+ let localOffset = 0;
+
+ for (const entry of entries) {
+ const name = encoder.encode(entry.name);
+ const data = bytesFor(entry.data);
+ const crc = crc32(data);
+
+ const local = new Uint8Array(30);
+ const lv = new DataView(local.buffer);
+ lv.setUint32(0, 0x04034b50, true);
+ lv.setUint16(4, 20, true);
+ lv.setUint16(6, 0x0800, true); // UTF-8 names
+ lv.setUint16(8, 0, true); // stored, no compression
+ lv.setUint16(10, time, true);
+ lv.setUint16(12, date, true);
+ lv.setUint32(14, crc, true);
+ lv.setUint32(18, data.length, true);
+ lv.setUint32(22, data.length, true);
+ lv.setUint16(26, name.length, true);
+ lv.setUint16(28, 0, true);
+ localParts.push(local, name, data);
+
+ const central = new Uint8Array(46);
+ const cv = new DataView(central.buffer);
+ cv.setUint32(0, 0x02014b50, true);
+ cv.setUint16(4, 20, true);
+ cv.setUint16(6, 20, true);
+ cv.setUint16(8, 0x0800, true);
+ cv.setUint16(10, 0, true);
+ cv.setUint16(12, time, true);
+ cv.setUint16(14, date, true);
+ cv.setUint32(16, crc, true);
+ cv.setUint32(20, data.length, true);
+ cv.setUint32(24, data.length, true);
+ cv.setUint16(28, name.length, true);
+ cv.setUint16(30, 0, true);
+ cv.setUint16(32, 0, true);
+ cv.setUint16(34, 0, true);
+ cv.setUint16(36, 0, true);
+ cv.setUint32(38, 0, true);
+ cv.setUint32(42, localOffset, true);
+ centralParts.push(central, name);
+
+ localOffset += local.length + name.length + data.length;
+ }
+
+ const localData = concat(localParts);
+ const centralData = concat(centralParts);
+ const end = new Uint8Array(22);
+ const ev = new DataView(end.buffer);
+ ev.setUint32(0, 0x06054b50, true);
+ ev.setUint16(4, 0, true);
+ ev.setUint16(6, 0, true);
+ ev.setUint16(8, entries.length, true);
+ ev.setUint16(10, entries.length, true);
+ ev.setUint32(12, centralData.length, true);
+ ev.setUint32(16, localData.length, true);
+ ev.setUint16(20, 0, true);
+ return concat([localData, centralData, end]);
+}
diff --git a/tests/game.spec.js b/tests/game.spec.js
index e78cca5..7c0e3b2 100644
--- a/tests/game.spec.js
+++ b/tests/game.spec.js
@@ -276,6 +276,76 @@ test.describe('Original MGE graphics data', () => {
expect(markers.missing).toBe(0);
});
+ test('city garrison flag uses its MGE marker and the active unit flashes over the city', async ({ page }) => {
+ await gotoGame(page);
+ await startTestGame(page);
+ await page.waitForFunction(() => window.__civ2?.mapScreen?._citySpriteData?.open?.[0]?.[0]);
+
+ const result = await page.evaluate(() => {
+ const screen = window.__civ2.mapScreen;
+ const gs = screen.gameState;
+ const founder = gs.units.find(u => u.civId === 0 && window.__civ2.UNITS[u.typeId]?.role === 5);
+ const city = gs.foundCity(founder);
+ const unit = gs._spawnUnit(2, 0, city.col, city.row);
+ gs.activeUnit = unit;
+
+ const canvas = document.createElement('canvas');
+ canvas.width = 1280;
+ canvas.height = 800;
+ const ctx = canvas.getContext('2d');
+ const flagSprite = screen._civFlagSprites[gs.civs[city.civId].data.color];
+ const unitSprite = screen._getColoredUnitSprite(
+ Math.floor(unit.typeId / 9),
+ unit.typeId % 9,
+ window.__civ2.CIV_COLORS?.[gs.civs[unit.civId].data.color] ?? '#fff',
+ );
+
+ const flagCalls = [];
+ const originalDrawImage = ctx.drawImage.bind(ctx);
+ ctx.drawImage = (...args) => {
+ if (args[0] === flagSprite) flagCalls.push(args.slice(1));
+ return originalDrawImage(...args);
+ };
+ screen._drawCity(ctx, city, 100, 200, true);
+
+ let unitCalls = 0;
+ let selectionRingCalls = 0;
+ screen._drawSelectionRing = () => { selectionRingCalls++; };
+ ctx.drawImage = (...args) => {
+ if (args[0] === unitSprite) unitCalls++;
+ return originalDrawImage(...args);
+ };
+ screen._blinkTime = 0;
+ screen.render(ctx, canvas.width, canvas.height);
+ const visibleCalls = unitCalls;
+ screen._blinkTime = 200;
+ screen.render(ctx, canvas.width, canvas.height);
+ const hiddenCalls = unitCalls - visibleCalls;
+
+ const { styleRow, sizeCol, hasWalls } = screen._getCitySpriteInfo(city);
+ const cell = screen._citySpriteData[hasWalls ? 'walled' : 'open'][styleRow][sizeCol];
+ const destY = 200 - 64 / 2;
+ return {
+ flagCall: flagCalls.at(-1),
+ expectedFlag: [
+ 100 + cell.flagLoc.x * 2,
+ destY + (cell.flagLoc.y - 17) * 2,
+ 28,
+ 44,
+ ],
+ visibleCalls,
+ hiddenCalls,
+ selectionRingCalls,
+ };
+ });
+
+ expect(result.flagCall).toEqual(result.expectedFlag);
+ // The sidebar keeps its unit portrait visible; the one additional draw in
+ // the on-frame is the flashing map sprite over the city.
+ expect(result.visibleCalls).toBe(result.hiddenCalls + 1);
+ expect(result.selectionRingCalls).toBe(0);
+ });
+
test('unit shields use original MGE light and dark player colours', async ({ page }) => {
await gotoGame(page);
await startTestGame(page);
@@ -302,6 +372,64 @@ test.describe('Original MGE graphics data', () => {
expect(colours).toContainEqual({ color: '#afafaf', type: 'back' });
});
+ test('terrain joins use sparse MGE dither dots instead of replacing whole quadrants', async ({ page }) => {
+ await gotoGame(page);
+ await startTestGame(page);
+
+ const opaqueCounts = await page.evaluate(() => {
+ const screen = window.__civ2.mapScreen;
+ const masks = screen._buildDitherMasks();
+ return masks.map(mask => {
+ const data = mask.getContext('2d').getImageData(0, 0, mask.width, mask.height).data;
+ let opaque = 0;
+ for (let i = 3; i < data.length; i += 4) if (data[i] > 0) opaque++;
+ return opaque;
+ });
+ });
+
+ expect(opaqueCounts).toHaveLength(4);
+ for (const count of opaqueCounts) {
+ expect(count).toBeGreaterThan(0);
+ expect(count).toBeLessThan(512);
+ }
+ });
+
+ test('fog edges use sparse MGE dither dots instead of opaque quadrants', async ({ page }) => {
+ await gotoGame(page);
+ await startTestGame(page);
+
+ const masks = await page.evaluate(() => {
+ const screen = window.__civ2.mapScreen;
+ const gs = screen.gameState;
+ for (const row of gs._visibility) row.fill(0);
+ gs._visibility[5][5] = 1;
+
+ screen._showHiddenTerrain = true;
+ screen._fogDitherQuads = null;
+ const canvas = document.createElement('canvas');
+ canvas.width = 128; canvas.height = 64;
+ screen._drawFogDither(canvas.getContext('2d'), 0, 0, 5, 5);
+ const revealBuiltFog = screen._fogDitherQuads !== null;
+
+ screen._showHiddenTerrain = false;
+ screen._drawFogDither(canvas.getContext('2d'), 0, 0, 5, 5);
+ const quads = screen._fogDitherQuads.map(({ canvas: mask }) => {
+ const data = mask.getContext('2d').getImageData(0, 0, mask.width, mask.height).data;
+ let opaque = 0;
+ for (let i = 3; i < data.length; i += 4) if (data[i] > 0) opaque++;
+ return { opaque, pixels: mask.width * mask.height };
+ });
+ return { revealBuiltFog, quads };
+ });
+
+ expect(masks.revealBuiltFog).toBe(false);
+ expect(masks.quads).toHaveLength(4);
+ for (const mask of masks.quads) {
+ expect(mask.opaque).toBeGreaterThan(0);
+ expect(mask.opaque).toBeLessThan(mask.pixels);
+ }
+ });
+
test('city sprite selection follows era, size, capital, walls, and Great Wall', async ({ page }) => {
await gotoGame(page);
await startTestGame(page);
@@ -423,10 +551,17 @@ test.describe('Original MGE graphics data', () => {
screen._cityScreenProdList = true;
const canvas = document.createElement('canvas');
canvas.width = 1280; canvas.height = 800;
- screen._drawCityScreen(canvas.getContext('2d'), 1280, 800);
+ const ctx = canvas.getContext('2d');
+ screen._drawCityScreen(ctx, 1280, 800);
+ const firstRow = screen._cityScreenItemRects[0];
+ const lastRow = screen._cityScreenItemRects.at(-1);
+ const pixel = (x, y) => [...ctx.getImageData(Math.round(x), Math.round(y), 1, 1).data];
return {
dialog: screen._cityProductionDialogRect,
itemCount: screen._cityScreenItemRects.length,
+ itemNames: screen._cityScreenItemRects.map(rect => rect.item.name),
+ selectedFill: pixel(firstRow.x + 55, firstRow.y + firstRow.h / 2),
+ emptyListFill: pixel(firstRow.x + 55, lastRow.y + lastRow.h + 10),
tabs: screen._cityScreenTabRects.length,
queue: screen._cityScreenQueueModeRect,
auto: screen._cityScreenAutoRect,
@@ -435,8 +570,12 @@ test.describe('Original MGE graphics data', () => {
};
});
- expect(result.dialog.w).toBe(550); // 440px Game.txt dialog at city window's 1.25 scale
+ expect(result.dialog.w).toBe(680); // original MGE top-level dialog width at this viewport
+ expect(result.dialog.h).toBe(267.5);
expect(result.itemCount).toBeGreaterThan(0);
+ expect(result.itemNames).toContain('Settlers');
+ expect(result.selectedFill).toEqual([128, 128, 128, 255]);
+ expect(result.emptyListFill).toEqual([192, 192, 192, 255]);
expect(result.tabs).toBe(0);
expect(result.queue).toBeNull();
expect(result.auto).not.toBeNull();
@@ -444,6 +583,47 @@ test.describe('Original MGE graphics data', () => {
expect(result.ok).not.toBeNull();
});
+ test('size-one city holds completed Settlers until it grows', async ({ page }) => {
+ await gotoGame(page);
+ await startTestGame(page);
+
+ const result = await page.evaluate(() => {
+ const gs = window.__civ2.mapScreen.gameState;
+ const founder = gs.units.find(u => u.civId === 0 && window.__civ2.UNITS[u.typeId]?.role === 5);
+ const city = gs.foundCity(founder);
+ city.production = { type: 'unit', id: 0 };
+ city.shields = gs._productionCost(city.production);
+ const beforeUnits = gs.units.filter(u => u.civId === 0 && u.typeId === 0).length;
+
+ const completedAtSizeOne = gs._completeProduction(city);
+ const held = {
+ shields: city.shields,
+ production: city.production,
+ units: gs.units.filter(u => u.civId === 0 && u.typeId === 0).length,
+ };
+
+ city.size = 2;
+ const completedAtSizeTwo = gs._completeProduction(city);
+ return {
+ cost: gs._productionCost({ type: 'unit', id: 0 }),
+ beforeUnits,
+ completedAtSizeOne,
+ held,
+ completedAtSizeTwo,
+ finalSize: city.size,
+ finalUnits: gs.units.filter(u => u.civId === 0 && u.typeId === 0).length,
+ };
+ });
+
+ expect(result.completedAtSizeOne).toBe(false);
+ expect(result.held.shields).toBe(result.cost);
+ expect(result.held.production).toEqual({ type: 'unit', id: 0 });
+ expect(result.held.units).toBe(result.beforeUnits);
+ expect(result.completedAtSizeTwo).toBe(true);
+ expect(result.finalSize).toBe(1);
+ expect(result.finalUnits).toBe(result.beforeUnits + 1);
+ });
+
test('production list highlights a row and changes production only after OK', async ({ page }) => {
await gotoGame(page);
await startTestGame(page);
@@ -571,6 +751,77 @@ test.describe('Original MGE graphics data', () => {
expect(geom.mapY).toBe(105);
});
+ test('minimap paints ocean blue and land green', async ({ page }) => {
+ await gotoGame(page);
+ await startTestGame(page, { mapCols: 80, mapRows: 50 });
+
+ const colours = await page.evaluate(() => {
+ const screen = window.__civ2.mapScreen;
+ const gs = screen.gameState;
+ gs.tiles[0][0] = window.__civ2.TERRAIN.OCEAN;
+ gs.tiles[0][1] = window.__civ2.TERRAIN.GRASSLAND;
+ gs._visibility[0][0] = 2;
+ gs._visibility[0][1] = 2;
+
+ const canvas = document.createElement('canvas');
+ canvas.width = 1280;
+ canvas.height = 800;
+ const ctx = canvas.getContext('2d');
+ screen._drawSidebarMinimap(ctx, canvas.width, canvas.height, gs);
+ const { mapX, mapY, mmTileW } = screen._mmGeom(canvas.width, canvas.height);
+ return {
+ ocean: Array.from(ctx.getImageData(mapX, mapY, 1, 1).data.slice(0, 3)),
+ land: Array.from(ctx.getImageData(mapX + mmTileW, mapY, 1, 1).data.slice(0, 3)),
+ };
+ });
+
+ expect(colours.ocean).toEqual([0, 0, 95]);
+ expect(colours.land).toEqual([55, 123, 23]);
+ });
+
+ test('minimap reveal mode shows every tile without fog', async ({ page }) => {
+ await gotoGame(page);
+ await startTestGame(page, { mapCols: 40, mapRows: 25 });
+
+ const result = await page.evaluate(() => {
+ const screen = window.__civ2.mapScreen;
+ const gs = screen.gameState;
+ for (let row = 0; row < gs.mapRows; row++) {
+ gs._visibility[row].fill(0);
+ for (let col = 0; col < gs.mapCols; col++) {
+ gs.tiles[row][col] = (row + col) % 2
+ ? window.__civ2.TERRAIN.OCEAN
+ : window.__civ2.TERRAIN.GRASSLAND;
+ }
+ }
+
+ const canvas = document.createElement('canvas');
+ canvas.width = 1280; canvas.height = 800;
+ const ctx = canvas.getContext('2d');
+ const geom = screen._mmGeom(canvas.width, canvas.height);
+
+ screen._showHiddenTerrain = false;
+ screen._drawSidebarMinimap(ctx, canvas.width, canvas.height, gs);
+ const fogged = Array.from(ctx.getImageData(geom.mapX, geom.mapY, 1, 1).data.slice(0, 3));
+
+ screen._showHiddenTerrain = true;
+ screen._drawSidebarMinimap(ctx, canvas.width, canvas.height, gs);
+ let blackTiles = 0;
+ for (let row = 0; row < gs.mapRows; row++) {
+ for (let col = 0; col < gs.mapCols; col++) {
+ const x = geom.mapX + col * geom.mmTileW + (row % 2 ? Math.floor(geom.mmTileW / 2) : 0);
+ const y = geom.mapY + row * geom.mmTileH;
+ const pixel = ctx.getImageData(x, y, 1, 1).data;
+ if (pixel[0] === 0 && pixel[1] === 0 && pixel[2] === 0) blackTiles++;
+ }
+ }
+ return { fogged, blackTiles };
+ });
+
+ expect(result.fogged).toEqual([0, 0, 0]);
+ expect(result.blackTiles).toBe(0);
+ });
+
test('sidebar uses light shield colour and original AI turn marker size', async ({ page }) => {
await gotoGame(page);
await startTestGame(page);
@@ -1154,6 +1405,162 @@ test.describe('Save / Load', () => {
});
});
+// ─── 6a. Bug reports ─────────────────────────────────────────────────────────
+
+test.describe('Bug reports', () => {
+ test.beforeEach(async ({ page }) => {
+ await gotoGame(page);
+ await startTestGame(page);
+ });
+
+ test('Game menu report contains screenshot, restorable state, and view diagnostics', async ({ page }) => {
+ const result = await page.evaluate(async () => {
+ 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.
+ 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;
+ screen._zoomLevel = 2;
+ const stateBefore = screen.gameState.toSaveData();
+
+ let reportDrawsDuringCapture = 0;
+ const originalDraw = screen._drawBugReportDialog;
+ screen._drawBugReportDialog = function(...args) {
+ reportDrawsDuringCapture++;
+ return originalDraw.apply(this, args);
+ };
+ screen._executeMenuAction(menuItem.action);
+ const drawsBeforeNextTask = reportDrawsDuringCapture;
+ const file = await screen._bugReportDialog.promise;
+ screen._drawBugReportDialog = originalDraw;
+
+ const bytes = new Uint8Array(await file.arrayBuffer());
+ const entries = {};
+ const decoder = new TextDecoder();
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
+ let offset = 0;
+ while (offset + 30 <= bytes.length && view.getUint32(offset, true) === 0x04034b50) {
+ const compressedSize = view.getUint32(offset + 18, true);
+ const nameLength = view.getUint16(offset + 26, true);
+ const extraLength = view.getUint16(offset + 28, true);
+ const nameStart = offset + 30;
+ const dataStart = nameStart + nameLength + extraLength;
+ const name = decoder.decode(bytes.slice(nameStart, nameStart + nameLength));
+ entries[name] = bytes.slice(dataStart, dataStart + compressedSize);
+ offset = dataStart + compressedSize;
+ }
+
+ const savedState = JSON.parse(decoder.decode(entries['game-state.json']));
+ const report = JSON.parse(decoder.decode(entries['report.json']));
+ const restored = screen.gameState.constructor.fromSaveData(savedState);
+ const png = entries['screenshot.png'];
+ const bitmap = await createImageBitmap(new Blob([png], { type: 'image/png' }));
+ const archiveEnd = Array.from(bytes.slice(-22, -18));
+ const answer = {
+ menuItem: !!menuItem,
+ status: screen._bugReportDialog.status,
+ filename: file.name,
+ mime: file.type,
+ zipStart: Array.from(bytes.slice(0, 4)),
+ zipEnd: archiveEnd,
+ entryNames: Object.keys(entries).sort(),
+ pngSignature: Array.from(png.slice(0, 8)),
+ pngSize: [bitmap.width, bitmap.height],
+ stateMatches: JSON.stringify(savedState) === JSON.stringify(stateBefore),
+ restoredTurn: restored.turn,
+ capturedTurn: stateBefore.turn,
+ rendererState: report.rendererState,
+ reportFormat: report.format,
+ drawsBeforeNextTask,
+ };
+ bitmap.close();
+ return answer;
+ });
+
+ expect(result.menuItem).toBe(true);
+ expect(result.status).toBe('ready');
+ expect(result.filename).toMatch(/^civ2-bug-report-.*\.zip$/);
+ expect(result.mime).toBe('application/zip');
+ expect(result.zipStart).toEqual([0x50, 0x4b, 0x03, 0x04]);
+ expect(result.zipEnd).toEqual([0x50, 0x4b, 0x05, 0x06]);
+ expect(result.entryNames).toEqual(['README.txt', 'game-state.json', 'report.json', 'screenshot.png']);
+ expect(result.pngSignature).toEqual([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
+ expect(result.pngSize).toEqual([1280, 800]);
+ expect(result.stateMatches).toBe(true);
+ expect(result.restoredTurn).toBe(result.capturedTurn);
+ expect(result.rendererState).toMatchObject({ viewX: 96, viewY: 48, zoomLevel: 2 });
+ expect(result.reportFormat).toBe('civ2-web-bug-report');
+ expect(result.drawsBeforeNextTask, 'report UI must not appear inside its own screenshot').toBe(0);
+ });
+
+ 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;
+ screen._openBugReportDialog();
+ await screen._bugReportDialog.promise;
+
+ const downloaded = [];
+ const openedUrls = [];
+ const originalClick = HTMLAnchorElement.prototype.click;
+ const originalOpen = window.open;
+ HTMLAnchorElement.prototype.click = function() { downloaded.push(this.download); };
+ window.open = url => { openedUrls.push(String(url)); return null; };
+ Object.defineProperty(navigator, 'canShare', {
+ configurable: true,
+ value: data => data.files?.length === 1,
+ });
+ Object.defineProperty(navigator, 'share', {
+ configurable: true,
+ value: async data => {
+ window.__sharedBugReport = {
+ title: data.title,
+ text: data.text,
+ names: data.files.map(file => file.name),
+ types: data.files.map(file => file.type),
+ };
+ },
+ });
+
+ const shared = await screen._shareBugReport();
+ Object.defineProperty(navigator, 'canShare', { configurable: true, value: () => false });
+ const emailFallback = await screen._shareBugReport();
+ const opened = screen._openGitHubBugReport();
+ const answer = {
+ shared,
+ emailFallback,
+ opened,
+ shareData: window.__sharedBugReport,
+ emailUrl: openedUrls[0],
+ issueUrl: openedUrls[1],
+ downloaded,
+ };
+ HTMLAnchorElement.prototype.click = originalClick;
+ window.open = originalOpen;
+ return answer;
+ });
+
+ expect(result.shared).toBe(true);
+ expect(result.emailFallback).toBe(false);
+ expect(result.opened).toBe(true);
+ expect(result.shareData.title).toBe('Civilization II bug report');
+ expect(result.shareData.names[0]).toMatch(/^civ2-bug-report-.*\.zip$/);
+ expect(result.shareData.types).toEqual(['application/zip']);
+ expect(result.downloaded).toEqual([result.shareData.names[0], result.shareData.names[0]]);
+ expect(result.emailUrl).toMatch(/^mailto:\?subject=/);
+ expect(decodeURIComponent(result.emailUrl)).toContain(`File: ${result.shareData.names[0]}`);
+ expect(result.issueUrl).toContain('https://github.com/wan0net/civ2/issues/new');
+ expect(decodeURIComponent(result.issueUrl)).toContain(`attach the downloaded \`${result.shareData.names[0]}\``);
+ });
+});
+
// ─── 7. City Interaction ──────────────────────────────────────────────────────
test.describe('City Interaction', () => {
@@ -1581,6 +1988,35 @@ test.describe('Research', () => {
expect(completed.granted).toBe(true);
});
+ test('fog of war preserves explored tiles while revealing only the current unit area', async ({ page }) => {
+ const result = await page.evaluate(() => {
+ const gs = window.__civ2.mapScreen.gameState;
+ for (const row of gs._visibility) row.fill(0);
+ gs.cities = [];
+ gs.units = [{ id: 999, civId: 0, typeId: 0, col: 5, row: 5 }];
+
+ gs._updateVisibility();
+ const first = {
+ unit: gs._visibility[5][5],
+ neighbour: gs._visibility[5][6],
+ distant: gs._visibility[20][30],
+ };
+
+ gs.units[0].col = 15;
+ gs.units[0].row = 15;
+ gs._updateVisibility();
+ const moved = {
+ oldUnit: gs._visibility[5][5],
+ newUnit: gs._visibility[15][15],
+ distant: gs._visibility[20][30],
+ };
+ return { first, moved };
+ });
+
+ expect(result.first).toEqual({ unit: 2, neighbour: 2, distant: 0 });
+ expect(result.moved).toEqual({ oldUnit: 1, newUnit: 2, distant: 0 });
+ });
+
test('research chooser matches the compact Game.txt listbox', async ({ page }) => {
const result = await page.evaluate(() => {
const screen = window.__civ2.mapScreen;
@@ -1588,15 +2024,26 @@ test.describe('Research', () => {
screen._researchGoalCandidates = null;
const canvas = document.createElement('canvas');
canvas.width = 1280; canvas.height = 800;
- screen._drawResearchChooser(canvas.getContext('2d'), 1280, 800);
+ const ctx = canvas.getContext('2d');
+ const titleCalls = [];
+ const fillText = ctx.fillText.bind(ctx);
+ ctx.fillText = (text, x, y, ...args) => {
+ if (String(text).startsWith('What discovery shall our ')) {
+ titleCalls.push({ text, width: ctx.measureText(text).width, font: ctx.font });
+ }
+ return fillText(text, x, y, ...args);
+ };
+ screen._drawResearchChooser(ctx, 1280, 800);
return {
dialog: screen._researchChooserRect,
+ title: titleCalls[0],
specials: screen._researchChooserRects.filter(r => r.advId < 0).map(r => r.advId),
rows: screen._researchChooserRects.filter(r => r.advId >= 0).length,
};
});
- expect(result.dialog.w).toBe(300);
+ expect(result.dialog.w).toBeGreaterThanOrEqual(300);
+ expect(result.title.width).toBeLessThanOrEqual(result.dialog.w - 18);
expect(result.specials).toEqual([-3, -2, -1]); // Help, Goal, implicit standard OK
expect(result.rows).toBeGreaterThan(0);
expect(result.rows).toBeLessThanOrEqual(16);
diff --git a/tests/site.spec.js b/tests/site.spec.js
index c3cce5d..eb5a3d3 100644
--- a/tests/site.spec.js
+++ b/tests/site.spec.js
@@ -6,6 +6,30 @@ test.describe('Public project site', () => {
await expect(page.getByRole('heading', { name: /Build a civilization/i })).toBeVisible();
await expect(page.getByText(/must own Civilization II MGE/i)).toBeVisible();
await expect(page.getByRole('link', { name: 'Open the game' })).toHaveAttribute('href', './game.html');
+
+ const preview = page.getByRole('img', { name: /Rome and three Roman warrior units/i });
+ await expect(preview).toBeVisible();
+ await expect(preview).toHaveAttribute('src', './site/gameplay.jpg');
+
+ const [headingBox, previewBox] = await Promise.all([
+ page.getByRole('heading', { name: /Build a civilization/i }).boundingBox(),
+ preview.boundingBox(),
+ ]);
+ expect(headingBox).not.toBeNull();
+ expect(previewBox).not.toBeNull();
+ expect(previewBox.x).toBeGreaterThan(headingBox.x);
+ expect(previewBox.width).toBeGreaterThan(400);
+ });
+
+ test('gameplay preview remains visible on a narrow screen', async ({ page }) => {
+ await page.setViewportSize({ width: 390, height: 844 });
+ await page.goto('/');
+
+ const preview = page.getByRole('img', { name: /Rome and three Roman warrior units/i });
+ await expect(preview).toBeVisible();
+ const previewBox = await preview.boundingBox();
+ expect(previewBox.width).toBeGreaterThan(300);
+ expect(previewBox.x).toBeGreaterThanOrEqual(0);
});
test('game does not load MGE assets before ownership attestation', async ({ page }) => {