diff --git a/.gitignore b/.gitignore index cbc0dc64..742611cc 100644 --- a/.gitignore +++ b/.gitignore @@ -7,4 +7,5 @@ builds buildmeta.* *.swp *.tsbuildinfo +src/e2e/artifacts/screenshots/ scripts/seed_map_ratings_from_csv.js diff --git a/docker-compose.yml b/docker-compose.yml index a407ee84..080a9b28 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -61,4 +61,4 @@ services: volumes: postgres_data: - redis_data: + redis_data: \ No newline at end of file diff --git a/src/api/game.ts b/src/api/game.ts index 7ff656ad..4976a9f7 100644 --- a/src/api/game.ts +++ b/src/api/game.ts @@ -41,6 +41,7 @@ const ActionApi = z.object({ actionName: z.string(), actionData: z.unknown(), confirmed: z.boolean(), + performingPlayerId: z.union([z.number(), z.string()]).optional(), }); type ActionApi = z.infer; @@ -71,6 +72,23 @@ export const CreateGameApi = z artificialStart: z.boolean(), unlisted: z.boolean(), autoStart: z.boolean(), + hotseat: z.boolean().default(false), + hotseatPlayers: z + .array( + z + .string() + .min(1) + .max(32) + .regex( + /^[a-zA-Z0-9_\- ]+$/, + "Player names can only contain letters, numbers, spaces, underscores, and hyphens", + ), + ) + .optional() + .refine( + (players) => !players || new Set(players).size === players.length, + { message: "Player names must be unique" }, + ), }) .and(MapConfig) .refine((data) => data.gameKey === data.variant.gameKey, { @@ -95,6 +113,17 @@ export const CreateGameApi = z message: numPlayersMessage(data.gameKey), path: ["maxPlayers"], }), + ) + .refine( + (data) => { + if (!data.hotseat) return true; + // Hotseat games must have player names for at least the minimum number of players + return data.hotseatPlayers !== undefined && data.hotseatPlayers.length >= data.minPlayers; + }, + { + message: "Hotseat games require player names for all minimum players", + path: ["hotseatPlayers"], + }, ); export type CreateGameApi = z.infer; @@ -144,23 +173,25 @@ export const GameLiteApi = z.object({ id: z.number(), gameKey: GameKeyZod, name: z.string(), - playerIds: z.array(z.number()), + playerIds: z.array(z.union([z.number(), z.string()])), status: GameStatus, - activePlayerId: z.number().optional(), + activePlayerId: z.union([z.number(), z.string()]).optional(), config: MapConfig, variant: VariantConfig, turnDuration: TurnDurationZod.or(z.number()), summary: z.string().optional(), unlisted: z.boolean(), + hotseat: z.boolean().default(false), + ownerId: z.number().optional(), }); export type GameLiteApi = z.infer; export const GameApi = GameLiteApi.extend({ version: z.number(), turnStartTime: z.string().optional(), - concedingPlayers: z.number().array(), + concedingPlayers: z.array(z.union([z.number(), z.string()])), gameData: z.string().optional(), - undoPlayerId: z.number().optional(), + undoPlayerId: z.union([z.number(), z.string()]).optional(), }); export type GameApi = z.infer; diff --git a/src/client/auto_action/form.tsx b/src/client/auto_action/form.tsx index b332230b..667c8d84 100644 --- a/src/client/auto_action/form.tsx +++ b/src/client/auto_action/form.tsx @@ -43,7 +43,7 @@ export function AutoActionForm() { !canEdit || me == null || game.status !== GameStatus.enum.ACTIVE || - !game.playerIds.includes(me.id) + !game.playerIds.some((id) => Number(id) === me.id) ) return <>; diff --git a/src/client/components/username.tsx b/src/client/components/username.tsx index 7ca3aacd..bc4f0cf8 100644 --- a/src/client/components/username.tsx +++ b/src/client/components/username.tsx @@ -4,13 +4,19 @@ import { useUsers, useUserUnsuspended } from "../services/user"; import * as styles from "./username.module.css"; interface UsernameProps { - userId: number; + userId: number | string; useAt?: boolean; useLink?: boolean; suspense?: boolean; } export function Username({ userId, useAt, useLink }: UsernameProps) { + // For string player IDs (hotseat players), render directly without API call + if (typeof userId === "string") { + return <>{(useAt ? "@" : "") + userId}; + } + + // For numeric user IDs, fetch from API const { data, isPending } = useUserUnsuspended(userId); return ( @@ -51,19 +57,36 @@ function MaybeLink({ username, userId, useAt, useLink }: MaybeLinkProps) { } interface UsernameListProps { - userIds: number[]; + userIds: (number | string)[]; useLink?: boolean; } export function UsernameList({ userIds, useLink }: UsernameListProps) { - const users = useUsers(userIds); + // Separate numeric and string IDs for useUsers query + const numericIds = userIds.filter((id): id is number => typeof id === "number"); + + const users = useUsers(numericIds); + + // Combine users and string IDs in original order + const allNames = userIds.map((id) => { + if (typeof id === "string") { + return { id, username: id, isString: true }; + } else { + const user = users.find((u) => u?.id === id); + return user ? { ...user, isString: false } : null; + } + }).filter(isNotNull); return ( <> - {users.filter(isNotNull).map(({ id, username }, index) => ( - + {allNames.map(({ id, username, isString }, index) => ( + {index !== 0 && ", "} - + {isString ? ( + <>{username} + ) : ( + + )} ))} diff --git a/src/client/game/active_game.module.css b/src/client/game/active_game.module.css index 4a1c028d..69996983 100644 --- a/src/client/game/active_game.module.css +++ b/src/client/game/active_game.module.css @@ -2,6 +2,25 @@ font-size: 1rem; } +.headerRow { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.hotseatBadge { + display: inline-block; + padding: 2px 8px; + border-radius: 999px; + font-size: 0.75rem; + font-weight: 600; + font-family: sans-serif; + letter-spacing: 0.02em; + background: #e6f3ff; + color: #1f6fb2; +} + .currentPlayer { font-weight: bold; text-decoration: underline; diff --git a/src/client/game/active_game.tsx b/src/client/game/active_game.tsx index 484efab4..5c520249 100644 --- a/src/client/game/active_game.tsx +++ b/src/client/game/active_game.tsx @@ -114,10 +114,13 @@ function TurnOrder() { function GameHeader() { const game = useGame(); return ( -

- [{game.name}] {ViewRegistry.singleton.get(game.gameKey).name} -{" "} - {game.summary} -

+
+

+ [{game.name}] {ViewRegistry.singleton.get(game.gameKey).name} -{" "} + {game.summary} +

+ {game.hotseat && Hotseat} +
); } diff --git a/src/client/game/create_page.tsx b/src/client/game/create_page.tsx index 2546f081..71a015b7 100644 --- a/src/client/game/create_page.tsx +++ b/src/client/game/create_page.tsx @@ -71,6 +71,8 @@ export function CreateGamePage() { const [artificialStart, setArtificialStart] = useSemanticUiCheckboxState(); const [unlisted, setUnlisted] = useSemanticUiCheckboxState(); const [autoStart, setAutoStart] = useSemanticUiCheckboxState(true); + const [hotseat, setHotseat] = useSemanticUiCheckboxState(false); + const [hotseatPlayers, setHotseatPlayers] = useState(["Alice", "Bob"]); const [minPlayersS, setMinPlayers, setMinPlayersRaw] = useNumberInputState( selectedMap.minPlayers, ); @@ -125,6 +127,8 @@ export function CreateGamePage() { unlisted, autoStart, variant: variant as VariantConfig, + hotseat, + hotseatPlayers: hotseat ? hotseatPlayers : undefined, }); }, [ @@ -139,6 +143,8 @@ export function CreateGamePage() { maxPlayers, turnDuration, variant, + hotseat, + hotseatPlayers, ], ); @@ -153,6 +159,8 @@ export function CreateGamePage() { unlisted, autoStart, variant: variant as VariantConfig, + hotseat, + hotseatPlayers: hotseat ? hotseatPlayers : undefined, }); }, [ name, @@ -164,6 +172,8 @@ export function CreateGamePage() { unlisted, autoStart, turnDuration, + hotseat, + hotseatPlayers, ]); const Editor = selectedMap.getVariantConfigEditor; @@ -260,12 +270,69 @@ export function CreateGamePage() { toggle label="Artificial Start" checked={artificialStart} - disabled={isPending} + disabled={isPending || hotseat} onChange={setArtificialStart} error={validationError?.artificialStart} /> )} + + + {hotseat && ( +
+ + {hotseatPlayers.map((playerName, index) => ( + ) => { + const newPlayers = [...hotseatPlayers]; + newPlayers[index] = e.target.value; + setHotseatPlayers(newPlayers); + }} + error={validationError?.hotseatPlayers} + /> + ))} + + +
+ )} + diff --git a/src/client/game/final_overview.module.css b/src/client/game/final_overview.module.css index 1ffcd209..f4c5565e 100644 --- a/src/client/game/final_overview.module.css +++ b/src/client/game/final_overview.module.css @@ -2,6 +2,26 @@ margin-bottom: 32px; } +.headerRow { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + margin-bottom: 8px; +} + +.hotseatBadge { + display: inline-block; + padding: 2px 8px; + border-radius: 999px; + font-size: 0.75rem; + font-weight: 600; + font-family: sans-serif; + letter-spacing: 0.02em; + background: #e6f3ff; + color: #1f6fb2; +} + .tableContainer { max-width: 100%; overflow-x: scroll; diff --git a/src/client/game/final_overview.tsx b/src/client/game/final_overview.tsx index a443797e..041954fc 100644 --- a/src/client/game/final_overview.tsx +++ b/src/client/game/final_overview.tsx @@ -16,6 +16,7 @@ export function FinalOverview() { } function FinalOverviewInternal() { + const game = useGame(); const playerHelper = useInjectedMemo(PlayerHelper); const playersOrdered = useMemo(() => { @@ -28,7 +29,12 @@ function FinalOverviewInternal() { return (
-

Final Overview

+
+

Final Overview

+ {game.hotseat && ( + Hotseat + )} +
diff --git a/src/client/game/game_log.tsx b/src/client/game/game_log.tsx index 138ef3b5..850687b8 100644 --- a/src/client/game/game_log.tsx +++ b/src/client/game/game_log.tsx @@ -37,7 +37,7 @@ import { isGameHistory, useGame } from "../services/game"; export function GameLog() { const game = useGame(); const userColorLookup = useInject(() => { - const lookup: Record = {}; + const lookup: Record = {}; injectAllPlayersUnsafe()().forEach((player) => { lookup[player.playerId] = player.color; }); diff --git a/src/client/game/options.tsx b/src/client/game/options.tsx index db752cef..59e136f0 100644 --- a/src/client/game/options.tsx +++ b/src/client/game/options.tsx @@ -25,7 +25,7 @@ export function GameOptions() { if ( game.status !== GameStatus.enum.ACTIVE || me == null || - !game.playerIds.includes(me.id) + !game.playerIds.some((id) => Number(id) === me.id) ) { return <>; } diff --git a/src/client/game/player_stats.tsx b/src/client/game/player_stats.tsx index e0016ae7..329b5b6e 100644 --- a/src/client/game/player_stats.tsx +++ b/src/client/game/player_stats.tsx @@ -190,9 +190,11 @@ export function PlayerStats() { ); })} {isExpanded && ( @@ -229,6 +231,7 @@ export function PlayerStats() { ); })} +
- - Switch - + {typeof player.playerId === "number" && ( + + Switch + + )}
diff --git a/src/client/game/switch.tsx b/src/client/game/switch.tsx index b88eb7ad..77a71266 100644 --- a/src/client/game/switch.tsx +++ b/src/client/game/switch.tsx @@ -4,7 +4,9 @@ import { LoginButton } from "./login_button"; export function SwitchToUndo() { const game = useGame(); - if (game.undoPlayerId == null) return <>; + if (game.undoPlayerId == null || typeof game.undoPlayerId === "string") { + return <>; + } return ( Switch to undo user ); @@ -13,6 +15,7 @@ export function SwitchToUndo() { export function SwitchToActive() { const currentPlayer = useCurrentPlayer(); if (currentPlayer == null) return <>; + if (typeof currentPlayer.playerId === "string") return <>; return ( Switch to active user diff --git a/src/client/home/game_card.module.css b/src/client/home/game_card.module.css index 2aa309c8..1d961587 100644 --- a/src/client/home/game_card.module.css +++ b/src/client/home/game_card.module.css @@ -1,4 +1,5 @@ .gameCard { + position: relative; } .gameCardHeader { @@ -6,6 +7,21 @@ border-radius: inherit; } +.hotseatBadge { + position: absolute; + bottom: 8px; + right: 8px; + padding: 2px 8px; + border-radius: 999px; + font-size: 0.75em; + font-weight: 600; + font-family: sans-serif; + letter-spacing: 0.02em; + background: #e6f3ff; + color: #1f6fb2; + white-space: nowrap; +} + :global(.ui.card) :global(.meta).gameCardMeta { font-size: 0.8em; } diff --git a/src/client/home/game_card.tsx b/src/client/home/game_card.tsx index a0420130..8ceb8e9e 100644 --- a/src/client/home/game_card.tsx +++ b/src/client/home/game_card.tsx @@ -43,7 +43,7 @@ export function GameCard({ game, hideStatus }: GameCardProps) { {game.name} Active Player:{" "} - +

)}

- Players: + Players:

{game.status === GameStatus.enum.LOBBY && (

Seats: {seats(game)}

@@ -78,11 +78,12 @@ export function GameCard({ game, hideStatus }: GameCardProps) {
- - + {!game.hotseat && } + {!game.hotseat && } + {game.hotseat && Hotseat}
); } diff --git a/src/client/services/action.tsx b/src/client/services/action.tsx index 77516661..4d7ec4f1 100644 --- a/src/client/services/action.tsx +++ b/src/client/services/action.tsx @@ -67,7 +67,7 @@ interface ActionHandler { emit(data: T): void; canEmit: boolean; isPending: boolean; - canEmitUserId?: number; + canEmitUserId?: number | string; getErrorMessage(t: T): string | undefined; } @@ -132,7 +132,13 @@ export function useAction( mutate( { params: { gameId: game.id }, - body: { actionName, actionData, confirmed }, + body: { + actionName, + actionData, + confirmed, + // For hotseat games, include the active player ID + performingPlayerId: game.hotseat ? game.activePlayerId : undefined, + }, }, { onError: (error) => { @@ -162,7 +168,7 @@ export function useAction( }, ); }, - [game.id, actionName], + [game.id, actionName, game.hotseat, game.activePlayerId], ); const actionCanBeEmitted = @@ -170,8 +176,12 @@ export function useAction( phaseDelegator.get().canEmit(action); const canEmitUserId = actionCanBeEmitted ? game.activePlayerId : undefined; - const canEmit = - me?.id === game.activePlayerId && actionCanBeEmitted && canEditGame(game); + + // For hotseat games, allow anyone to perform actions + // For regular games, only the active player can perform actions + const canEmit = game.hotseat + ? actionCanBeEmitted && canEditGame(game) + : String(me?.id) === String(game.activePlayerId) && actionCanBeEmitted && canEditGame(game); const getErrorMessage = useCallback( (data: T) => { try { diff --git a/src/client/services/game.ts b/src/client/services/game.ts index f0979817..33de8525 100644 --- a/src/client/services/game.ts +++ b/src/client/services/game.ts @@ -37,9 +37,9 @@ function checkMatch(game: GameLiteApi, entry: Entry): boolean { if (value == null) return true; switch (key) { case "userId": - return game.playerIds.includes(value); + return game.playerIds.includes(String(value)); case "excludeUserId": - return !game.playerIds.includes(value); + return !game.playerIds.includes(String(value)); case "status": return value.includes(game.status); case "gameKey": @@ -150,7 +150,7 @@ export function useGameList(baseQuery: ListGamesApi) { ); // Do not add unlisted games to the UI // FIXME: Ideally this should be excluded server-side, but since updates are broadcast to all sockets it's a bit tricky to control there - if (!present && game.unlisted && (me === undefined || game.playerIds.indexOf(me.id) === -1)) { + if (!present && game.unlisted && (me === undefined || game.playerIds.indexOf(String(me.id)) === -1)) { return pages; } @@ -361,7 +361,7 @@ export function useDeleteGame(game: GameLiteApi) { const canBeDeleted = game.status === GameStatus.enum.LOBBY || game.playerIds.length === 1; - const canPerform = isAdmin || (canBeDeleted && game.playerIds[0] === me?.id); + const canPerform = isAdmin || (canBeDeleted && Number(game.playerIds[0]) === me?.id); return { canPerform, perform, isPending }; } @@ -386,7 +386,7 @@ export function useJoinGame(game: GameLiteApi): GameAction { const canPerform = me != null && game.status == GameStatus.enum.LOBBY && - !game.playerIds.includes(me.id) && + !game.playerIds.some((id) => Number(id) === me.id) && game.playerIds.length < game.config.maxPlayers; return { canPerform, perform, isPending }; @@ -406,8 +406,8 @@ export function useLeaveGame(game: GameLiteApi): GameAction { const canPerform = me != null && game.status == GameStatus.enum.LOBBY && - game.playerIds.includes(me.id) && - game.playerIds[0] !== me.id; + game.playerIds.some((id) => Number(id) === me.id) && + Number(game.playerIds[0]) !== me.id; return { canPerform, perform, isPending }; } @@ -453,7 +453,9 @@ export function useStartGame(game: GameLiteApi) { const canPerform = me != null && game.status == GameStatus.enum.LOBBY && - game.playerIds[0] === me.id && + (game.hotseat + ? game.ownerId != null && game.ownerId === me.id + : Number(game.playerIds[0]) === me.id) && game.playerIds.length >= game.config.minPlayers; return { canPerform, perform, isPending }; @@ -498,8 +500,9 @@ export function useUndoAction(): UndoAction { const { mutate, error, isPending } = tsr.games.undoAction.useMutation(); handleError(isPending, error); - const canUndoBecausePlayer = - game.undoPlayerId != null && game.undoPlayerId === me?.id; + const canUndoBecausePlayer = game.hotseat + ? game.ownerId != null && game.ownerId === me?.id && game.undoPlayerId != null + : game.undoPlayerId != null && game.undoPlayerId === me?.id; const canUndoBecauseAdmin = isAdmin; const canUndo = canEditGame(game) && (canUndoBecausePlayer || canUndoBecauseAdmin); diff --git a/src/e2e/create_game_test.ts b/src/e2e/create_game_test.ts index 0bf8a03e..dd15ea15 100644 --- a/src/e2e/create_game_test.ts +++ b/src/e2e/create_game_test.ts @@ -31,6 +31,7 @@ export function creatingGame(driver: Driver) { await joinGame(users[2], game); await startGame(game, "fooval"); + await waitForGameActive(game); await compareGameData(game, "create_game_after"); }); @@ -56,14 +57,31 @@ export function creatingGame(driver: Driver) { } async function startGame(game: GameDao, seedValue: string): Promise { - await driver.goToGame(game.id, game.playerIds[0]); - const seedEl = await driver.waitForElement(By.xpath('//*[@name="seed"]')); - await driver.driver.executeScript( - `arguments[0].value = arguments[1];`, - seedEl, - seedValue, + const firstPlayerId = game.playerIds[0]; + const numericPlayerId = Number(firstPlayerId); + assert( + Number.isFinite(numericPlayerId), + "First player should be a numeric user ID", ); - await driver.waitForElement(By.xpath("//*[@data-start-button]")).click(); + await driver.goToGame(game.id, numericPlayerId); + + // Check game status in database + await game.reload(); + const status = game.status; + assert(status === "LOBBY", `Expected game to be in LOBBY but is in ${status}`); + + const seedEls = await driver.driver.findElements(By.name("seed")); + if (seedEls.length > 0) { + await driver.driver.executeScript( + `arguments[0].value = arguments[1];`, + seedEls[0], + seedValue, + ); + } + const startButton = await driver.waitForElement( + By.xpath("//*[@data-start-button]"), + ); + await startButton.click(); await driver.waitForSuccess(); } @@ -71,4 +89,12 @@ export function creatingGame(driver: Driver) { await driver.goToGame(game.id, user.id); await driver.waitForElement(By.xpath("//*[@data-join-button]")).click(); } + + async function waitForGameActive(game: GameDao): Promise { + for (let i = 0; i < 20; i++) { + await game.reload(); + if (game.status === "ACTIVE") return; + await new Promise((resolve) => setTimeout(resolve, 200)); + } + } } diff --git a/src/e2e/e2e_test.ts b/src/e2e/e2e_test.ts index 99418246..00f094b4 100644 --- a/src/e2e/e2e_test.ts +++ b/src/e2e/e2e_test.ts @@ -1,5 +1,6 @@ import { buildingTrack } from "./build_track_test"; import { creatingGame } from "./create_game_test"; +import { hotseatGame } from "./hotseat_game_test"; import { setUpServer } from "./util/server"; import { setTestTimeout } from "./util/timeout"; import { Driver, setUpWebDriver } from "./util/webdriver"; @@ -19,4 +20,5 @@ describe("e2e tests", () => { describe("Building track", () => buildingTrack(driver)); describe("creating game", () => creatingGame(driver)); + describe("hotseat game", () => hotseatGame(driver)); }); diff --git a/src/e2e/goldens/build_track_after.json b/src/e2e/goldens/build_track_after.json index d5998b4c..bea82d3c 100644 --- a/src/e2e/goldens/build_track_after.json +++ b/src/e2e/goldens/build_track_after.json @@ -7,8 +7,15 @@ "name": "Test game", "status": "ACTIVE", "turnDuration": 1000, - "playerIds": [6, 5, 4, 3, 2, 1], - "activePlayerId": 3, + "playerIds": [ + "6", + "5", + "4", + "3", + "2", + "1" + ], + "activePlayerId": "3", "config": { "maxPlayers": 6, "minPlayers": 6 @@ -21,7 +28,30 @@ "version": 3, "gameData": { "bag": [ - 4, 3, 4, 0, 1, 0, 3, 3, 0, 3, 0, 0, 3, 0, 3, 3, 2, 1, 4, 2, 3, 2, 0, 2 + 4, + 3, + 4, + 0, + 1, + 0, + 3, + 3, + 0, + 3, + 0, + 0, + 3, + 0, + 3, + 3, + 2, + 1, + 4, + 2, + 3, + 2, + 0, + 2 ], "grid": [ [ @@ -113,13 +143,22 @@ { "type": 1, "name": "Kansas City", - "color": [3], - "goods": [3, 1, 0, 1], + "color": [ + 3 + ], + "goods": [ + 3, + 1, + 0, + 1 + ], "onRoll": [ { "onRoll": 3, "group": 1, - "goods": [3] + "goods": [ + 3 + ] } ], "startingNumCubes": 2 @@ -163,7 +202,12 @@ "tile": { "tileType": 111, "orientation": 4, - "owners": [7, 7, 1, 7] + "owners": [ + 7, + 7, + 1, + 7 + ] } } ], @@ -177,7 +221,9 @@ "tile": { "tileType": 2, "orientation": 2, - "owners": [7] + "owners": [ + 7 + ] } } ], @@ -274,7 +320,9 @@ "tile": { "tileType": 2, "orientation": 3, - "owners": [7] + "owners": [ + 7 + ] } } ], @@ -358,13 +406,21 @@ { "type": 1, "name": "Cincinnati", - "color": [0], - "goods": [2], + "color": [ + 0 + ], + "goods": [ + 2 + ], "onRoll": [ { "onRoll": 2, "group": 2, - "goods": [0, 2, 4] + "goods": [ + 0, + 2, + 4 + ] } ], "startingNumCubes": 2 @@ -515,13 +571,22 @@ { "type": 1, "name": "Detroit", - "color": [2], - "goods": [1, 2], + "color": [ + 2 + ], + "goods": [ + 1, + 2 + ], "onRoll": [ { "onRoll": 3, "group": 2, - "goods": [0, 2, 4] + "goods": [ + 0, + 2, + 4 + ] } ], "startingNumCubes": 2 @@ -546,7 +611,9 @@ "tile": { "tileType": 2, "orientation": 3, - "owners": [4] + "owners": [ + 4 + ] } } ], @@ -641,7 +708,9 @@ "tile": { "tileType": 1, "orientation": 6, - "owners": [4] + "owners": [ + 4 + ] } } ], @@ -673,7 +742,9 @@ "tile": { "tileType": 2, "orientation": 3, - "owners": [3] + "owners": [ + 3 + ] } } ], @@ -737,7 +808,10 @@ { "onRoll": 1, "group": 2, - "goods": [2, 2] + "goods": [ + 2, + 2 + ] } ] } @@ -761,7 +835,9 @@ "tile": { "tileType": 2, "orientation": 5, - "owners": [4] + "owners": [ + 4 + ] } } ], @@ -775,7 +851,9 @@ "tile": { "tileType": 2, "orientation": 4, - "owners": [4] + "owners": [ + 4 + ] } } ], @@ -814,13 +892,22 @@ { "type": 1, "name": "Toronto", - "color": [4], - "goods": [1, 3, 0], + "color": [ + 4 + ], + "goods": [ + 1, + 3, + 0 + ], "onRoll": [ { "onRoll": 6, "group": 2, - "goods": [4, 0] + "goods": [ + 4, + 0 + ] } ], "startingNumCubes": 2 @@ -863,7 +950,9 @@ "tile": { "tileType": 1, "orientation": 4, - "owners": [4] + "owners": [ + 4 + ] } } ], @@ -877,7 +966,9 @@ "tile": { "tileType": 1, "orientation": 6, - "owners": [4] + "owners": [ + 4 + ] } } ], @@ -891,7 +982,9 @@ "tile": { "tileType": 2, "orientation": 5, - "owners": [3] + "owners": [ + 3 + ] } } ], @@ -903,13 +996,22 @@ { "type": 1, "name": "Wheeling", - "color": [4], - "goods": [4, 4], + "color": [ + 4 + ], + "goods": [ + 4, + 4 + ], "onRoll": [ { "onRoll": 4, "group": 2, - "goods": [3, 1, 3] + "goods": [ + 3, + 1, + 3 + ] } ], "startingNumCubes": 3 @@ -978,8 +1080,16 @@ { "type": 1, "name": "Pittsburgh", - "color": [2], - "goods": [3, 3, 4, 2, 0], + "color": [ + 2 + ], + "goods": [ + 3, + 3, + 4, + 2, + 0 + ], "onRoll": [ { "onRoll": 5, @@ -1000,7 +1110,9 @@ "tile": { "tileType": 2, "orientation": 2, - "owners": [3] + "owners": [ + 3 + ] } } ], @@ -1039,13 +1151,22 @@ { "type": 1, "name": "Minneapolis", - "color": [0], - "goods": [2, 1], + "color": [ + 0 + ], + "goods": [ + 2, + 1 + ], "onRoll": [ { "onRoll": 5, "group": 1, - "goods": [4, 0, 2] + "goods": [ + 4, + 0, + 2 + ] } ], "startingNumCubes": 2 @@ -1115,7 +1236,9 @@ "tile": { "tileType": 1, "orientation": 6, - "owners": [5] + "owners": [ + 5 + ] } } ], @@ -1181,13 +1304,22 @@ { "type": 1, "name": "Des Moines", - "color": [0], - "goods": [2, 3], + "color": [ + 0 + ], + "goods": [ + 2, + 3 + ], "onRoll": [ { "onRoll": 4, "group": 1, - "goods": [0, 4, 2] + "goods": [ + 0, + 4, + 2 + ] } ], "startingNumCubes": 2 @@ -1203,7 +1335,9 @@ "tile": { "tileType": 2, "orientation": 4, - "owners": [5] + "owners": [ + 5 + ] } } ], @@ -1217,7 +1351,9 @@ "tile": { "tileType": 2, "orientation": 6, - "owners": [5] + "owners": [ + 5 + ] } } ], @@ -1238,13 +1374,22 @@ { "type": 1, "name": "Duluth", - "color": [3], - "goods": [3, 1, 4, 3], + "color": [ + 3 + ], + "goods": [ + 3, + 1, + 4, + 3 + ], "onRoll": [ { "onRoll": 6, "group": 1, - "goods": [2] + "goods": [ + 2 + ] } ], "startingNumCubes": 2 @@ -1323,7 +1468,9 @@ "tile": { "tileType": 1, "orientation": 4, - "owners": [5] + "owners": [ + 5 + ] } } ], @@ -1337,7 +1484,9 @@ "tile": { "tileType": 2, "orientation": 3, - "owners": [5] + "owners": [ + 5 + ] } } ], @@ -1424,7 +1573,10 @@ "tile": { "tileType": 13, "orientation": 1, - "owners": [5, 5] + "owners": [ + 5, + 5 + ] } } ], @@ -1438,7 +1590,9 @@ "tile": { "tileType": 3, "orientation": 3, - "owners": [5] + "owners": [ + 5 + ] } } ], @@ -1525,7 +1679,9 @@ "tile": { "tileType": 2, "orientation": 5, - "owners": [1] + "owners": [ + 1 + ] } } ], @@ -1537,13 +1693,20 @@ { "type": 1, "name": "St. Louis", - "color": [2], - "goods": [4], + "color": [ + 2 + ], + "goods": [ + 4 + ], "onRoll": [ { "onRoll": 2, "group": 1, - "goods": [1, 3] + "goods": [ + 1, + 3 + ] } ], "startingNumCubes": 2 @@ -1604,7 +1767,9 @@ "tile": { "tileType": 2, "orientation": 5, - "owners": [6] + "owners": [ + 6 + ] } } ], @@ -1618,7 +1783,9 @@ "tile": { "tileType": 1, "orientation": 5, - "owners": [6] + "owners": [ + 6 + ] } } ], @@ -1631,7 +1798,10 @@ "type": 1, "name": "Springfield", "color": 1, - "goods": [4, 4], + "goods": [ + 4, + 4 + ], "urbanized": true, "onRoll": [ { @@ -1652,7 +1822,9 @@ "tile": { "tileType": 2, "orientation": 2, - "owners": [6] + "owners": [ + 6 + ] } } ], @@ -1666,7 +1838,9 @@ "tile": { "tileType": 2, "orientation": 3, - "owners": [7] + "owners": [ + 7 + ] } } ], @@ -1716,7 +1890,9 @@ "tile": { "tileType": 1, "orientation": 5, - "owners": [6] + "owners": [ + 6 + ] } } ], @@ -1728,13 +1904,21 @@ { "type": 1, "name": "Chicago", - "color": [2], - "goods": [4, 1], + "color": [ + 2 + ], + "goods": [ + 4, + 1 + ], "onRoll": [ { "onRoll": 1, "group": 1, - "goods": [1, 2] + "goods": [ + 1, + 2 + ] } ], "startingNumCubes": 2 @@ -1750,7 +1934,9 @@ "tile": { "tileType": 1, "orientation": 5, - "owners": [1] + "owners": [ + 1 + ] } } ], @@ -1764,7 +1950,9 @@ "tile": { "tileType": 2, "orientation": 2, - "owners": [1] + "owners": [ + 1 + ] } } ], @@ -1778,7 +1966,9 @@ "tile": { "tileType": 1, "orientation": 4, - "owners": [6] + "owners": [ + 6 + ] } } ], @@ -1876,7 +2066,10 @@ "tile": { "tileType": 102, "orientation": 4, - "owners": [6, 6] + "owners": [ + 6, + 6 + ] } } ], @@ -1890,7 +2083,9 @@ "tile": { "tileType": 2, "orientation": 3, - "owners": [7] + "owners": [ + 7 + ] } } ], @@ -1968,7 +2163,9 @@ "tile": { "tileType": 1, "orientation": 4, - "owners": [7] + "owners": [ + 7 + ] } } ], @@ -1982,7 +2179,9 @@ "tile": { "tileType": 2, "orientation": 5, - "owners": [1] + "owners": [ + 1 + ] } } ], @@ -1994,13 +2193,22 @@ { "type": 1, "name": "Evansville", - "color": [0], - "goods": [4, 4, 0, 3], + "color": [ + 0 + ], + "goods": [ + 4, + 4, + 0, + 3 + ], "onRoll": [ { "onRoll": 1, "group": 2, - "goods": [0] + "goods": [ + 0 + ] } ], "startingNumCubes": 2 @@ -2055,7 +2263,7 @@ "gridVersion": 2, "players": [ { - "playerId": 6, + "playerId": "6", "color": 3, "income": 1, "shares": 8, @@ -2064,7 +2272,7 @@ "selectedAction": 2 }, { - "playerId": 5, + "playerId": "5", "color": 6, "income": 2, "shares": 6, @@ -2073,7 +2281,7 @@ "selectedAction": 0 }, { - "playerId": 4, + "playerId": "4", "color": 4, "income": 1, "shares": 6, @@ -2082,7 +2290,7 @@ "selectedAction": 5 }, { - "playerId": 3, + "playerId": "3", "color": 1, "income": 2, "shares": 7, @@ -2091,7 +2299,7 @@ "selectedAction": 4 }, { - "playerId": 2, + "playerId": "2", "color": 7, "income": 2, "shares": 6, @@ -2100,7 +2308,7 @@ "selectedAction": 1 }, { - "playerId": 1, + "playerId": "1", "color": 5, "income": 1, "shares": 6, @@ -2109,7 +2317,14 @@ "selectedAction": 3 } ], - "turnOrder": [4, 6, 5, 7, 1, 3], + "turnOrder": [ + 4, + 6, + 5, + 7, + 1, + 3 + ], "availableCities": [ { "color": 2, @@ -2117,7 +2332,10 @@ { "onRoll": 3, "group": 1, - "goods": [2, 1] + "goods": [ + 2, + 1 + ] } ], "goods": [] @@ -2128,7 +2346,10 @@ { "onRoll": 4, "group": 1, - "goods": [2, 4] + "goods": [ + 2, + 4 + ] } ], "goods": [] @@ -2139,7 +2360,10 @@ { "onRoll": 5, "group": 1, - "goods": [0, 2] + "goods": [ + 0, + 2 + ] } ], "goods": [] @@ -2150,7 +2374,10 @@ { "onRoll": 2, "group": 2, - "goods": [1, 0] + "goods": [ + 1, + 0 + ] } ], "goods": [] @@ -2161,7 +2388,10 @@ { "onRoll": 3, "group": 2, - "goods": [1, 0] + "goods": [ + 1, + 0 + ] } ], "goods": [] @@ -2172,7 +2402,10 @@ { "onRoll": 4, "group": 2, - "goods": [4, 1] + "goods": [ + 4, + 1 + ] } ], "goods": [] @@ -2199,5 +2432,6 @@ } } }, - "undoPlayerId": 3 + "undoPlayerId": 3, + "hotseat": false } diff --git a/src/e2e/goldens/create_game_after.json b/src/e2e/goldens/create_game_after.json index 67e312a2..c5ae583b 100644 --- a/src/e2e/goldens/create_game_after.json +++ b/src/e2e/goldens/create_game_after.json @@ -6,8 +6,12 @@ "name": "My Game", "status": "ACTIVE", "turnDuration": 86400000, - "playerIds": [1, 2, 3], - "activePlayerId": 3, + "playerIds": [ + "1", + "2", + "3" + ], + "activePlayerId": "3", "config": { "maxPlayers": 6, "minPlayers": 3 @@ -19,7 +23,26 @@ "gameData": { "version": 3, "gameData": { - "bag": [0, 1, 0, 2, 3, 1, 0, 0, 3, 3, 2, 3, 2, 3, 0, 4, 1, 3], + "bag": [ + 0, + 1, + 0, + 2, + 3, + 1, + 0, + 0, + 3, + 3, + 2, + 3, + 2, + 3, + 0, + 4, + 1, + 3 + ], "grid": [ [ { @@ -109,16 +132,25 @@ }, { "name": "Kansas City", - "color": [3], + "color": [ + 3 + ], "startingNumCubes": 2, "onRoll": [ { "group": 1, "onRoll": 3, - "goods": [4, 4, 4] + "goods": [ + 4, + 4, + 4 + ] } ], - "goods": [4, 2], + "goods": [ + 4, + 2 + ], "type": 1 } ], @@ -339,16 +371,25 @@ }, { "name": "Cincinnati", - "color": [0], + "color": [ + 0 + ], "startingNumCubes": 2, "onRoll": [ { "group": 2, "onRoll": 2, - "goods": [0, 2, 4] + "goods": [ + 0, + 2, + 4 + ] } ], - "goods": [1, 1], + "goods": [ + 1, + 1 + ], "type": 1 } ], @@ -496,16 +537,25 @@ }, { "name": "Detroit", - "color": [2], + "color": [ + 2 + ], "startingNumCubes": 2, "onRoll": [ { "group": 2, "onRoll": 3, - "goods": [0, 1, 0] + "goods": [ + 0, + 1, + 0 + ] } ], - "goods": [1, 0], + "goods": [ + 1, + 0 + ], "type": 1 } ], @@ -760,16 +810,25 @@ }, { "name": "Toronto", - "color": [4], + "color": [ + 4 + ], "startingNumCubes": 2, "onRoll": [ { "group": 2, "onRoll": 6, - "goods": [4, 3, 0] + "goods": [ + 4, + 3, + 0 + ] } ], - "goods": [0, 2], + "goods": [ + 0, + 2 + ], "type": 1 } ], @@ -834,16 +893,26 @@ }, { "name": "Wheeling", - "color": [4], + "color": [ + 4 + ], "startingNumCubes": 3, "onRoll": [ { "group": 2, "onRoll": 4, - "goods": [2, 0, 2] + "goods": [ + 2, + 0, + 2 + ] } ], - "goods": [2, 0, 1], + "goods": [ + 2, + 0, + 1 + ], "type": 1 } ], @@ -909,16 +978,26 @@ }, { "name": "Pittsburgh", - "color": [2], + "color": [ + 2 + ], "startingNumCubes": 3, "onRoll": [ { "group": 2, "onRoll": 5, - "goods": [1, 1, 2] + "goods": [ + 1, + 1, + 2 + ] } ], - "goods": [3, 4, 3], + "goods": [ + 3, + 4, + 3 + ], "type": 1 } ], @@ -965,16 +1044,25 @@ }, { "name": "Minneapolis", - "color": [0], + "color": [ + 0 + ], "startingNumCubes": 2, "onRoll": [ { "group": 1, "onRoll": 5, - "goods": [2, 2, 0] + "goods": [ + 2, + 2, + 0 + ] } ], - "goods": [4, 4], + "goods": [ + 4, + 4 + ], "type": 1 } ], @@ -1102,16 +1190,25 @@ }, { "name": "Des Moines", - "color": [0], + "color": [ + 0 + ], "startingNumCubes": 2, "onRoll": [ { "group": 1, "onRoll": 4, - "goods": [2, 1, 1] + "goods": [ + 2, + 1, + 1 + ] } ], - "goods": [3, 1], + "goods": [ + 3, + 1 + ], "type": 1 } ], @@ -1149,16 +1246,25 @@ }, { "name": "Duluth", - "color": [3], + "color": [ + 3 + ], "startingNumCubes": 2, "onRoll": [ { "group": 1, "onRoll": 6, - "goods": [3, 3, 2] + "goods": [ + 3, + 3, + 2 + ] } ], - "goods": [3, 0], + "goods": [ + 3, + 0 + ], "type": 1 } ], @@ -1423,16 +1529,25 @@ }, { "name": "St. Louis", - "color": [2], + "color": [ + 2 + ], "startingNumCubes": 2, "onRoll": [ { "group": 1, "onRoll": 2, - "goods": [0, 0, 2] + "goods": [ + 0, + 0, + 2 + ] } ], - "goods": [0, 4], + "goods": [ + 0, + 4 + ], "type": 1 } ], @@ -1579,16 +1694,25 @@ }, { "name": "Chicago", - "color": [2], + "color": [ + 2 + ], "startingNumCubes": 2, "onRoll": [ { "group": 1, "onRoll": 1, - "goods": [4, 0, 4] + "goods": [ + 4, + 0, + 4 + ] } ], - "goods": [1, 4], + "goods": [ + 1, + 4 + ], "type": 1 } ], @@ -1810,16 +1934,25 @@ }, { "name": "Evansville", - "color": [0], + "color": [ + 0 + ], "startingNumCubes": 2, "onRoll": [ { "group": 2, "onRoll": 1, - "goods": [2, 3, 4] + "goods": [ + 2, + 3, + 4 + ] } ], - "goods": [4, 4], + "goods": [ + 4, + 4 + ], "type": 1 } ], @@ -1872,7 +2005,7 @@ "gridVersion": 2, "players": [ { - "playerId": 2, + "playerId": "2", "color": 7, "income": 0, "shares": 2, @@ -1880,7 +2013,7 @@ "locomotive": 1 }, { - "playerId": 3, + "playerId": "3", "color": 1, "income": 0, "shares": 2, @@ -1888,7 +2021,7 @@ "locomotive": 1 }, { - "playerId": 1, + "playerId": "1", "color": 2, "income": 0, "shares": 2, @@ -1896,13 +2029,20 @@ "locomotive": 1 } ], - "turnOrder": [1, 7, 2], + "turnOrder": [ + 1, + 7, + 2 + ], "availableCities": [ { "color": 2, "onRoll": [ { - "goods": [1, 3], + "goods": [ + 1, + 3 + ], "group": 1, "onRoll": 3 } @@ -1913,7 +2053,10 @@ "color": 0, "onRoll": [ { - "goods": [3, 3], + "goods": [ + 3, + 3 + ], "group": 1, "onRoll": 4 } @@ -1924,7 +2067,10 @@ "color": 1, "onRoll": [ { - "goods": [2, 3], + "goods": [ + 2, + 3 + ], "group": 1, "onRoll": 5 } @@ -1935,7 +2081,10 @@ "color": 1, "onRoll": [ { - "goods": [2, 3], + "goods": [ + 2, + 3 + ], "group": 1, "onRoll": 6 } @@ -1946,7 +2095,10 @@ "color": 4, "onRoll": [ { - "goods": [3, 0], + "goods": [ + 3, + 0 + ], "group": 2, "onRoll": 1 } @@ -1957,7 +2109,10 @@ "color": 3, "onRoll": [ { - "goods": [4, 2], + "goods": [ + 4, + 2 + ], "group": 2, "onRoll": 2 } @@ -1968,7 +2123,10 @@ "color": 1, "onRoll": [ { - "goods": [1, 4], + "goods": [ + 1, + 4 + ], "group": 2, "onRoll": 3 } @@ -1979,7 +2137,10 @@ "color": 1, "onRoll": [ { - "goods": [2, 4], + "goods": [ + 2, + 4 + ], "group": 2, "onRoll": 4 } @@ -1992,5 +2153,7 @@ "currentPhase": 1, "currentPlayer": 1 } - } + }, + "hotseat": false, + "ownerId": 1 } diff --git a/src/e2e/hotseat_game_test.ts b/src/e2e/hotseat_game_test.ts new file mode 100644 index 00000000..2ab1adfa --- /dev/null +++ b/src/e2e/hotseat_game_test.ts @@ -0,0 +1,108 @@ +import { By } from "selenium-webdriver"; +import { GameDao } from "../server/game/dao"; +import { UserDao } from "../server/user/dao"; +import { log } from "../utils/functions"; +import { assert } from "../utils/validate"; +import { initializeUsers } from "./util/game_data"; +import { Driver } from "./util/webdriver"; + +export function hotseatGame(driver: Driver) { + let users: UserDao[]; + let game: GameDao | undefined | null; + const shouldCaptureScreenshots = + process.env.E2E_HOTSEAT_SCREENSHOTS === "true"; + const screenshotWidth = 1898; + const screenshotHeight = 1350; + + beforeEach(async function setUpUsers() { + users = await initializeUsers(); + if (shouldCaptureScreenshots) { + await driver.setViewportSize(screenshotWidth, screenshotHeight); + } + }); + + afterEach(async function cleanUpGameData() { + log("start game data clean up"); + if (game != null) { + await GameDao.destroy({ where: { id: game.id }, force: true }); + } + log("end game data clean up"); + }); + + it("creates and starts a hotseat game", async () => { + const owner = users[0]; + game = await createHotseatGame(owner); + + await startGame(game, owner); + await waitForGameActive(game); + }); + + async function createHotseatGame(owner: UserDao): Promise { + await driver.goTo("/app/games/create", owner.id); + await driver.waitForElement(By.name("name")).sendKeys("Hotseat Game"); + await driver.waitForElement(By.xpath("//*[@data-hotseat-toggle]")).click(); + + // Default hotseat players are "Alice" and "Bob", add one more + await driver.waitForElement(By.xpath("//*[@data-hotseat-add-player]")).click(); + + if (shouldCaptureScreenshots) { + await driver.saveScreenshot( + "src/e2e/artifacts/screenshots/hotseat-create-page.png", + ); + } + + await driver.waitForElement(By.xpath("//*[@data-create-button]")).click(); + await driver.waitForElement(By.xpath("//*[@data-game-card]")); + + if (shouldCaptureScreenshots) { + await driver.saveScreenshot( + "src/e2e/artifacts/screenshots/hotseat-lobby-card.png", + ); + } + + const createdGame = await GameDao.findByPk(await driver.getGameId()); + assert(createdGame != null); + + assert(createdGame.hotseat, "Expected hotseat game to be created"); + assert( + createdGame.ownerId === owner.id, + "Expected ownerId to be set to the creating user", + ); + // Verify playerIds match the default names + assert( + createdGame.playerIds.length === 3, + `Expected 3 players, got ${createdGame.playerIds.length}`, + ); + + return createdGame; + } + + async function startGame(game: GameDao, owner: UserDao): Promise { + await driver.goToGame(game.id, owner.id); + + const startButton = await driver.waitForElement( + By.xpath("//*[@data-start-button]"), + ); + await startButton.click(); + await driver.waitForSuccess(); + } + + async function waitForGameActive(game: GameDao): Promise { + for (let i = 0; i < 20; i++) { + await game.reload(); + if (game.status === "ACTIVE") { + if (shouldCaptureScreenshots) { + await driver.goToGame(game.id, users[0].id); + // Wait for the game board to load by checking for game container or waiting for network to settle + await driver.waitForElement(By.xpath("//*[contains(@class, 'game')]"), { timeout: 5000 }); + await new Promise((resolve) => setTimeout(resolve, 1000)); // Extra wait for rendering + await driver.saveScreenshot( + "src/e2e/artifacts/screenshots/hotseat-active-game.png", + ); + } + return; + } + await new Promise((resolve) => setTimeout(resolve, 200)); + } + } +} diff --git a/src/e2e/util/game_data.ts b/src/e2e/util/game_data.ts index 7f976567..b4de18e5 100644 --- a/src/e2e/util/game_data.ts +++ b/src/e2e/util/game_data.ts @@ -33,7 +33,7 @@ export function setUpGameEnvironment( ] as PlayerColor; for (const player of playerData) { const user = users.pop()!; - player.playerId = user.id; + player.playerId = String(user.id); players.set(player.color, user); if (player.color === currentPlayerColor) { gameEnvironment.activePlayer = user; @@ -117,8 +117,8 @@ async function initializeGame( status: GameStatus.enum.ACTIVE, turnDuration: 1000, concedingPlayers: [], - activePlayerId: currentPlayer.id, - playerIds: players.map((u) => u.id), + activePlayerId: String(currentPlayer.id), + playerIds: players.map((u) => String(u.id)), variant: variantConfig, config: { minPlayers: players.length, @@ -126,6 +126,7 @@ async function initializeGame( }, unlisted: false, autoStart: false, + hotseat: false, }); } diff --git a/src/e2e/util/webdriver.ts b/src/e2e/util/webdriver.ts index 87132851..f462befd 100644 --- a/src/e2e/util/webdriver.ts +++ b/src/e2e/util/webdriver.ts @@ -1,3 +1,5 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import path from "node:path"; import { Browser, Builder, @@ -101,24 +103,54 @@ export class Driver { tileType: TileType, orientation: Direction, ) { - await this.findElementByDataAttributes({ + 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 to bypass SVG overlay + 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, + ); + + // 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 }); - await this.findElementByDataAttributes({ + 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")); + 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(); } @@ -128,6 +160,21 @@ export class Driver { await element.findElement(By.css("button")).click(); } + async setViewportSize(width: number, height: number): Promise { + // Browser chrome overhead in headless Chrome: ~22px width, ~150px height + const windowWidth = width + 22; + const windowHeight = height + 150; + await this.driver.manage().window().setRect({ width: windowWidth, height: windowHeight }); + } + + async saveScreenshot(relativePath: string): Promise { + const imageBase64 = await this.driver.takeScreenshot(); + const absolutePath = path.resolve(process.cwd(), relativePath); + await mkdir(path.dirname(absolutePath), { recursive: true }); + await writeFile(absolutePath, imageBase64, { encoding: "base64" }); + return absolutePath; + } + async getGameId(): Promise { const path = await this.getPath(); const matches = path.match(/\/app\/games\/(\d*)$/); diff --git a/src/engine/framework/engine.ts b/src/engine/framework/engine.ts index cc24f6ad..8d64611a 100644 --- a/src/engine/framework/engine.ts +++ b/src/engine/framework/engine.ts @@ -22,7 +22,7 @@ import { InjectionContext } from "./inject"; import { StateStore } from "./state"; interface GameState { - activePlayerId?: number; + activePlayerId?: string | number; hasEnded: boolean; gameData: string; reversible: boolean; @@ -63,11 +63,11 @@ export class EngineDelegator { return this.getEngine(game.gameKey).readSummary(game); } - inTheLead(game: LimitedGame): number[] { + inTheLead(game: LimitedGame): (string | number)[] { return this.getEngine(game.gameKey).inTheLead(game); } - remainingPlayers(game: LimitedGame): number[] { + remainingPlayers(game: LimitedGame): (string | number)[] { return this.getEngine(game.gameKey).remainingPlayers(game); } } @@ -143,7 +143,7 @@ export class EngineProcessor { }); } - inTheLead(game: LimitedGame): number[] { + inTheLead(game: LimitedGame): (number | string)[] { return this.process(game, () => { return this.playerHelper .getPlayersOrderedByScore()[0] @@ -151,7 +151,7 @@ export class EngineProcessor { }); } - remainingPlayers(game: LimitedGame): number[] { + remainingPlayers(game: LimitedGame): (number | string)[] { return this.process(game, () => { return this.playerHelper .getRemainingPlayers() diff --git a/src/engine/game/auto_action_manager.ts b/src/engine/game/auto_action_manager.ts index 7a824a1e..f04e6952 100644 --- a/src/engine/game/auto_action_manager.ts +++ b/src/engine/game/auto_action_manager.ts @@ -6,7 +6,7 @@ import { injectCurrentPlayer } from "./state"; type AutoActionMutation = (autoAction: AutoAction) => void; export interface AutoActionMutationConfig { - playerId: number; + playerId: number | string; mutation: AutoActionMutation; } @@ -24,7 +24,7 @@ export class AutoActionManager { return [...this.newAutoAction]; } - mutate(playerId: number, mutation: AutoActionMutation) { + mutate(playerId: number | string, mutation: AutoActionMutation) { this.newAutoAction.push({ playerId, mutation }); } } diff --git a/src/engine/game/log.ts b/src/engine/game/log.ts index e42c83e9..3da5e358 100644 --- a/src/engine/game/log.ts +++ b/src/engine/game/log.ts @@ -12,8 +12,12 @@ export class Log { } player(player: PlayerData, entry: string): void { + const playerLabel = + typeof player.playerId === "string" + ? player.playerId + : `<@user-${player.playerId}>`; this.log( - `<@user-${player.playerId}> (${playerColorToString(player.color)}) ${entry}`, + `${playerLabel} (${playerColorToString(player.color)}) ${entry}`, ); } diff --git a/src/engine/game/starter.ts b/src/engine/game/starter.ts index 18aed425..015a5912 100644 --- a/src/engine/game/starter.ts +++ b/src/engine/game/starter.ts @@ -23,7 +23,7 @@ import { } from "./state"; export interface PlayerUser { - playerId: number; + playerId: number | string; preferredColors?: PlayerColor[]; } @@ -190,7 +190,7 @@ export class GameStarter { return true; } - protected buildPlayer(playerId: number, color: PlayerColor): PlayerData { + protected buildPlayer(playerId: number | string, color: PlayerColor): PlayerData { return { playerId, color, diff --git a/src/engine/state/player.ts b/src/engine/state/player.ts index 006dba20..f8660ee1 100644 --- a/src/engine/state/player.ts +++ b/src/engine/state/player.ts @@ -22,7 +22,7 @@ export function stringToPlayerColor(str: string): PlayerColor { } export const MutablePlayerData = z.object({ - playerId: z.number(), + playerId: z.union([z.number(), z.string()]), color: z.nativeEnum(PlayerColor), income: z.number(), shares: z.number(), diff --git a/src/migrations/20260226000001_add_hotseat_support.ts b/src/migrations/20260226000001_add_hotseat_support.ts new file mode 100644 index 00000000..1992cb83 --- /dev/null +++ b/src/migrations/20260226000001_add_hotseat_support.ts @@ -0,0 +1,106 @@ +import { DataTypes } from "@sequelize/core"; +import type { Migration } from "../scripts/migrations"; + +export const up: Migration = async ({ context: queryInterface }) => { + // First, add the hotseat column with default value + await queryInterface.addColumn("Games", "hotseat", { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: false, + }); + + // Convert array columns via temp columns to avoid ALTER TYPE casting issues + await queryInterface.addColumn("Games", "playerIdsTmp", { + type: DataTypes.ARRAY(DataTypes.TEXT), + allowNull: true, + }); + + await queryInterface.sequelize.query(` + UPDATE "Games" + SET "playerIdsTmp" = ARRAY(SELECT CAST(unnest("playerIds") AS TEXT)) + WHERE "playerIds" IS NOT NULL + `); + + await queryInterface.removeColumn("Games", "playerIds"); + await queryInterface.renameColumn("Games", "playerIdsTmp", "playerIds"); + await queryInterface.changeColumn("Games", "playerIds", { + type: DataTypes.ARRAY(DataTypes.TEXT), + allowNull: false, + }); + + await queryInterface.addColumn("Games", "concedingPlayersTmp", { + type: DataTypes.ARRAY(DataTypes.TEXT), + allowNull: true, + }); + + await queryInterface.sequelize.query(` + UPDATE "Games" + SET "concedingPlayersTmp" = ARRAY(SELECT CAST(unnest("concedingPlayers") AS TEXT)) + WHERE "concedingPlayers" IS NOT NULL + `); + + await queryInterface.removeColumn("Games", "concedingPlayers"); + await queryInterface.renameColumn( + "Games", + "concedingPlayersTmp", + "concedingPlayers", + ); + await queryInterface.changeColumn("Games", "concedingPlayers", { + type: DataTypes.ARRAY(DataTypes.TEXT), + allowNull: false, + }); + + // Convert single value columns + await queryInterface.sequelize.query(` + ALTER TABLE "Games" + ALTER COLUMN "activePlayerId" TYPE TEXT + USING "activePlayerId"::TEXT + `); + + await queryInterface.sequelize.query(` + ALTER TABLE "Games" + ALTER COLUMN "undoPlayerId" TYPE TEXT + USING "undoPlayerId"::TEXT + `); + + await queryInterface.sequelize.query(` + ALTER TABLE "GameHistories" + ALTER COLUMN "userId" TYPE TEXT + USING "userId"::TEXT + `); +}; + +export const down: Migration = async ({ context: queryInterface }) => { + // Revert GameHistories userId to INTEGER + await queryInterface.changeColumn("GameHistories", "userId", { + type: DataTypes.INTEGER, + allowNull: true, + }); + + // Revert hotseat column + await queryInterface.removeColumn("Games", "hotseat"); + + // Revert concedingPlayers to INTEGER[] + await queryInterface.changeColumn("Games", "concedingPlayers", { + type: DataTypes.ARRAY(DataTypes.INTEGER), + allowNull: false, + }); + + // Revert undoPlayerId to INTEGER + await queryInterface.changeColumn("Games", "undoPlayerId", { + type: DataTypes.INTEGER, + allowNull: true, + }); + + // Revert activePlayerId to INTEGER + await queryInterface.changeColumn("Games", "activePlayerId", { + type: DataTypes.INTEGER, + allowNull: true, + }); + + // Revert playerIds to INTEGER[] + await queryInterface.changeColumn("Games", "playerIds", { + type: DataTypes.ARRAY(DataTypes.INTEGER), + allowNull: false, + }); +}; diff --git a/src/migrations/20260226000002_add_game_owner.ts b/src/migrations/20260226000002_add_game_owner.ts new file mode 100644 index 00000000..09065c70 --- /dev/null +++ b/src/migrations/20260226000002_add_game_owner.ts @@ -0,0 +1,22 @@ +import { DataTypes } from "@sequelize/core"; +import type { Migration } from "../scripts/migrations"; + +export const up: Migration = async ({ context: queryInterface }) => { + await queryInterface.addColumn("Games", "ownerId", { + type: DataTypes.INTEGER, + allowNull: true, + }); + + await queryInterface.sequelize.query(` + UPDATE "Games" + SET "ownerId" = CASE + WHEN "playerIds"[1] ~ '^\\d+$' THEN ("playerIds"[1])::int + ELSE NULL + END + WHERE "ownerId" IS NULL + `); +}; + +export const down: Migration = async ({ context: queryInterface }) => { + await queryInterface.removeColumn("Games", "ownerId"); +}; diff --git a/src/migrations/20260226000003_add_hotseat_indexes.ts b/src/migrations/20260226000003_add_hotseat_indexes.ts new file mode 100644 index 00000000..0becffc0 --- /dev/null +++ b/src/migrations/20260226000003_add_hotseat_indexes.ts @@ -0,0 +1,21 @@ +import type { Migration } from "../scripts/migrations"; + +export const up: Migration = async ({ context: queryInterface }) => { + await queryInterface.addIndex("Games", ["ownerId"], { + name: "Games_ownerId_idx", + }); + await queryInterface.addIndex("Games", ["hotseat", "status"], { + name: "Games_hotseat_status_idx", + }); + await queryInterface.addIndex("Games", { + fields: ["playerIds"], + using: "gin", + name: "Games_playerIds_gin_idx", + }); +}; + +export const down: Migration = async ({ context: queryInterface }) => { + await queryInterface.removeIndex("Games", "Games_playerIds_gin_idx"); + await queryInterface.removeIndex("Games", "Games_hotseat_status_idx"); + await queryInterface.removeIndex("Games", "Games_ownerId_idx"); +}; diff --git a/src/server/game/dao.ts b/src/server/game/dao.ts index 1bf8a9f7..0c300b7b 100644 --- a/src/server/game/dao.ts +++ b/src/server/game/dao.ts @@ -54,11 +54,14 @@ export class GameDao extends Model< declare status: GameStatus; @Attribute(DataTypes.JSONB) - declare autoAction: { users: { [userId: number]: AutoAction } } | null; + declare autoAction: { users: { [playerId: number | string]: AutoAction } } | null; - @Attribute(DataTypes.ARRAY(DataTypes.INTEGER)) + @Attribute(DataTypes.ARRAY(DataTypes.TEXT)) @NotNull - declare playerIds: number[]; + declare playerIds: (number | string)[]; + + @Attribute({ type: DataTypes.INTEGER, allowNull: true }) + declare ownerId: number | null; @Attribute(DataTypes.BOOLEAN) @NotNull @@ -75,9 +78,9 @@ export class GameDao extends Model< @Attribute({ type: DataTypes.DATE, allowNull: true }) declare turnStartTime?: CreationOptional; - @Attribute(DataTypes.ARRAY(DataTypes.INTEGER)) + @Attribute(DataTypes.ARRAY(DataTypes.TEXT)) @NotNull - declare concedingPlayers: number[]; + declare concedingPlayers: (number | string)[]; @Attribute(DataTypes.JSONB) declare config: MapConfig; @@ -89,11 +92,15 @@ export class GameDao extends Model< @Attribute(DataTypes.ARRAY(DataTypes.TEXT)) declare notes: Array | null; - @Attribute({ type: DataTypes.INTEGER, allowNull: true }) - declare activePlayerId: number | null; + @Attribute({ type: DataTypes.TEXT, allowNull: true }) + declare activePlayerId: number | string | null; - @Attribute({ type: DataTypes.INTEGER, allowNull: true }) - declare undoPlayerId: number | null; + @Attribute({ type: DataTypes.TEXT, allowNull: true }) + declare undoPlayerId: number | string | null; + + @Attribute(DataTypes.BOOLEAN) + @NotNull + declare hotseat: boolean; @Version @NotNull @@ -126,27 +133,27 @@ export class GameDao extends Model< return toSummary(this); } - getAutoActionForUser(userId: number): AutoAction { - return this.autoAction?.users?.[userId] ?? {}; + getAutoActionForUser(playerId: number | string): AutoAction { + return this.autoAction?.users?.[playerId] ?? {}; } - setAutoActionForUser(userId: number, autoAction: AutoAction): void { + setAutoActionForUser(playerId: number | string, autoAction: AutoAction): void { this.autoAction = this.autoAction ?? { users: {} }; - this.autoAction.users[userId] = autoAction; + this.autoAction.users[playerId] = autoAction; this.changed("autoAction", true); } - getNotesForUser(userId: number): string { - const index = this.playerIds.indexOf(userId); + getNotesForUser(playerId: number | string): string { + const index = this.playerIds.indexOf(playerId); if (index === -1) { return ""; } return this.notes?.[index] ?? ""; } - setNotesForUser(userId: number, notes: string) { - const index = this.playerIds.indexOf(userId); - assert(index >= 0, { unauthorized: "only players can set notes" }); + setNotesForUser(playerId: number | string, notes: string) { + const index = this.playerIds.indexOf(playerId); + assert(index >= 0, { unauthorized: "only players can set notes for themselves" }); this.notes ??= []; this.notes[index] = notes; this.changed("notes", true); @@ -161,6 +168,7 @@ export function toApi(game: InferAttributes | GameApi): GameApi { turnStartTime: game.turnStartTime?.toString() ?? undefined, gameData: game.gameData ?? undefined, undoPlayerId: game.undoPlayerId ?? undefined, + hotseat: game.hotseat ?? false, }; } @@ -177,6 +185,9 @@ function toLiteApi(game: GameApi | InferAttributes): GameLiteApi { config: game.config, summary: toSummary(game), unlisted: game.unlisted, + hotseat: ("hotseat" in game && game.hotseat) || false, + ownerId: + "ownerId" in game ? (game.ownerId ?? undefined) : undefined, }; } diff --git a/src/server/game/history_dao.ts b/src/server/game/history_dao.ts index 9ab81785..bf957437 100644 --- a/src/server/game/history_dao.ts +++ b/src/server/game/history_dao.ts @@ -69,8 +69,8 @@ export class GameHistoryDao extends Model< @BelongsTo(() => GameDao, "gameId") declare game: NonAttribute; - @Attribute(DataTypes.INTEGER) - declare userId: number | null; + @Attribute(DataTypes.TEXT) + declare userId: number | string | null; @BelongsTo(() => UserDao, "userId") declare user: NonAttribute; @@ -118,6 +118,7 @@ export class GameHistoryDao extends Model< variant: game.variant, unlisted: game.unlisted, undoPlayerId: undefined, + hotseat: game.hotseat ?? false, }; historyApi.summary = toSummary(historyApi); return historyApi; diff --git a/src/server/game/logic.ts b/src/server/game/logic.ts index f573185c..8b7bc141 100644 --- a/src/server/game/logic.ts +++ b/src/server/game/logic.ts @@ -6,6 +6,7 @@ import { AutoAction, NoAutoActionError, } from "../../engine/state/auto_action"; +import { PlayerColor } from "../../engine/state/player"; import { ErrorCode } from "../../utils/error_code"; import { log, logError } from "../../utils/functions"; import { afterTransaction } from "../../utils/transaction"; @@ -29,24 +30,40 @@ export async function startGame( assert(game.status === GameStatus.enum.LOBBY, { invalidInput: "cannot start a game that has already been started", }); - assert(enforceOwner == null || game.playerIds[0] === enforceOwner, { - invalidInput: "only the owner can start the game", - }); + const ownerId = game.ownerId; + assert( + enforceOwner == null || ownerId == null || ownerId === enforceOwner, + { + invalidInput: "only the owner can start the game", + }, + ); assert( game.playerIds.length >= game.toLiteApi().config.minPlayers, "not enough players to start the game", ); - const users = await Promise.all( - game.playerIds.map((id) => UserDao.getUser(id)), - ); + let players: Array<{ playerId: number | string; preferredColors?: PlayerColor[] }>; + + if (game.hotseat) { + // For hotseat games, use string player IDs directly without fetching users + players = game.playerIds.map((playerId) => ({ + playerId, + preferredColors: undefined, + })); + } else { + // For regular games, fetch user data but convert playerIds to strings + const users = await Promise.all( + game.playerIds.map((id) => UserDao.getUser(Number(id))), + ); + players = users.map((user) => ({ + playerId: String(user!.id), + preferredColors: user!.preferredColors, + })); + } const { gameData, logs, activePlayerId, seed } = EngineDelegator.singleton.start({ - players: users.map((user) => ({ - playerId: user!.id, - preferredColors: user!.preferredColors, - })), + players, seed: inputSeed, game: game.toLimitedGame(), }); @@ -54,7 +71,7 @@ export async function startGame( game.turnStartTime = new Date(); game.gameData = gameData; game.status = GameStatus.enum.ACTIVE; - game.activePlayerId = activePlayerId ?? null; + game.activePlayerId = activePlayerId != null ? String(activePlayerId) : null; const gameHistory = GameHistoryDao.build({ previousGameVersion: game.version - 1, @@ -82,7 +99,7 @@ export async function startGame( export async function performAction( gameId: number, - playerId: number, + playerId: number | string, actionName: string, actionData: unknown, confirmed: boolean, @@ -139,9 +156,9 @@ export async function performAction( } game.version = game.version + 1; game.gameData = gameData; - game.activePlayerId = activePlayerId ?? null; + game.activePlayerId = activePlayerId != null ? String(activePlayerId) : null; game.status = hasEnded ? GameStatus.enum.ENDED : GameStatus.enum.ACTIVE; - game.undoPlayerId = reversible ? playerId : null; + game.undoPlayerId = reversible ? String(playerId) : null; for (const mutation of autoActionMutations) { const autoAction = game.getAutoActionForUser(mutation.playerId); @@ -181,6 +198,9 @@ export async function performAction( } async function notifyTurnUnlessAutoAction(game: GameDao): Promise { + // Skip notifications for hotseat games (single device play, no remote notifications needed) + if (game.hotseat) return; + const hasAutoAction = await checkForAutoAction(game.id, /* dryRun= */ true); if (!hasAutoAction) { return notifyTurn(game); @@ -189,35 +209,55 @@ async function notifyTurnUnlessAutoAction(game: GameDao): Promise { export async function abandonGame( game: GameDao, - userId: number, + userId: number | string, kicked: boolean, ): Promise { - assert(game.playerIds.includes(userId), { permissionDenied: true }); + assert(game.playerIds.includes(String(userId)), { permissionDenied: true }); assert(game.status === GameStatus.enum.ACTIVE, { invalidInput: "Can only abandon an active game", }); game.status = GameStatus.enum.ABANDONED; game.activePlayerId = null; game.undoPlayerId = null; - const user = await UserDao.findByPk(userId); - assert(user != null); - user.abandons++; - await sequelize.transaction(async (transaction) => { - await Promise.all([ - game.save({ transaction }), - user.save({ transaction }), - LogDao.createForGame( - game.id, - game.version, - [ - kicked - ? `<@user-${userId}> ran out of time and was kicked from the game.` - : `<@user-${userId}> abandoned the game.`, - ], - transaction, - ), - ]); - }); + // Only update user stats for actual user accounts (numeric IDs) + if (typeof userId === "number") { + const user = await UserDao.findByPk(userId); + assert(user != null); + user.abandons++; + await sequelize.transaction(async (transaction) => { + await Promise.all([ + game.save({ transaction }), + user.save({ transaction }), + LogDao.createForGame( + game.id, + game.version, + [ + kicked + ? `<@user-${userId}> ran out of time and was kicked from the game.` + : `<@user-${userId}> abandoned the game.`, + ], + transaction, + ), + ]); + }); + } else { + // For hotseat players (string IDs), just save game state and log + await sequelize.transaction(async (transaction) => { + await Promise.all([ + game.save({ transaction }), + LogDao.createForGame( + game.id, + game.version, + [ + kicked + ? `${userId} ran out of time and was kicked from the game.` + : `${userId} abandoned the game.`, + ], + transaction, + ), + ]); + }); + } } async function checkForAutoAction( @@ -251,11 +291,11 @@ async function checkForAutoAction( } } -export function inTheLead(game: GameDao): number[] { +export function inTheLead(game: GameDao): (string | number)[] { return EngineDelegator.singleton.inTheLead(game.toLimitedGame()); } -export function remainingPlayers(game: GameDao): number[] { +export function remainingPlayers(game: GameDao): (string | number)[] { return EngineDelegator.singleton.remainingPlayers(game.toLimitedGame()); } diff --git a/src/server/game/routes.ts b/src/server/game/routes.ts index 77b4e2cd..a1feeba6 100644 --- a/src/server/game/routes.ts +++ b/src/server/game/routes.ts @@ -42,47 +42,56 @@ const router = initServer().router(gameContract, { } = { ...defaultQuery, ...query }; const pageCursor = parsePageCursor(pageCursorString); - let where: WhereOptions = rest; + const baseWhere: WhereOptions = rest; + const whereClauses: WhereOptions[] = [baseWhere]; if (status != null) { if (status.length === 1) { - where.status = status[0]; + baseWhere.status = status[0]; } else if (status.length > 1) { - where.status = { [Op.in]: status }; + baseWhere.status = { [Op.in]: status }; } } if (userId != null) { - where.playerIds = { [Op.contains]: [userId] }; + whereClauses.push({ + [Op.or]: [ + { playerIds: { [Op.contains]: [String(userId)] } }, + { ownerId: userId }, + ], + }); } if (excludeUserId != null) { - where.playerIds = { - ...where.playerIds, - [Op.not]: { - [Op.contains]: [excludeUserId], + whereClauses.push({ + playerIds: { + [Op.not]: { + [Op.contains]: [String(excludeUserId)], + }, }, - }; + }); + whereClauses.push({ ownerId: { [Op.ne]: excludeUserId } }); } if (pageCursor != null) { - where.id = { [Op.notIn]: pageCursor }; + baseWhere.id = { [Op.notIn]: pageCursor }; } // Add a condition so that only games which are not marked as unlisted or which the user is a part of will be included if (req.session.userId) { - where = { - [Op.and]: [ - { - [Op.or]: [ - { playerIds: { [Op.contains]: [req.session.userId] } }, - { unlisted: false }, - ], - }, - where, + whereClauses.push({ + [Op.or]: [ + { playerIds: { [Op.contains]: [String(req.session.userId)] } }, + { ownerId: req.session.userId }, + { unlisted: false }, ], - }; + }); } else { - where.unlisted = false; + baseWhere.unlisted = false; } + const where: WhereOptions = + whereClauses.length === 1 + ? whereClauses[0] + : ({ [Op.and]: whereClauses } as WhereOptions); + const games = await GameDao.findAll({ attributes: [ "id", @@ -97,6 +106,8 @@ const router = initServer().router(gameContract, { "turnDuration", "unlisted", "autoStart", + "hotseat", + "ownerId", ], where, limit: pageSize! + 1, @@ -143,15 +154,32 @@ const router = initServer().router(gameContract, { ); assert(map.stage !== ReleaseStage.DEVELOPMENT || body.unlisted, { invalidInput: "Development map games must be unlisted." }); - const playerIds = [userId]; - if (body.artificialStart) { - assert(stage() === Stage.enum.development); - const users = await UserDao.findAll({ - where: { id: { [Op.ne]: userId }, role: UserRole.enum.USER }, - limit: body.minPlayers - 1, + let playerIds: (number | string)[] = [String(userId)]; + let unlisted = body.unlisted; + let hotseat = false; + + if (body.hotseat) { + // Hotseat games always use string player names and are always unlisted + // Validation already handled by CreateGameApi schema + playerIds = body.hotseatPlayers!; + unlisted = true; + hotseat = true; + // Artificial start is not compatible with hotseat + assert(!body.artificialStart, { + invalidInput: "Artificial start is not supported for hotseat games", }); - playerIds.push(...users.map(({ id }) => id)); + } else { + // Traditional account-based game + if (body.artificialStart) { + assert(stage() === Stage.enum.development); + const users = await UserDao.findAll({ + where: { id: { [Op.ne]: userId }, role: UserRole.enum.USER }, + limit: body.minPlayers - 1, + }); + playerIds.push(...users.map(({ id }) => String(id))); + } } + const game = await GameDao.create({ version: 1, gameKey: body.gameKey, @@ -160,13 +188,15 @@ const router = initServer().router(gameContract, { turnDuration: body.turnDuration, concedingPlayers: [], playerIds, + ownerId: userId, variant: body.variant, config: { minPlayers: body.minPlayers, maxPlayers: body.maxPlayers, }, - unlisted: body.unlisted, + unlisted, autoStart: body.autoStart, + hotseat, }); return { status: 201, body: { game: game.toApi() } }; }, @@ -185,7 +215,11 @@ const router = initServer().router(gameContract, { invalidInput: "cannot delete started game unless it's a solo", }, ); - assert(game.playerIds[0] === user.id, { permissionDenied: true }); + // For hotseat games, ownerId must be set; for regular games, fall back to first player + const ownerId = game.hotseat + ? game.ownerId + : game.ownerId ?? (Number.isFinite(Number(game.playerIds[0])) ? Number(game.playerIds[0]) : null); + assert(ownerId != null && ownerId === user.id, { permissionDenied: true }); } await sequelize.transaction(() => @@ -206,12 +240,12 @@ const router = initServer().router(gameContract, { const game = await GameDao.findByPk(params.gameId); assert(game != null); assert(game.status === GameStatus.enum.LOBBY, "cannot join started game"); - assert(!game.playerIds.includes(userId), { invalidInput: true }); + assert(!game.playerIds.includes(String(userId)), { invalidInput: true }); assert(game.playerIds.length < game.toLiteApi().config.maxPlayers, { invalidInput: "game full", }); - game.playerIds = [...game.playerIds, userId]; + game.playerIds = [...game.playerIds, String(userId)]; const newGame = await game.save(); return { status: 200, body: { game: newGame.toApi() } }; }, @@ -223,10 +257,12 @@ const router = initServer().router(gameContract, { const game = await GameDao.findByPk(params.gameId); assert(game != null); assert(game.status === GameStatus.enum.LOBBY, "cannot leave started game"); - const index = game.playerIds.indexOf(userId); + const index = game.playerIds.indexOf(String(userId)); assert(index >= 0, { invalidInput: "cannot leave game you are not in" }); // Figure out what to do if the owner wants to leave - assert(index > 0, { invalidInput: "the owner cannot leave the game" }); + assert(game.ownerId !== userId, { + invalidInput: "the owner cannot leave the game", + }); game.playerIds = game.playerIds .slice(0, index) @@ -241,7 +277,7 @@ const router = initServer().router(gameContract, { const seed = stage() !== Stage.enum.production ? req.body.seed : undefined; - const game = await startGame(params.gameId, userId, seed); + const game = await startGame(params.gameId, Number(userId), seed); return { status: 200, body: { game } }; }, @@ -255,11 +291,31 @@ const router = initServer().router(gameContract, { }, async performAction({ req, params, body }) { - const userId = req.session.userId; - assert(userId != null, { permissionDenied: true }); + // First fetch the game to check if it's hotseat + const gamePreFetch = await GameDao.findByPk(params.gameId); + assert(gamePreFetch != null, { notFound: true }); + + let playerId: number | string; + if (gamePreFetch.hotseat) { + // For hotseat games, use performingPlayerId from body + assert(body.performingPlayerId != null, { + invalidInput: "Hotseat games require performingPlayerId", + }); + // SECURITY: Only the game owner can perform actions in hotseat mode + assert(gamePreFetch.ownerId === req.session.userId, { + permissionDenied: "Only the game owner can perform actions in hotseat mode", + }); + playerId = body.performingPlayerId; + } else { + // For regular games, require session userId + const userId = req.session.userId; + assert(userId != null, { permissionDenied: true }); + playerId = String(userId); + } + const game = await performAction( params.gameId, - userId, + playerId, body.actionName, body.actionData, body.confirmed, @@ -267,7 +323,7 @@ const router = initServer().router(gameContract, { return { status: 200, - body: { game: game.toApi(), auto: game.getAutoActionForUser(userId) }, + body: { game: game.toApi(), auto: game.getAutoActionForUser(playerId) }, }; }, @@ -299,9 +355,15 @@ const router = initServer().router(gameContract, { assert(gameHistory.reversible, { invalidInput: "cannot undo irreversible action", }); - assert(gameHistory.userId === req.session.userId, { - permissionDenied: true, - }); + if (game.hotseat) { + assert(game.ownerId === req.session.userId, { + permissionDenied: true, + }); + } else { + assert(gameHistory.userId === String(req.session.userId), { + permissionDenied: true, + }); + } } game.version = backToVersion; @@ -329,27 +391,35 @@ const router = initServer().router(gameContract, { async retryLast({ req, body, params }) { await assertRole(req, UserRole.enum.ADMIN); + const game = await GameDao.findByPk(params.gameId); + assert(game != null, { notFound: true }); + // Hotseat games use string player IDs without user accounts, so retrying would require + // fetching user data which is not applicable. Retries are only supported for account-based games. + assert(!game.hotseat, { + invalidInput: "Cannot retry hotseat games", + }); + const limit = body.steps; const previousActions = await GameHistoryDao.findAll({ where: { gameId: params.gameId }, limit, order: [["id", "DESC"]], }); - const game = await GameDao.findByPk(params.gameId); - assert(game != null, { notFound: true }); + assert(previousActions.length == body.steps, { invalidInput: "There are not that many steps to retry", }); + // Safe to cast to number[] since we checked !game.hotseat above const users = await Promise.all( - game.playerIds.map((id) => UserDao.getUser(id)), + game.playerIds.map((id) => UserDao.getUser(Number(id))), ); let previousAction: GameHistoryDao | undefined; let currentGameData: string; let currentGameVersion: number; - let finalActivePlayerId: number | undefined; - let finalUndoPlayerId: number | undefined; + let finalActivePlayerId: number | string | undefined; + let finalUndoPlayerId: number | string | undefined; const allLogs: LogDao[] = []; const firstAction = peek(previousActions); @@ -467,26 +537,26 @@ const router = initServer().router(gameContract, { assert(userId != null); const game = await GameDao.findByPk(params.gameId); assert(game != null, { notFound: true }); - assert(game.playerIds.includes(userId), { permissionDenied: true }); + assert(game.playerIds.includes(String(userId)), { permissionDenied: true }); assert(game.status === GameStatus.enum.ACTIVE, { invalidInput: "Can only concede an active game", }); - const hasConceded = game.concedingPlayers.includes(userId); + const hasConceded = game.concedingPlayers.includes(String(userId)); if (body.concede) { if (!hasConceded) { - game.concedingPlayers = game.concedingPlayers.concat([userId]); + game.concedingPlayers = game.concedingPlayers.concat([String(userId)]); } } else { if (hasConceded) { - game.concedingPlayers = remove(game.concedingPlayers, userId); + game.concedingPlayers = remove(game.concedingPlayers, String(userId)); } } assert(game.concedingPlayers.length <= game.playerIds.length); const remaining = remainingPlayers(game).filter( - (playerId) => !game.concedingPlayers.includes(playerId), + (playerId) => !game.concedingPlayers.includes(String(playerId)), ); const noneRemaining = remaining.length === 0; - const leadPlayer = inTheLead(game); + const leadPlayer = inTheLead(game).map((playerId) => String(playerId)); const onlyLeadPlayerRemaining = remaining.length === 1 && leadPlayer.length === 1 && @@ -505,7 +575,7 @@ const router = initServer().router(gameContract, { assert(userId != null); const game = await GameDao.findByPk(params.gameId); assert(game != null, { notFound: true }); - assert(game.playerIds.includes(userId), { permissionDenied: true }); + assert(game.playerIds.includes(String(userId)), { permissionDenied: true }); await abandonGame(game, userId, /* kicked= */ false); return { status: 200, body: { game: game.toApi() } }; }, @@ -515,11 +585,13 @@ const router = initServer().router(gameContract, { assert(userId != null); const game = await GameDao.findByPk(params.gameId); assert(game != null, { notFound: true }); - assert(game.playerIds.includes(userId), { permissionDenied: true }); + assert(game.playerIds.includes(String(userId)), { permissionDenied: true }); assert(game.status === GameStatus.enum.ACTIVE, { invalidInput: "Can only kick an active game", }); - assert(game.activePlayerId != null); + assert(game.activePlayerId != null, { + invalidInput: "No active player to kick", + }); assert( game.turnStartTime != null && game.turnStartTime.getTime() + game.turnDuration < Date.now(),