Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,5 @@ builds
buildmeta.*
*.swp
*.tsbuildinfo
src/e2e/artifacts/screenshots/
scripts/seed_map_ratings_from_csv.js
2 changes: 1 addition & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -61,4 +61,4 @@ services:

volumes:
postgres_data:
redis_data:
redis_data:
39 changes: 35 additions & 4 deletions src/api/game.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof ActionApi>;
Expand Down Expand Up @@ -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, {
Expand All @@ -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<typeof CreateGameApi>;
Expand Down Expand Up @@ -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<typeof GameLiteApi>;

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<typeof GameApi>;

Expand Down
2 changes: 1 addition & 1 deletion src/client/auto_action/form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <></>;

Expand Down
35 changes: 29 additions & 6 deletions src/client/components/username.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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) => (
<span key={username}>
{allNames.map(({ id, username, isString }, index) => (
<span key={`${id}-${username}`}>
{index !== 0 && ", "}
<MaybeLink username={username} userId={id} useLink={useLink} />
{isString ? (
<>{username}</>
) : (
<MaybeLink username={username} userId={id as number} useLink={useLink} />
)}
</span>
))}
</>
Expand Down
19 changes: 19 additions & 0 deletions src/client/game/active_game.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
11 changes: 7 additions & 4 deletions src/client/game/active_game.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -114,10 +114,13 @@ function TurnOrder() {
function GameHeader() {
const game = useGame();
return (
<h1 className={styles.header}>
[{game.name}] {ViewRegistry.singleton.get(game.gameKey).name} -{" "}
{game.summary}
</h1>
<div className={styles.headerRow}>
<h1 className={styles.header}>
[{game.name}] {ViewRegistry.singleton.get(game.gameKey).name} -{" "}
{game.summary}
</h1>
{game.hotseat && <span className={styles.hotseatBadge}>Hotseat</span>}
</div>
);
}

Expand Down
73 changes: 70 additions & 3 deletions src/client/game/create_page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string[]>(["Alice", "Bob"]);
const [minPlayersS, setMinPlayers, setMinPlayersRaw] = useNumberInputState(
selectedMap.minPlayers,
);
Expand Down Expand Up @@ -125,6 +127,8 @@ export function CreateGamePage() {
unlisted,
autoStart,
variant: variant as VariantConfig,
hotseat,
hotseatPlayers: hotseat ? hotseatPlayers : undefined,
});
},
[
Expand All @@ -139,6 +143,8 @@ export function CreateGamePage() {
maxPlayers,
turnDuration,
variant,
hotseat,
hotseatPlayers,
],
);

Expand All @@ -153,6 +159,8 @@ export function CreateGamePage() {
unlisted,
autoStart,
variant: variant as VariantConfig,
hotseat,
hotseatPlayers: hotseat ? hotseatPlayers : undefined,
});
}, [
name,
Expand All @@ -164,6 +172,8 @@ export function CreateGamePage() {
unlisted,
autoStart,
turnDuration,
hotseat,
hotseatPlayers,
]);

const Editor = selectedMap.getVariantConfigEditor;
Expand Down Expand Up @@ -260,12 +270,69 @@ export function CreateGamePage() {
toggle
label="Artificial Start"
checked={artificialStart}
disabled={isPending}
disabled={isPending || hotseat}
onChange={setArtificialStart}
error={validationError?.artificialStart}
/>
)}

<FormCheckbox
toggle
label="Hotseat Mode (Local multiplayer)"
checked={hotseat}
disabled={isPending}
onChange={setHotseat}
error={validationError?.hotseat}
data-hotseat-toggle
/>

{hotseat && (
<div style={{ marginBottom: "1em" }}>
<label style={{ fontWeight: "bold" }}>Player Names</label>
{hotseatPlayers.map((playerName, index) => (
<FormInput
key={index}
placeholder={`Player ${index + 1}`}
value={playerName}
data-hotseat-player-input
data-hotseat-player-index={index}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
const newPlayers = [...hotseatPlayers];
newPlayers[index] = e.target.value;
setHotseatPlayers(newPlayers);
}}
error={validationError?.hotseatPlayers}
/>
))}
<Button
type="button"
size="small"
data-hotseat-add-player
onClick={() => {
if (hotseatPlayers.length < (maxPlayers || 8)) {
setHotseatPlayers([...hotseatPlayers, `Player ${hotseatPlayers.length + 1}`]);
}
}}
disabled={hotseatPlayers.length >= (maxPlayers || 8)}
>
Add Player
</Button>
<Button
type="button"
size="small"
data-hotseat-remove-player
onClick={() => {
if (hotseatPlayers.length > (minPlayers || 2)) {
setHotseatPlayers(hotseatPlayers.slice(0, -1));
}
}}
disabled={hotseatPlayers.length <= (minPlayers || 2)}
>
Remove Player
</Button>
</div>
)}

<FormCheckbox
toggle
data-auto-start
Expand All @@ -279,8 +346,8 @@ export function CreateGamePage() {
<FormCheckbox
toggle
label="Unlisted Game"
checked={unlisted}
disabled={isPending}
checked={unlisted || hotseat}
disabled={isPending || hotseat}
onChange={setUnlisted}
error={validationError?.unlisted}
/>
Expand Down
20 changes: 20 additions & 0 deletions src/client/game/final_overview.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
8 changes: 7 additions & 1 deletion src/client/game/final_overview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export function FinalOverview() {
}

function FinalOverviewInternal() {
const game = useGame();
const playerHelper = useInjectedMemo(PlayerHelper);

const playersOrdered = useMemo(() => {
Expand All @@ -28,7 +29,12 @@ function FinalOverviewInternal() {

return (
<div className={styles.finalOverview}>
<h2>Final Overview</h2>
<div className={styles.headerRow}>
<h2>Final Overview</h2>
{game.hotseat && (
<span className={styles.hotseatBadge}>Hotseat</span>
)}
</div>
<div className={styles.tableContainer}>
<table>
<thead>
Expand Down
2 changes: 1 addition & 1 deletion src/client/game/game_log.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ import { isGameHistory, useGame } from "../services/game";
export function GameLog() {
const game = useGame();
const userColorLookup = useInject(() => {
const lookup: Record<number, PlayerColor> = {};
const lookup: Record<number | string, PlayerColor> = {};
injectAllPlayersUnsafe()().forEach((player) => {
lookup[player.playerId] = player.color;
});
Expand Down
Loading