From 18276633fefda5e41cd2ab96bdbb2ccdfe2ff4f1 Mon Sep 17 00:00:00 2001 From: Doug Moore Date: Fri, 20 Feb 2026 15:55:13 -0500 Subject: [PATCH 01/10] Add instructions for rebuilding Docker containers in README --- README.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/README.md b/README.md index 0c2c4912..4e314599 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,15 @@ docker-compose up --watch Visit http://localhost:3001 to see the application. +#### Rebuilding Containers + +When you need to rebuild the Docker containers (after pulling updates, merging branches, or changing dependencies): + +```bash +docker-compose down +docker-compose up --build --watch +``` + **Note:** - If you encounter port conflicts, check for existing services with `lsof -i :PORT_NUMBER` From d206887472ef15297baf24ea6c1e904197a8285c Mon Sep 17 00:00:00 2001 From: Doug Moore Date: Thu, 26 Feb 2026 18:16:44 -0500 Subject: [PATCH 02/10] Add hotseat (local multiplayer) mode with visual badges - Add hotseat column to Games table with migration - Add ownerId column to track game creator - Convert playerIds from INTEGER[] to TEXT[] to support both user IDs and string names - Update game creation flow to support hotseat mode with custom player names - Update username components to handle string player names without links - Add hotseat badges to game cards, active game headers, and final overview - Update undo permissions to allow hotseat game owner to undo actions - Update game visibility filters to include owner-based filtering - Hotseat games are automatically unlisted and prevent regular join/leave operations --- .vscode/tasks.json | 74 ++++++++ docker-compose.yml | 6 +- src/api/game.ts | 23 ++- src/client/components/awaiting_player.tsx | 10 +- src/client/components/username.tsx | 36 +++- src/client/game/active_game.module.css | 19 ++ src/client/game/active_game.tsx | 11 +- src/client/game/create_page.tsx | 68 ++++++- src/client/game/final_overview.module.css | 20 ++ src/client/game/final_overview.tsx | 8 +- src/client/game/player_stats.tsx | 8 +- src/client/game/switch.tsx | 5 +- src/client/home/game_card.module.css | 16 ++ src/client/home/game_card.tsx | 9 +- src/client/services/action.tsx | 20 +- src/client/services/game.ts | 5 +- src/e2e/create_game_test.ts | 4 +- src/e2e/util/game_data.ts | 1 + src/engine/framework/engine.ts | 10 +- src/engine/game/auto_action_manager.ts | 4 +- src/engine/game/starter.ts | 4 +- src/engine/state/player.ts | 2 +- .../20260226000001_add_hotseat_support.ts | 106 +++++++++++ .../20260226000002_add_game_owner.ts | 22 +++ src/server/game/dao.ts | 46 +++-- src/server/game/history_dao.ts | 5 +- src/server/game/logic.ts | 105 +++++++---- src/server/game/routes.ts | 176 ++++++++++++------ 28 files changed, 666 insertions(+), 157 deletions(-) create mode 100644 .vscode/tasks.json create mode 100644 src/migrations/20260226000001_add_hotseat_support.ts create mode 100644 src/migrations/20260226000002_add_game_owner.ts diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 00000000..ca9ad03e --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,74 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "Docker: Start (watch)", + "type": "shell", + "command": "docker-compose up --watch", + "isBackground": true, + "problemMatcher": [], + "group": { + "kind": "build", + "isDefault": true + }, + "presentation": { + "reveal": "always", + "panel": "dedicated", + "focus": false + } + }, + { + "label": "Docker: Stop", + "type": "shell", + "command": "docker-compose down", + "problemMatcher": [], + "presentation": { + "reveal": "always", + "panel": "shared" + } + }, + { + "label": "Docker: Rebuild", + "type": "shell", + "command": "docker-compose build", + "problemMatcher": [], + "presentation": { + "reveal": "always", + "panel": "shared" + } + }, + { + "label": "Docker: Rebuild and Start (watch)", + "type": "shell", + "command": "docker-compose down && docker-compose up --build --watch", + "isBackground": true, + "problemMatcher": [], + "presentation": { + "reveal": "always", + "panel": "dedicated", + "focus": false + } + }, + { + "label": "Docker: View Logs", + "type": "shell", + "command": "docker-compose logs -f", + "isBackground": true, + "problemMatcher": [], + "presentation": { + "reveal": "always", + "panel": "dedicated" + } + }, + { + "label": "Docker: Restart Containers", + "type": "shell", + "command": "docker-compose restart", + "problemMatcher": [], + "presentation": { + "reveal": "always", + "panel": "shared" + } + } + ] +} \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 675bf282..080a9b28 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -55,8 +55,10 @@ services: path: ./src target: /app/src - action: rebuild - path: ./package*.json + path: ./package.json + - action: rebuild + path: ./package-lock.json 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..cab60cd4 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,8 @@ 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)).optional(), }) .and(MapConfig) .refine((data) => data.gameKey === data.variant.gameKey, { @@ -95,6 +98,16 @@ export const CreateGameApi = z message: numPlayersMessage(data.gameKey), path: ["maxPlayers"], }), + ) + .refine( + (data) => { + if (!data.hotseat) return true; + 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 +157,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/components/awaiting_player.tsx b/src/client/components/awaiting_player.tsx index 6da6facb..5339bb79 100644 --- a/src/client/components/awaiting_player.tsx +++ b/src/client/components/awaiting_player.tsx @@ -13,11 +13,17 @@ const AwaitingPlayerContext = createContext<(() => void) | undefined>( ); const IsAwaitingPlayerContext = createContext(false); -export function useAwaitingPlayer(awaitingPlayer?: number): void { +export function useAwaitingPlayer(awaitingPlayer?: number | string): void { const me = useMe(); const ctx = useContext(AwaitingPlayerContext); useEffect(() => { - if (ctx != null && awaitingPlayer != null && awaitingPlayer === me?.id) { + // Only check for numeric user IDs (not hotseat string names) + if ( + ctx != null && + awaitingPlayer != null && + typeof awaitingPlayer === "number" && + awaitingPlayer === me?.id + ) { return ctx(); } }, [me?.id, awaitingPlayer, ctx]); diff --git a/src/client/components/username.tsx b/src/client/components/username.tsx index 7ca3aacd..15360bfb 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,37 @@ 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 + const numericIds = userIds.filter((id): id is number => typeof id === "number"); + const stringIds = userIds.filter((id): id is string => typeof id === "string"); + + 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 cb6d5972..f0c8e481 100644 --- a/src/client/game/active_game.tsx +++ b/src/client/game/active_game.tsx @@ -117,10 +117,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 57005819..d1ffd731 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; @@ -253,12 +263,64 @@ 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/player_stats.tsx b/src/client/game/player_stats.tsx index 76b5bf06..a30c1717 100644 --- a/src/client/game/player_stats.tsx +++ b/src/client/game/player_stats.tsx @@ -136,9 +136,11 @@ export function PlayerStats() { ); })} ))} 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 0c1aca44..9fb1cf87 100644 --- a/src/client/home/game_card.tsx +++ b/src/client/home/game_card.tsx @@ -62,11 +62,11 @@ export function GameCard({ game, hideStatus }: GameCardProps) { {game.activePlayerId && (

