From e251fc0ebdca1ca8b741ffdb1182cfc38b0b13cf Mon Sep 17 00:00:00 2001 From: Iain Date: Sat, 29 Aug 2026 08:51:35 +1000 Subject: [PATCH] fix: resolve post-merge playthrough regressions --- src/render/MapRenderer.js | 19 ++++++--- src/render/mixins/DialogsMixin.js | 2 +- src/render/mixins/WizardMixin.js | 22 ++++++++-- tests/game.spec.js | 70 ++++++++++++++++++++++++++++++- 4 files changed, 102 insertions(+), 11 deletions(-) diff --git a/src/render/MapRenderer.js b/src/render/MapRenderer.js index b3e807f..575e028 100644 --- a/src/render/MapRenderer.js +++ b/src/render/MapRenderer.js @@ -982,7 +982,7 @@ export class MapRenderer { this._setScrollKey(e.key, true); if (this._handleDialogKey(e)) return; - this._handleGameKey(e); + if (this._handleGameKey(e)) e.preventDefault(); } _handleDialogKey(e) { @@ -4117,6 +4117,10 @@ export class MapRenderer { this._stopEventVideo(); // Suppress any opening video overlay still in the DOM (prevent it from re-enabling title screen) this._openingVideoDone = true; + if (this._openingVideoSkipHandler) { + document.removeEventListener('keydown', this._openingVideoSkipHandler, true); + this._openingVideoSkipHandler = null; + } const openVid = document.querySelector('video[src*="OPENING"]'); if (openVid?.parentNode) { openVid.pause(); @@ -4154,12 +4158,17 @@ export class MapRenderer { gs._updateVisibility(); } - const firstUnit = gs.activeUnit ?? gs.units[0]; + // Never centre a human load on an unseen AI unit. If the player has no + // active piece (common immediately after founding the capital), use their + // first remaining unit or city instead. + const firstUnit = gs.activeUnit?.civId === 0 + ? gs.activeUnit + : gs.units.find(unit => unit.civId === 0); if (firstUnit) { this.centerOn(firstUnit.col, firstUnit.row, this._canvasW, this._canvasH); - } else if (gs.cities.length > 0) { - const firstCity = gs.cities[0]; - this.centerOn(firstCity.col, firstCity.row, this._canvasW, this._canvasH); + } else { + const firstCity = gs.cities.find(city => city.civId === 0); + if (firstCity) this.centerOn(firstCity.col, firstCity.row, this._canvasW, this._canvasH); } } diff --git a/src/render/mixins/DialogsMixin.js b/src/render/mixins/DialogsMixin.js index 83c42f5..166f16b 100644 --- a/src/render/mixins/DialogsMixin.js +++ b/src/render/mixins/DialogsMixin.js @@ -357,7 +357,7 @@ export function applyDialogsMixin(MapRenderer) { if (others.length === 0) { ctx.font = FONT.BODY; ctx.textAlign = 'center'; - this._panelText(ctx, 'No other civilizations known.', px + PW / 2, py + 80); + this._panelText(ctx, 'No other civilizations known.', px + PW / 2, py + 54); ctx.textAlign = 'left'; } diff --git a/src/render/mixins/WizardMixin.js b/src/render/mixins/WizardMixin.js index fdcbe26..f0814eb 100644 --- a/src/render/mixins/WizardMixin.js +++ b/src/render/mixins/WizardMixin.js @@ -366,12 +366,14 @@ export function applyWizardMixin(MapRenderer) { video.playsInline = true; video.muted = false; let soundButton = null; + let skipOnKey = null; const finish = () => { if (this._openingVideoDone) return; this._openingVideoDone = true; clearTimeout(stallTimer); - document.removeEventListener('keydown', skipOnKey); + if (skipOnKey) document.removeEventListener('keydown', skipOnKey, true); + if (this._openingVideoSkipHandler === skipOnKey) this._openingVideoSkipHandler = null; soundButton?.remove(); if (video.parentNode) video.parentNode.removeChild(video); onDone(); @@ -383,9 +385,21 @@ export function applyWizardMixin(MapRenderer) { const stallTimer = setTimeout(finish, 60_000); video.addEventListener('ended', finish); - video.addEventListener('click', finish); - const skipOnKey = () => finish(); - document.addEventListener('keydown', skipOnKey); + video.addEventListener('click', event => { + event.preventDefault(); + event.stopPropagation(); + finish(); + }); + skipOnKey = event => { + if (this._openingVideoDone) return; + // The same Enter/Space press must not fall through to the freshly shown + // title dialog and activate its default selection. + event.preventDefault(); + event.stopImmediatePropagation(); + finish(); + }; + this._openingVideoSkipHandler = skipOnKey; + document.addEventListener('keydown', skipOnKey, true); video.addEventListener('canplay', async () => { try { diff --git a/tests/game.spec.js b/tests/game.spec.js index 37b5ec8..c6bbb87 100644 --- a/tests/game.spec.js +++ b/tests/game.spec.js @@ -243,6 +243,14 @@ test.describe('Title Screen', () => { expect(newGame).not.toBeNull(); expect(titleScreen).toBe(false); }); + + test('Enter skips the opening movie without also starting a new game', async ({ page }) => { + await gotoGame(page); + await page.keyboard.press('Enter'); + await page.waitForFunction(() => window.__civ2?.mapScreen?._titleScreen === true); + expect(await ms(page, '_wizard')).toBeNull(); + expect(await ms(page, '_titleSelection')).toBe(0); + }); }); test.describe('Original MGE graphics data', () => { @@ -1379,6 +1387,31 @@ test.describe('Save / Load', () => { expect(restoredTurn).toBe(turnBeforeSave); }); + test('loading without a human unit centres on the first human city, not an unseen AI unit', async ({ page }) => { + await gotoGame(page); + await startTestGame(page); + const result = await page.evaluate(() => { + const screen = window.__civ2.mapScreen; + const gs = screen.gameState; + let city = gs.cities.find(c => c.civId === 0); + if (!city) { + const settler = gs.units.find(u => u.civId === 0 && u.typeId === 0); + city = gs.foundCity(settler); + } + gs.units = gs.units.filter(u => u.civId !== 0); + screen._saveGame(); + let centred = null; + const originalCenterOn = screen.centerOn.bind(screen); + screen.centerOn = (col, row, ...rest) => { + centred = { col, row }; + return originalCenterOn(col, row, ...rest); + }; + screen._loadGame(); + return { centred, city: { col: city.col, row: city.row } }; + }); + expect(result.centred).toEqual(result.city); + }); + test('title screen enables Load Saved Game after a save exists', async ({ page }) => { await gotoGame(page); await page.evaluate(() => localStorage.removeItem('civ2_save')); @@ -6969,17 +7002,25 @@ test.describe('Human playthrough regressions', () => { const gs = ms.gameState; const canvas = document.createElement('canvas'); canvas.width = 1280; canvas.height = 800; + let emptyTextY = null; + const originalPanelText = ms._panelText.bind(ms); + ms._panelText = (ctx, text, x, y, ...rest) => { + if (text === 'No other civilizations known.') emptyTextY = y; + return originalPanelText(ctx, text, x, y, ...rest); + }; ms._drawDiplomacyScreen(canvas.getContext('2d'), canvas.width, canvas.height); const before = ms._diplomacyScreenRects.filter(r => r.action === 'contact').map(r => r.civId); + const close = ms._diplomacyScreenRects.find(r => r.action === 'close'); 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) }; + return { before, after, savedContact: restored.hasContact(0, 1), emptyTextY, closeY: close.y }; }); expect(result.before).toEqual([]); expect(result.after).toEqual([1]); expect(result.savedContact).toBe(true); + expect(result.emptyTextY).toBeLessThan(result.closeY - 8); }); test('a garrisoned city opens before unit or tile actions in both view modes', async ({ page }) => { @@ -7151,6 +7192,33 @@ test.describe('Human playthrough regressions', () => { expect(result.after).toBe(result.before + 1); expect(result.domestic).toBe(true); }); + + test('handled cheat function keys suppress browser focus shortcuts', 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]; + civ.currentResearch = gs.availableAdvances(0)[0].id; + ms._executeMenuAction('cheat_toggle'); + const before = { x: ms.viewX, y: ms.viewY }; + let prevented = false; + ms._onKeyDown({ + key: 'F6', shiftKey: true, ctrlKey: false, altKey: false, + preventDefault() { prevented = true; }, + }); + return { + prevented, + before, + after: { x: ms.viewX, y: ms.viewY }, + completed: civ.beakers === gs.advanceCost(civ), + }; + }); + expect(result.prevented).toBe(true); + expect(result.after).toEqual(result.before); + expect(result.completed).toBe(true); + }); }); // ─── Dialog Padding Constants ────────────────────────────────────────────────