diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..4ee01478 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +*.json text eol=lf diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..25fddaf6 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,40 @@ +{ + "files.eol": "\n", + "editor.insertSpaces": true, + "editor.tabSize": 2, + "editor.defaultFormatter": "esbenp.prettier-vscode", + "editor.formatOnSave": true, + "editor.codeActionsOnSave": { + "source.fixAll.eslint": true + }, + "eslint.experimental.useFlatConfig": true, + "eslint.workingDirectories": [{ "mode": "auto" }], + "eslint.validate": [ + "javascript", + "javascriptreact", + "typescript", + "typescriptreact" + ], + "[javascript]": { + "editor.defaultFormatter": "esbenp.prettier-vscode", + "editor.formatOnSave": true + }, + "[javascriptreact]": { + "editor.defaultFormatter": "esbenp.prettier-vscode", + "editor.formatOnSave": true + }, + "[typescript]": { + "editor.defaultFormatter": "esbenp.prettier-vscode", + "editor.formatOnSave": true + }, + "[typescriptreact]": { + "editor.defaultFormatter": "esbenp.prettier-vscode", + "editor.formatOnSave": true + }, + "[json]": { + "editor.formatOnSave": false + }, + "[jsonc]": { + "editor.formatOnSave": false + } +} diff --git a/package-lock.json b/package-lock.json index 470bd3d2..c84fbad9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -112,25 +112,6 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, - "node_modules/@emotion/is-prop-valid": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.3.1.tgz", - "integrity": "sha512-/ACwoqx7XQi9knQs/G0qKvv5teDMhD7bXYns9N/wM8ah8iNb8jZ2uNO0YOgiq2o2poIvVtJS2YALasQuMSQ7Kw==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@emotion/memoize": "^0.9.0" - } - }, - "node_modules/@emotion/memoize": { - "version": "0.9.0", - "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.9.0.tgz", - "integrity": "sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==", - "license": "MIT", - "optional": true, - "peer": true - }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.2", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.2.tgz", @@ -2914,31 +2895,6 @@ "node": ">= 0.8" } }, - "node_modules/encoding": { - "version": "0.1.13", - "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", - "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "iconv-lite": "^0.6.2" - } - }, - "node_modules/encoding/node_modules/iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/engine.io": { "version": "6.6.4", "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.4.tgz", diff --git a/src/e2e/goldens/build_track_after.json b/src/e2e/goldens/build_track_after.json index d5998b4c..28b725c1 100644 --- a/src/e2e/goldens/build_track_after.json +++ b/src/e2e/goldens/build_track_after.json @@ -7,8 +7,8 @@ "name": "Test game", "status": "ACTIVE", "turnDuration": 1000, - "playerIds": [6, 5, 4, 3, 2, 1], - "activePlayerId": 3, + "playerIds": [1, 2, 3, 4, 5, 6], + "activePlayerId": 4, "config": { "maxPlayers": 6, "minPlayers": 6 @@ -2091,7 +2091,7 @@ "selectedAction": 4 }, { - "playerId": 2, + "playerId": 5, "color": 7, "income": 2, "shares": 6, @@ -2100,7 +2100,7 @@ "selectedAction": 1 }, { - "playerId": 1, + "playerId": 2, "color": 5, "income": 1, "shares": 6, @@ -2199,5 +2199,5 @@ } } }, - "undoPlayerId": 3 + "undoPlayerId": 4 } diff --git a/src/e2e/goldens/create_game_after.json b/src/e2e/goldens/create_game_after.json index 67e312a2..a0918adf 100644 --- a/src/e2e/goldens/create_game_after.json +++ b/src/e2e/goldens/create_game_after.json @@ -345,10 +345,10 @@ { "group": 2, "onRoll": 2, - "goods": [0, 2, 4] + "goods": [0, 3, 3] } ], - "goods": [1, 1], + "goods": [2, 3], "type": 1 } ], @@ -766,10 +766,10 @@ { "group": 2, "onRoll": 6, - "goods": [4, 3, 0] + "goods": [0, 0, 2] } ], - "goods": [0, 2], + "goods": [0, 4], "type": 1 } ], @@ -840,10 +840,10 @@ { "group": 2, "onRoll": 4, - "goods": [2, 0, 2] + "goods": [3, 1, 1] } ], - "goods": [2, 0, 1], + "goods": [4, 3, 4], "type": 1 } ], @@ -915,10 +915,10 @@ { "group": 2, "onRoll": 5, - "goods": [1, 1, 2] + "goods": [4, 0, 4] } ], - "goods": [3, 4, 3], + "goods": [2, 1, 4], "type": 1 } ], @@ -971,10 +971,10 @@ { "group": 1, "onRoll": 5, - "goods": [2, 2, 0] + "goods": [4, 3, 0] } ], - "goods": [4, 4], + "goods": [0, 2], "type": 1 } ], @@ -1108,10 +1108,10 @@ { "group": 1, "onRoll": 4, - "goods": [2, 1, 1] + "goods": [1, 2, 1] } ], - "goods": [3, 1], + "goods": [2, 3], "type": 1 } ], @@ -1155,10 +1155,10 @@ { "group": 1, "onRoll": 6, - "goods": [3, 3, 2] + "goods": [4, 2, 2] } ], - "goods": [3, 0], + "goods": [1, 4], "type": 1 } ], @@ -1429,10 +1429,10 @@ { "group": 1, "onRoll": 2, - "goods": [0, 0, 2] + "goods": [1, 2, 0] } ], - "goods": [0, 4], + "goods": [2, 0], "type": 1 } ], @@ -1585,10 +1585,10 @@ { "group": 1, "onRoll": 1, - "goods": [4, 0, 4] + "goods": [4, 2, 3] } ], - "goods": [1, 4], + "goods": [0, 4], "type": 1 } ], @@ -1816,10 +1816,10 @@ { "group": 2, "onRoll": 1, - "goods": [2, 3, 4] + "goods": [0, 2, 4] } ], - "goods": [4, 4], + "goods": [1, 1], "type": 1 } ], @@ -1872,15 +1872,15 @@ "gridVersion": 2, "players": [ { - "playerId": 2, - "color": 7, + "playerId": 1, + "color": 9, "income": 0, "shares": 2, "money": 10, "locomotive": 1 }, { - "playerId": 3, + "playerId": 2, "color": 1, "income": 0, "shares": 2, @@ -1888,7 +1888,7 @@ "locomotive": 1 }, { - "playerId": 1, + "playerId": 3, "color": 2, "income": 0, "shares": 2, @@ -1896,7 +1896,7 @@ "locomotive": 1 } ], - "turnOrder": [1, 7, 2], + "turnOrder": [2, 9, 1], "availableCities": [ { "color": 2, @@ -1990,7 +1990,7 @@ "interCityConnections": [], "roundNumber": 1, "currentPhase": 1, - "currentPlayer": 1 + "currentPlayer": 2 } } } diff --git a/src/e2e/util/game_data.ts b/src/e2e/util/game_data.ts index 7f976567..06107e5e 100644 --- a/src/e2e/util/game_data.ts +++ b/src/e2e/util/game_data.ts @@ -1,6 +1,6 @@ import { readFile, writeFile } from "fs/promises"; import { resolve } from "path"; -import { GameStatus } from "../../api/game"; +import { GameApi, GameStatus } from "../../api/game"; import { UserRole } from "../../api/user"; import { VariantConfig } from "../../api/variant_config"; import { SerializedGameData } from "../../engine/framework/state"; @@ -72,6 +72,81 @@ function serialize({ q, r }: CoordinatesData): string { return `${q}|${r}`; } +/** + * Normalizes player IDs in game data to canonical values (1, 2, 3, etc.) + * to make snapshots portable across different database states. + * Maps actual IDs to their ordinal position in the playerIds array. + */ +function normalizePlayerIds( + gameData: GameApi & { gameData?: SerializedGameData }, +): void { + const actualPlayerIds = gameData.playerIds || []; + + // Build mapping from actual IDs to canonical IDs (1, 2, 3, ...) + const idMap = new Map(); + actualPlayerIds.forEach((id: number, index: number) => { + idMap.set(id, index + 1); + }); + + // Normalize the playerIds array itself + gameData.playerIds = Array.from( + { length: actualPlayerIds.length }, + (_, i) => i + 1, + ); + + // Normalize activePlayerId + if ( + gameData.activePlayerId !== undefined && + idMap.has(gameData.activePlayerId) + ) { + gameData.activePlayerId = idMap.get(gameData.activePlayerId)!; + } + + // Normalize undoPlayerId + if ( + gameData.undoPlayerId !== undefined && + idMap.has(gameData.undoPlayerId) + ) { + gameData.undoPlayerId = idMap.get(gameData.undoPlayerId)!; + } + + // Normalize game data internal references + const gd = gameData.gameData?.gameData; + if (gd) { + // Normalize players array - map each player's playerId and all references to player metadata + if (gd.players && Array.isArray(gd.players)) { + gd.players.forEach((player: MutablePlayerData) => { + if (player.playerId !== undefined && idMap.has(player.playerId)) { + player.playerId = idMap.get(player.playerId)!; + } + }); + } + + // Note: turnOrder contains PlayerColor enum values (e.g., [2, 9, 1]), not player IDs. + // Colors are fixed per player and don't change with ID normalization, so no action needed here. + + // Normalize any other ID references that might exist in the game state + // (This is recursive to handle nested structures) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const normalizeNestedIds = (obj: any): void => { + if (obj === null || typeof obj !== "object") return; + + for (const key in obj) { + if ( + key === "playerId" && + typeof obj[key] === "number" && + idMap.has(obj[key]) + ) { + obj[key] = idMap.get(obj[key])!; + } else if (typeof obj[key] === "object") { + normalizeNestedIds(obj[key]); + } + } + }; + normalizeNestedIds(gd); + } +} + export async function compareGameData(game: GameDao, gameDataFile: string) { await game.reload(); const actualGameDataValue = removeKeys( @@ -91,14 +166,21 @@ export async function compareGameData(game: GameDao, gameDataFile: string) { // Remove undefined values const actualGameData = JSON.parse(JSON.stringify(actualGameDataValue)); + // Normalize player IDs to canonical values for portable comparison + normalizePlayerIds(actualGameData); + if (process.env.WRITE === "true") { await writeFile( resolve(__dirname, `../goldens/${gameDataFile}.json`), - JSON.stringify(actualGameData, null, 2), + JSON.stringify(actualGameData, null, 2) + "\n", "utf-8", ); } else { - const expectedGameData = await parseFile(gameDataFile); + const expectedGameData = (await parseFile(gameDataFile)) as GameApi & { + gameData?: SerializedGameData; + }; + // Also normalize expected data to handle any legacy golden files + normalizePlayerIds(expectedGameData); expect(actualGameData).toEqual(expectedGameData); } } @@ -130,7 +212,10 @@ async function initializeGame( } export async function initializeUsers(): Promise { - const currentUsers = await UserDao.findAll({ limit: 6 }); + const currentUsers = await UserDao.findAll({ + limit: 6, + order: [["id", "ASC"]], + }); if (currentUsers.length < 6) { const newUsers = await fakeUsers( new Set(currentUsers.map((u) => u.username)), diff --git a/src/e2e/util/webdriver.ts b/src/e2e/util/webdriver.ts index 87132851..b756ed48 100644 --- a/src/e2e/util/webdriver.ts +++ b/src/e2e/util/webdriver.ts @@ -101,24 +101,62 @@ export class Driver { tileType: TileType, orientation: Direction, ) { - await this.findElementByDataAttributes({ + // Ensure the main map SVG is loaded before attempting to interact + await this.waitForElement( + By.xpath("//*[name()='svg'][@data-hex-grid='main-map']"), + ); + + const mapHex = await this.findElementByDataAttributes({ parent: By.xpath("//*[name()='svg'][@data-hex-grid='main-map']"), name: "polygon", dataAttributes: { coordinates: coordinates.serialize(), }, - }).click(); + }); + + await this.driver.executeScript( + "arguments[0].scrollIntoView({ block: 'center', inline: 'center' });", + mapHex, + ); + + // Dispatch click event with pointer events for better React integration + await this.driver.executeScript( + ` + const element = arguments[0]; + element.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })); + element.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, cancelable: true })); + element.dispatchEvent(new PointerEvent('pointerup', { bubbles: true, cancelable: true })); + `, + mapHex, + ); - await this.findElementByDataAttributes({ + // Give React time to process the events and render the building options + await new Promise((resolve) => setTimeout(resolve, 500)); + + await this.waitForElement(By.xpath("//*[@data-building-options]"), { + timeout: 3000, + }); + + const tileOption = await this.findElementByDataAttributes({ parent: By.xpath("//*[@data-building-options]"), name: "div", dataAttributes: { "tile-type": tileType, orientation: orientation, }, - }) - .findElement(By.css("polygon")) - .click(); + }); + const tilePolygon = await tileOption.findElement(By.css("polygon")); + + // Dispatch click events for tile selection + await this.driver.executeScript( + ` + const element = arguments[0]; + element.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })); + element.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, cancelable: true })); + element.dispatchEvent(new PointerEvent('pointerup', { bubbles: true, cancelable: true })); + `, + tilePolygon, + ); await this.waitForSuccess(); }