Active Player:{" "} - +

)}

- Players: + Players:

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

Seats: {seats(game)}

@@ -81,11 +81,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..b1784375 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) + : me?.id === 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..9db78e7b 100644 --- a/src/client/services/game.ts +++ b/src/client/services/game.ts @@ -498,8 +498,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 4d764691..bc6560c5 100644 --- a/src/e2e/create_game_test.ts +++ b/src/e2e/create_game_test.ts @@ -48,7 +48,9 @@ export function creatingGame(driver: Driver) { } async function startGame(game: GameDao, seedValue: string): Promise { - await driver.goToGame(game.id, game.playerIds[0]); + const firstPlayerId = game.playerIds[0]; + assert(typeof firstPlayerId === "number", "First player should be a user ID"); + await driver.goToGame(game.id, firstPlayerId); const seedEl = await driver.waitForElement(By.xpath('//*[@name="seed"]')); await driver.driver.executeScript( `arguments[0].value = arguments[1];`, diff --git a/src/e2e/util/game_data.ts b/src/e2e/util/game_data.ts index 7f976567..09a47fc3 100644 --- a/src/e2e/util/game_data.ts +++ b/src/e2e/util/game_data.ts @@ -126,6 +126,7 @@ async function initializeGame( }, unlisted: false, autoStart: false, + hotseat: false, }); } 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/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..712b61fc --- /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 integer arrays to text arrays by casting the data + await queryInterface.sequelize.query(` + UPDATE "Games" + SET "playerIds" = ARRAY(SELECT CAST(unnest("playerIds") AS TEXT)) + WHERE "playerIds" IS NOT NULL + `); + + await queryInterface.sequelize.query(` + UPDATE "Games" + SET "concedingPlayers" = ARRAY(SELECT CAST(unnest("concedingPlayers") AS TEXT)) + WHERE "concedingPlayers" IS NOT NULL + `); + + // Convert activePlayerId to text + await queryInterface.sequelize.query(` + UPDATE "Games" + SET "activePlayerId" = CAST("activePlayerId" AS TEXT) + WHERE "activePlayerId" IS NOT NULL + `); + + // Convert undoPlayerId to text + await queryInterface.sequelize.query(` + UPDATE "Games" + SET "undoPlayerId" = CAST("undoPlayerId" AS TEXT) + WHERE "undoPlayerId" IS NOT NULL + `); + + // Convert userId in GameHistories to text + await queryInterface.sequelize.query(` + UPDATE "GameHistories" + SET "userId" = CAST("userId" AS TEXT) + WHERE "userId" IS NOT NULL + `); + + // Now change the column types + await queryInterface.changeColumn("Games", "playerIds", { + type: DataTypes.ARRAY(DataTypes.TEXT), + allowNull: false, + }); + + await queryInterface.changeColumn("Games", "activePlayerId", { + type: DataTypes.TEXT, + allowNull: true, + }); + + await queryInterface.changeColumn("Games", "undoPlayerId", { + type: DataTypes.TEXT, + allowNull: true, + }); + + await queryInterface.changeColumn("Games", "concedingPlayers", { + type: DataTypes.ARRAY(DataTypes.TEXT), + allowNull: false, + }); + + await queryInterface.changeColumn("GameHistories", "userId", { + type: DataTypes.TEXT, + allowNull: true, + }); +}; + +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/server/game/dao.ts b/src/server/game/dao.ts index 1bf8a9f7..95858a43 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,8 @@ function toLiteApi(game: GameApi | InferAttributes): GameLiteApi { config: game.config, summary: toSummary(game), unlisted: game.unlisted, + hotseat: ("hotseat" in game ? game.hotseat : false) ?? 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..236d2b22 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 + const users = await Promise.all( + game.playerIds.map((id) => UserDao.getUser(Number(id))), + ); + players = users.map((user) => ({ + playerId: 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(), }); @@ -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, @@ -189,35 +206,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 +288,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..aae935aa 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,34 @@ 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)[] = [userId]; + let unlisted = body.unlisted; + let hotseat = false; + + if (body.hotseat) { + // Hotseat games always use string player names and are always unlisted + assert(body.hotseatPlayers != null && body.hotseatPlayers.length > 0, { + invalidInput: "Hotseat games require player names", }); - playerIds.push(...users.map(({ id }) => id)); + 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", + }); + } 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 }) => id)); + } } + const game = await GameDao.create({ version: 1, gameKey: body.gameKey, @@ -160,13 +190,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() } }; }, @@ -179,13 +211,17 @@ const router = initServer().router(gameContract, { const isAdmin = user.role === UserRole.enum.ADMIN; if (!isAdmin) { + const fallbackOwnerId = Number.isFinite(Number(game.playerIds[0])) + ? Number(game.playerIds[0]) + : null; + const ownerId = game.ownerId ?? fallbackOwnerId; assert( game.status === GameStatus.enum.LOBBY || game.playerIds.length === 1, { invalidInput: "cannot delete started game unless it's a solo", }, ); - assert(game.playerIds[0] === user.id, { permissionDenied: true }); + assert(ownerId === user.id, { permissionDenied: true }); } await sequelize.transaction(() => @@ -206,12 +242,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 +259,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 +279,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 +293,27 @@ 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", + }); + 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 +321,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 +353,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 +389,33 @@ 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 }); + 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 +533,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 +571,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,7 +581,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 }); assert(game.status === GameStatus.enum.ACTIVE, { invalidInput: "Can only kick an active game", }); From e29eb7858deff47a1e60347bb6f1973746e65d11 Mon Sep 17 00:00:00 2001 From: Doug Moore Date: Fri, 27 Feb 2026 07:04:00 -0500 Subject: [PATCH 03/10] Fix player ID handling for hotseat games --- src/api/game.ts | 28 +- src/client/auto_action/form.tsx | 2 +- src/client/components/username.tsx | 3 +- src/client/game/create_page.tsx | 5 + src/client/game/options.tsx | 2 +- src/client/services/game.ts | 16 +- src/e2e/create_game_test.ts | 41 ++- src/e2e/e2e_test.ts | 2 + src/e2e/goldens/build_track_after.json | 406 +++++++++++++++++++------ src/e2e/goldens/create_game_after.json | 267 ++++++++++++---- src/e2e/util/game_data.ts | 6 +- src/e2e/util/webdriver.ts | 25 +- src/engine/game/log.ts | 6 +- src/server/game/dao.ts | 5 +- src/server/game/logic.ts | 13 +- src/server/game/routes.ts | 10 +- 16 files changed, 659 insertions(+), 178 deletions(-) diff --git a/src/api/game.ts b/src/api/game.ts index cab60cd4..f27d87fe 100644 --- a/src/api/game.ts +++ b/src/api/game.ts @@ -73,7 +73,22 @@ export const CreateGameApi = z unlisted: z.boolean(), autoStart: z.boolean(), hotseat: z.boolean().default(false), - hotseatPlayers: z.array(z.string().min(1).max(32)).optional(), + 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, { @@ -108,6 +123,17 @@ export const CreateGameApi = z message: "Hotseat games require player names for all minimum players", path: ["hotseatPlayers"], }, + ) + .refine( + (data) => { + if (!data.hotseat) return true; + // If hotseat is true, hotseatPlayers should be defined and not empty + return data.hotseatPlayers !== undefined && data.hotseatPlayers.length > 0; + }, + { + message: "Hotseat games must have at least one player name", + path: ["hotseatPlayers"], + }, ); export type CreateGameApi = 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 15360bfb..bc4f0cf8 100644 --- a/src/client/components/username.tsx +++ b/src/client/components/username.tsx @@ -62,9 +62,8 @@ interface UsernameListProps { } export function UsernameList({ userIds, useLink }: UsernameListProps) { - // Separate numeric and string IDs + // Separate numeric and string IDs for useUsers query const numericIds = userIds.filter((id): id is number => typeof id === "number"); - const stringIds = userIds.filter((id): id is string => typeof id === "string"); const users = useUsers(numericIds); diff --git a/src/client/game/create_page.tsx b/src/client/game/create_page.tsx index d1ffd731..88f21998 100644 --- a/src/client/game/create_page.tsx +++ b/src/client/game/create_page.tsx @@ -276,6 +276,7 @@ export function CreateGamePage() { disabled={isPending} onChange={setHotseat} error={validationError?.hotseat} + data-hotseat-toggle /> {hotseat && ( @@ -286,6 +287,8 @@ export function CreateGamePage() { key={index} placeholder={`Player ${index + 1}`} value={playerName} + data-hotseat-player-input + data-hotseat-player-index={index} onChange={(e: React.ChangeEvent) => { const newPlayers = [...hotseatPlayers]; newPlayers[index] = e.target.value; @@ -297,6 +300,7 @@ export function CreateGamePage() {
- - Switch - + {typeof player.playerId === "number" && ( + + Switch + + )}