Compare commits
4 commits
fad3d8399b
...
07eb8aa22e
| Author | SHA1 | Date | |
|---|---|---|---|
| 07eb8aa22e | |||
| c2dc0fd593 | |||
| 40b7cbb025 | |||
| 27b5b4537f |
6 changed files with 177 additions and 41 deletions
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "polaris-task-force",
|
"name": "polaris-task-force",
|
||||||
"version": "0.2.43",
|
"version": "0.2.44",
|
||||||
"description": "Web app for the Arma 3 unit Polaris Task Force. Uses Next.js for client- and server-side rendering, with a Postgres database and some other neat libraries.",
|
"description": "Web app for the Arma 3 unit Polaris Task Force. Uses Next.js for client- and server-side rendering, with a Postgres database and some other neat libraries.",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
import {
|
import {
|
||||||
fetchBlackjackTable,
|
fetchBlackjackTable,
|
||||||
joinBlackjackTable,
|
joinBlackjackTable,
|
||||||
|
|
@ -11,7 +12,6 @@ import {
|
||||||
} from "@/app/(frontend)/minigames/blackjack/actions";
|
} from "@/app/(frontend)/minigames/blackjack/actions";
|
||||||
import type { BlackjackPhase } from "@/lib/minigames/blackjack";
|
import type { BlackjackPhase } from "@/lib/minigames/blackjack";
|
||||||
import { useRealtimePresence } from "@/hooks/useRealtimePresence";
|
import { useRealtimePresence } from "@/hooks/useRealtimePresence";
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { ChipBankCard } from "@/components/frontend/minigames/ChipBankCard";
|
import { ChipBankCard } from "@/components/frontend/minigames/ChipBankCard";
|
||||||
import { BlackjackGuide } from "./BlackjackGuide";
|
import { BlackjackGuide } from "./BlackjackGuide";
|
||||||
|
|
@ -61,7 +61,6 @@ export function BlackjackArena({
|
||||||
const [activeTableId, setActiveTableId] = useState<string | null>(initialTableId ?? null);
|
const [activeTableId, setActiveTableId] = useState<string | null>(initialTableId ?? null);
|
||||||
const [tableError, setTableError] = useState<string | null>(null);
|
const [tableError, setTableError] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState(Boolean(initialTableId));
|
const [loading, setLoading] = useState(Boolean(initialTableId));
|
||||||
const [terminated, setTerminated] = useState<string | null>(null);
|
|
||||||
const [leaderboardRefreshKey, setLeaderboardRefreshKey] = useState(0);
|
const [leaderboardRefreshKey, setLeaderboardRefreshKey] = useState(0);
|
||||||
const [lobbyRefreshKey, setLobbyRefreshKey] = useState(0);
|
const [lobbyRefreshKey, setLobbyRefreshKey] = useState(0);
|
||||||
const lastRoundRef = useRef<number | null>(null);
|
const lastRoundRef = useRef<number | null>(null);
|
||||||
|
|
@ -73,7 +72,6 @@ export function BlackjackArena({
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
setActiveTableId(next.tableId);
|
setActiveTableId(next.tableId);
|
||||||
setTerminated((current) => (current !== next.tableId ? null : current));
|
|
||||||
if (syncedTableIdRef.current !== next.tableId) {
|
if (syncedTableIdRef.current !== next.tableId) {
|
||||||
syncedTableIdRef.current = next.tableId;
|
syncedTableIdRef.current = next.tableId;
|
||||||
window.history.replaceState(
|
window.history.replaceState(
|
||||||
|
|
@ -134,7 +132,8 @@ export function BlackjackArena({
|
||||||
const handleTerminated = (event: Event) => {
|
const handleTerminated = (event: Event) => {
|
||||||
const detail = readBlackjackTerminatedEvent(event);
|
const detail = readBlackjackTerminatedEvent(event);
|
||||||
if (!detail || detail.tableId !== activeTableId) return;
|
if (!detail || detail.tableId !== activeTableId) return;
|
||||||
setTerminated(detail.tableId);
|
toast.info("An administrator ended your blackjack game.");
|
||||||
|
resetToLobby();
|
||||||
};
|
};
|
||||||
window.addEventListener(BLACKJACK_STATE_EVENT, handleState);
|
window.addEventListener(BLACKJACK_STATE_EVENT, handleState);
|
||||||
window.addEventListener(BLACKJACK_TABLES_EVENT, handleTables);
|
window.addEventListener(BLACKJACK_TABLES_EVENT, handleTables);
|
||||||
|
|
@ -150,13 +149,18 @@ export function BlackjackArena({
|
||||||
// deliver server-action pushes (dev per-bundle module duplication). The
|
// deliver server-action pushes (dev per-bundle module duplication). The
|
||||||
// SSE push stays as an instant bonus; applySnapshot dedupes by version.
|
// SSE push stays as an instant bonus; applySnapshot dedupes by version.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (view !== "table" || activeTableId === null || terminated === activeTableId) return;
|
if (view !== "table" || activeTableId === null) return;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
const poll = window.setInterval(() => {
|
const poll = window.setInterval(() => {
|
||||||
void fetchBlackjackTable(activeTableId)
|
void fetchBlackjackTable(activeTableId)
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
if (result.success && result.data) applySnapshot(result.data);
|
if (result.success && result.data) {
|
||||||
|
applySnapshot(result.data);
|
||||||
|
} else if (result.error === "Table not found.") {
|
||||||
|
toast.info("An administrator ended your blackjack game.");
|
||||||
|
resetToLobby();
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.catch(() => undefined);
|
.catch(() => undefined);
|
||||||
}, 1250);
|
}, 1250);
|
||||||
|
|
@ -164,7 +168,7 @@ export function BlackjackArena({
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
window.clearInterval(poll);
|
window.clearInterval(poll);
|
||||||
};
|
};
|
||||||
}, [activeTableId, terminated, view, applySnapshot]);
|
}, [activeTableId, view, applySnapshot]);
|
||||||
|
|
||||||
const resetToLobby = useCallback(() => {
|
const resetToLobby = useCallback(() => {
|
||||||
setView("lobby");
|
setView("lobby");
|
||||||
|
|
@ -172,7 +176,6 @@ export function BlackjackArena({
|
||||||
setActiveTableId(null);
|
setActiveTableId(null);
|
||||||
setTableError(null);
|
setTableError(null);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
setTerminated(null);
|
|
||||||
lastRoundRef.current = null;
|
lastRoundRef.current = null;
|
||||||
if (syncedTableIdRef.current !== null) {
|
if (syncedTableIdRef.current !== null) {
|
||||||
syncedTableIdRef.current = null;
|
syncedTableIdRef.current = null;
|
||||||
|
|
@ -304,22 +307,6 @@ export function BlackjackArena({
|
||||||
onOpenTable={applySnapshot}
|
onOpenTable={applySnapshot}
|
||||||
isSuperuser={isSuperuser}
|
isSuperuser={isSuperuser}
|
||||||
/>
|
/>
|
||||||
) : terminated === activeTableId ? (
|
|
||||||
<Card className="rounded-none border-destructive/40" role="alert">
|
|
||||||
<CardHeader className="border-b border-destructive/20 p-4">
|
|
||||||
<CardTitle className="text-base text-destructive">
|
|
||||||
Game ended by an administrator
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="flex flex-col items-start gap-4 p-4">
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
An administrator forcefully ended this blackjack table.
|
|
||||||
</p>
|
|
||||||
<Button type="button" variant="outline" onClick={resetToLobby}>
|
|
||||||
Back to lobby
|
|
||||||
</Button>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
) : (
|
) : (
|
||||||
<BlackjackTable
|
<BlackjackTable
|
||||||
snapshot={snapshot}
|
snapshot={snapshot}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { handValue, type Card, type PublicPlayerView } from "@/lib/minigames/blackjack";
|
import { handValue, type Card, type PublicPlayerView, type Rank } from "@/lib/minigames/blackjack";
|
||||||
import type { BlackjackSnapshot } from "@/app/(frontend)/minigames/blackjack/actions";
|
import type { BlackjackSnapshot } from "@/app/(frontend)/minigames/blackjack/actions";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { CardView } from "./CardView";
|
import { CardView } from "./CardView";
|
||||||
|
|
@ -40,6 +40,15 @@ function signedAmount(value: number): string {
|
||||||
return `${value >= 0 ? "+" : ""}${value.toLocaleString()}`;
|
return `${value >= 0 ? "+" : ""}${value.toLocaleString()}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Upcard label reads the card's point value, not its rank: face cards (J/Q/K)
|
||||||
|
// count as 10, aces read "ace", and 2-10 show their pip count.
|
||||||
|
function upcardDisplay(rank: Rank | undefined): string {
|
||||||
|
if (!rank) return "";
|
||||||
|
if (rank === 14) return "ace";
|
||||||
|
if (rank >= 11) return "10";
|
||||||
|
return String(rank);
|
||||||
|
}
|
||||||
|
|
||||||
export function BlackjackFeltCanvas({
|
export function BlackjackFeltCanvas({
|
||||||
snapshot,
|
snapshot,
|
||||||
onSnapshot,
|
onSnapshot,
|
||||||
|
|
@ -196,8 +205,7 @@ export function BlackjackFeltCanvas({
|
||||||
)}
|
)}
|
||||||
{view.dealer.holeHidden && view.dealer.cards.length > 0 && (
|
{view.dealer.holeHidden && view.dealer.cards.length > 0 && (
|
||||||
<span className="font-mono text-sm uppercase tracking-[0.2em] text-white/60">
|
<span className="font-mono text-sm uppercase tracking-[0.2em] text-white/60">
|
||||||
Dealer showing{" "}
|
Dealer showing {upcardDisplay(view.dealer.cards[0]?.rank)}
|
||||||
{view.dealer.cards[0]?.rank === 14 ? "ace" : (view.dealer.cards[0]?.rank ?? "")}
|
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Trophy } from "lucide-react";
|
import { Trophy } from "lucide-react";
|
||||||
import type { PresenceParticipant } from "@/hooks/useRealtimePresence";
|
import type { PresenceParticipant } from "@/hooks/useRealtimePresence";
|
||||||
|
import { PROFILE_UPDATE_EVENT } from "@/hooks/useGameTick";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
|
@ -84,6 +85,20 @@ export function BlackjackLeaderboard({
|
||||||
}: BlackjackLeaderboardProps) {
|
}: BlackjackLeaderboardProps) {
|
||||||
const [rows, setRows] = useState<readonly LeaderboardRow[] | null>(null);
|
const [rows, setRows] = useState<readonly LeaderboardRow[] | null>(null);
|
||||||
const [failed, setFailed] = useState(false);
|
const [failed, setFailed] = useState(false);
|
||||||
|
const [refreshTick, setRefreshTick] = useState(0);
|
||||||
|
|
||||||
|
// Read-only surface: keep it fresh without a page refresh. A profile update
|
||||||
|
// (a fellow player's result) and a 30s poll both bump refreshTick, which
|
||||||
|
// re-runs the fetch below. Rows only update on success, so no loading flash.
|
||||||
|
useEffect(() => {
|
||||||
|
const handleProfileUpdate = () => setRefreshTick((tick) => tick + 1);
|
||||||
|
window.addEventListener(PROFILE_UPDATE_EVENT, handleProfileUpdate);
|
||||||
|
const poll = window.setInterval(() => setRefreshTick((tick) => tick + 1), 30_000);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener(PROFILE_UPDATE_EVENT, handleProfileUpdate);
|
||||||
|
window.clearInterval(poll);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
@ -135,7 +150,7 @@ export function BlackjackLeaderboard({
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [currentUserId, refreshKey]);
|
}, [currentUserId, refreshKey, refreshTick]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="rounded-none">
|
<Card className="rounded-none">
|
||||||
|
|
|
||||||
|
|
@ -169,15 +169,14 @@ function scheduleTurnTimer(record: BlackjackTableRecord): void {
|
||||||
record.turnTimer = setTimeout(() => onTurnTimeout(record), TURN_TIMEOUT_MS);
|
record.turnTimer = setTimeout(() => onTurnTimeout(record), TURN_TIMEOUT_MS);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Turn timeout: sit out / decline insurance / stand for whoever is acting. */
|
/**
|
||||||
function onTurnTimeout(record: BlackjackTableRecord): void {
|
* Auto-act for whoever must act: sit out / decline insurance / stand. Shared
|
||||||
const scheduledVersion = record.turnTimerVersion;
|
* by the turn timer and by leaveTable so a mid-game leaver who is on the
|
||||||
record.turnTimer = null;
|
* clock does not hold the table hostage until their timer expires.
|
||||||
record.turnTimerVersion = null;
|
*/
|
||||||
record.turnDeadlineAt = null;
|
function resolveActingSeat(record: BlackjackTableRecord): boolean {
|
||||||
if (scheduledVersion === null || scheduledVersion !== record.version) return;
|
|
||||||
const state = record.state;
|
const state = record.state;
|
||||||
if (!state || state.toActSeat === null) return;
|
if (!state || state.toActSeat === null) return false;
|
||||||
const seat = state.toActSeat;
|
const seat = state.toActSeat;
|
||||||
let result;
|
let result;
|
||||||
if (state.phase === "betting") {
|
if (state.phase === "betting") {
|
||||||
|
|
@ -187,12 +186,23 @@ function onTurnTimeout(record: BlackjackTableRecord): void {
|
||||||
} else if (state.phase === "player") {
|
} else if (state.phase === "player") {
|
||||||
result = applyAction(state, seat, "stand");
|
result = applyAction(state, seat, "stand");
|
||||||
} else {
|
} else {
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
if (!result.success) return;
|
if (!result.success) return false;
|
||||||
record.state = result.state;
|
record.state = result.state;
|
||||||
record.version += 1;
|
record.version += 1;
|
||||||
afterMutation(record);
|
afterMutation(record);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Turn timeout: sit out / decline insurance / stand for whoever is acting. */
|
||||||
|
function onTurnTimeout(record: BlackjackTableRecord): void {
|
||||||
|
const scheduledVersion = record.turnTimerVersion;
|
||||||
|
record.turnTimer = null;
|
||||||
|
record.turnTimerVersion = null;
|
||||||
|
record.turnDeadlineAt = null;
|
||||||
|
if (scheduledVersion === null || scheduledVersion !== record.version) return;
|
||||||
|
resolveActingSeat(record);
|
||||||
}
|
}
|
||||||
|
|
||||||
function scheduleNextRound(record: BlackjackTableRecord): void {
|
function scheduleNextRound(record: BlackjackTableRecord): void {
|
||||||
|
|
@ -252,6 +262,14 @@ function onNextRoundTimeout(record: BlackjackTableRecord): void {
|
||||||
while (record.seatMap.length < MAX_PLAYERS) record.seatMap.push(null);
|
while (record.seatMap.length < MAX_PLAYERS) record.seatMap.push(null);
|
||||||
|
|
||||||
let next = startNextRound(current, dropIndexes);
|
let next = startNextRound(current, dropIndexes);
|
||||||
|
// joinTable pre-seats a mid-game joiner into seatMap AND queues them in
|
||||||
|
// pendingJoins. Clear the pre-seated slot so the loop below seats each
|
||||||
|
// joiner exactly once; after compaction the cleared slot is the first
|
||||||
|
// null, so the joiner lands back on the same seat index.
|
||||||
|
for (const pending of record.pendingJoins) {
|
||||||
|
const preSeated = record.seatMap.indexOf(pending.userId);
|
||||||
|
if (preSeated >= 0) record.seatMap[preSeated] = null;
|
||||||
|
}
|
||||||
for (const pending of record.pendingJoins) {
|
for (const pending of record.pendingJoins) {
|
||||||
// Paid pending joiners bring their buy-in into the engine now.
|
// Paid pending joiners bring their buy-in into the engine now.
|
||||||
if (record.pendingChipPaid.has(pending.userId)) {
|
if (record.pendingChipPaid.has(pending.userId)) {
|
||||||
|
|
@ -625,9 +643,16 @@ export function forceExpireTable(tableId: string, payload: Payload): void {
|
||||||
const record = tables.get(tableId);
|
const record = tables.get(tableId);
|
||||||
if (!record) throw new Error("Table not found.");
|
if (!record) throw new Error("Table not found.");
|
||||||
|
|
||||||
|
// Everyone still watching the table: seated players (leavers are already
|
||||||
|
// out) plus spectators, who would otherwise never learn the game ended.
|
||||||
|
const affected = new Set<number>();
|
||||||
for (const userId of record.seatMap) {
|
for (const userId of record.seatMap) {
|
||||||
if (userId === null || record.leftUsers.has(userId)) continue;
|
if (userId === null || record.leftUsers.has(userId)) continue;
|
||||||
|
affected.add(userId);
|
||||||
|
}
|
||||||
|
for (const spectatorId of record.spectators) affected.add(spectatorId);
|
||||||
|
|
||||||
|
for (const userId of affected) {
|
||||||
broadcastToUser("blackjack-terminated", userId, {
|
broadcastToUser("blackjack-terminated", userId, {
|
||||||
tableId: record.id,
|
tableId: record.id,
|
||||||
reason: "force-ended",
|
reason: "force-ended",
|
||||||
|
|
@ -770,9 +795,14 @@ export function leaveTable(tableId: string, userId: number): void {
|
||||||
if (!isSeatedAt(record, userId)) throw new Error("You are not seated at this table.");
|
if (!isSeatedAt(record, userId)) throw new Error("You are not seated at this table.");
|
||||||
if (record.inGame) {
|
if (record.inGame) {
|
||||||
// Mid-game: keep the seat visible; the player is dropped (and their
|
// Mid-game: keep the seat visible; the player is dropped (and their
|
||||||
// session awarded) when the next betting round starts.
|
// session awarded) when the next betting round starts. When the leaver
|
||||||
|
// is the acting seat, resolve their turn immediately so the table is
|
||||||
|
// not held hostage until their timer expires.
|
||||||
|
const seatIndex = record.seatMap.indexOf(userId);
|
||||||
|
const acting = record.state !== null && record.state.toActSeat === seatIndex;
|
||||||
record.leftUsers.add(userId);
|
record.leftUsers.add(userId);
|
||||||
record.awayUsers.add(userId);
|
record.awayUsers.add(userId);
|
||||||
|
if (acting) resolveActingSeat(record);
|
||||||
} else {
|
} else {
|
||||||
const seatIndex = record.seatMap.indexOf(userId);
|
const seatIndex = record.seatMap.indexOf(userId);
|
||||||
record.seatMap = record.seatMap.filter((_, index) => index !== seatIndex);
|
record.seatMap = record.seatMap.filter((_, index) => index !== seatIndex);
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,17 @@
|
||||||
import { beforeEach, describe, expect, it } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import type { Payload } from "payload";
|
||||||
|
|
||||||
|
import * as bus from "@/lib/realtime/bus";
|
||||||
|
import { TURN_TIMEOUT_MS } from "@/lib/minigames/blackjack/constants";
|
||||||
import {
|
import {
|
||||||
applyBlackjackAction,
|
applyBlackjackAction,
|
||||||
clearTablesForTests,
|
clearTablesForTests,
|
||||||
createTable,
|
createTable,
|
||||||
|
forceExpireTable,
|
||||||
getSnapshotFor,
|
getSnapshotFor,
|
||||||
joinTable,
|
joinTable,
|
||||||
leaveTable,
|
leaveTable,
|
||||||
|
NEXT_ROUND_DELAY_MS,
|
||||||
renameTable,
|
renameTable,
|
||||||
spectateTable,
|
spectateTable,
|
||||||
startGame,
|
startGame,
|
||||||
|
|
@ -187,3 +192,94 @@ describe("blackjack table registry: table management", () => {
|
||||||
expect(() => getSnapshotFor(tableId, 11001)).toThrow("Table not found.");
|
expect(() => getSnapshotFor(tableId, 11001)).toThrow("Table not found.");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("blackjack table registry: mid-game leavers", () => {
|
||||||
|
it("resolves the acting seat immediately when the leaver is on the clock", () => {
|
||||||
|
const tableId = createTable(11101, "Host");
|
||||||
|
joinTable(tableId, 11102, "Guest");
|
||||||
|
startGame(tableId, 11101);
|
||||||
|
// Seat 0 (the host) acts first in the betting phase.
|
||||||
|
expect(getSnapshotFor(tableId, 11102).you?.activeSeat).toBe(0);
|
||||||
|
|
||||||
|
leaveTable(tableId, 11101);
|
||||||
|
|
||||||
|
const guest = getSnapshotFor(tableId, 11102);
|
||||||
|
expect(guest.you?.activeSeat).toBe(1);
|
||||||
|
expect(guest.you?.players.find((player) => player.seatId === 0)?.sittingOut).toBe(true);
|
||||||
|
expect(guest.turnDeadline).not.toBeNull();
|
||||||
|
// The guest is on the clock right away; their bet is accepted.
|
||||||
|
expect(() => applyBlackjackAction(tableId, 11102, "bet", 10)).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not advance the turn when a non-acting player leaves mid-game", () => {
|
||||||
|
const tableId = createTable(11151, "Host");
|
||||||
|
joinTable(tableId, 11152, "Guest");
|
||||||
|
startGame(tableId, 11151);
|
||||||
|
const before = getSnapshotFor(tableId, 11151);
|
||||||
|
const deadline = before.turnDeadline;
|
||||||
|
|
||||||
|
leaveTable(tableId, 11152);
|
||||||
|
|
||||||
|
const after = getSnapshotFor(tableId, 11151);
|
||||||
|
expect(after.you?.activeSeat).toBe(0);
|
||||||
|
expect(after.turnDeadline).toBe(deadline);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("blackjack table registry: mid-game joins", () => {
|
||||||
|
it("seats a mid-game joiner exactly once at the next round", () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
try {
|
||||||
|
const tableId = createTable(11201, "Host");
|
||||||
|
startGame(tableId, 11201);
|
||||||
|
joinTable(tableId, 11202, "Latecomer");
|
||||||
|
|
||||||
|
// Host times out of betting (empty round settles), then the next-round
|
||||||
|
// pause applies the pending join.
|
||||||
|
vi.advanceTimersByTime(TURN_TIMEOUT_MS);
|
||||||
|
vi.advanceTimersByTime(NEXT_ROUND_DELAY_MS);
|
||||||
|
|
||||||
|
const snapshot = getSnapshotFor(tableId, 11201);
|
||||||
|
expect(snapshot.seats.filter((id) => id === 11202)).toHaveLength(1);
|
||||||
|
expect(snapshot.seats).toHaveLength(6);
|
||||||
|
const latecomerPlayers = (snapshot.you?.players ?? []).filter(
|
||||||
|
(player) => player.name === "Latecomer",
|
||||||
|
);
|
||||||
|
expect(latecomerPlayers).toHaveLength(1);
|
||||||
|
} finally {
|
||||||
|
vi.useRealTimers();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("blackjack table registry: force end", () => {
|
||||||
|
it("terminates every seated player and spectator", async () => {
|
||||||
|
const broadcastSpy = vi.spyOn(bus, "broadcastToUser");
|
||||||
|
const tableId = createTable(11301, "Host");
|
||||||
|
joinTable(tableId, 11302, "Guest");
|
||||||
|
startGame(tableId, 11301);
|
||||||
|
spectateTable(tableId, 11303);
|
||||||
|
// The notification write is stubbed out; only the broadcast is asserted.
|
||||||
|
const payloadStub = {
|
||||||
|
logger: { error: vi.fn() },
|
||||||
|
create: vi.fn().mockRejectedValue(new Error("notifications unavailable")),
|
||||||
|
} as unknown as Payload;
|
||||||
|
|
||||||
|
forceExpireTable(tableId, payloadStub);
|
||||||
|
|
||||||
|
expect(broadcastSpy).toHaveBeenCalledWith("blackjack-terminated", 11301, {
|
||||||
|
tableId,
|
||||||
|
reason: "force-ended",
|
||||||
|
});
|
||||||
|
expect(broadcastSpy).toHaveBeenCalledWith("blackjack-terminated", 11302, {
|
||||||
|
tableId,
|
||||||
|
reason: "force-ended",
|
||||||
|
});
|
||||||
|
expect(broadcastSpy).toHaveBeenCalledWith("blackjack-terminated", 11303, {
|
||||||
|
tableId,
|
||||||
|
reason: "force-ended",
|
||||||
|
});
|
||||||
|
expect(() => getSnapshotFor(tableId, 11301)).toThrow("Table not found.");
|
||||||
|
broadcastSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue