Skip to content

Commit c96ac46

Browse files
committed
пофиксил отсутствие имен при старте доски квиза и сделал первую часть русской рулетки
1 parent f5a4a18 commit c96ac46

23 files changed

Lines changed: 1198 additions & 152 deletions

artifacts/api-server/src/adepts.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,8 @@ function emitAdeptsStateToSocket(socket: Socket, sessionId: string): void {
9696
});
9797
}
9898

99-
function broadcastSync(io: Server, sessionId: string, originSocket?: Socket): void {
99+
/** Синк квиза + сессии рулетки в комнату `sessionId` (например после старта игры из `quiz-nav`). */
100+
export function broadcastAdeptsQuizSync(io: Server, sessionId: string, originSocket?: Socket): void {
100101
mirrorSessionTurnFromQuiz(sessionId);
101102
const session = getMutableAdeptsSession(sessionId);
102103
const quiz = cloneQuizRelay(sessionId);
@@ -112,6 +113,10 @@ function broadcastSync(io: Server, sessionId: string, originSocket?: Socket): vo
112113
originSocket?.emit("sync", payload);
113114
}
114115

116+
function broadcastSync(io: Server, sessionId: string, originSocket?: Socket): void {
117+
broadcastAdeptsQuizSync(io, sessionId, originSocket);
118+
}
119+
115120
export function setupAdepts(io: Server): void {
116121
const ns = io.of("/adepts");
117122

artifacts/api-server/src/game.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,31 @@ function getState(sessionId: string): GameState {
4747
return states.get(sessionId)!;
4848
}
4949

50+
/** Перед открытием «Ящика Пандоры» с квиза: 5 слотов с именами мест 0–4, все офлайн — игроки подхватят по setName. */
51+
export function seedPandoraFromQuiz(
52+
sessionId: string,
53+
opts: { playerNames: string[]; initialTurnSeat: number },
54+
): void {
55+
const gs = getState(sessionId);
56+
const names = [...opts.playerNames].slice(0, 5);
57+
while (names.length < 5) names.push("");
58+
gs.bulletPos = -1;
59+
gs.currentPos = 0;
60+
gs.isSpinning = false;
61+
gs.gameOver = false;
62+
gs.gameStarted = false;
63+
gs.roundCount = 0;
64+
gs.eliminatedIndex = null;
65+
gs.bannedNames = [];
66+
gs.scores = {};
67+
gs.turn = ((Math.floor(opts.initialTurnSeat) % 5) + 5) % 5;
68+
gs.slots = names.map((raw, i) => {
69+
const trimmed = String(raw ?? "").trim().slice(0, 20);
70+
const name = trimmed.length > 0 ? trimmed : `Игрок ${i + 1}`;
71+
return { socketId: null, name, isOnline: false };
72+
});
73+
}
74+
5075
function filledSlotCount(state: GameState): number {
5176
return state.slots.filter((s) => s !== null).length;
5277
}

artifacts/api-server/src/lib/adepts-quiz-room-store.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -275,6 +275,19 @@ export function resetQuizRoomForSession(sessionId: string): void {
275275
rooms.set(sessionId, defaultQuizRelayPayload(sessionId));
276276
}
277277

278+
/** Имена мест 0–4 из лобби при «Запуск игры» — сразу в relay, чтобы все клиенты увидели ники в `sync`. */
279+
export function applySeatNickRosterToQuizRelay(sessionId: string, seatNicks: string[]): void {
280+
const s = getQuizRelayOrDefault(sessionId);
281+
const row = [...seatNicks.map((x) => String(x ?? "").trim().slice(0, 64))];
282+
while (row.length < 5) row.push("");
283+
for (let i = 0; i < 5; i++) {
284+
const p = s.players[i];
285+
if (!p) continue;
286+
const nick = row[i] ?? "";
287+
p.name = nick.length > 0 ? nick : `Игрок ${i + 1}`;
288+
}
289+
}
290+
278291
export function __resetQuizRoomsForTests(): void {
279292
rooms.clear();
280293
}

artifacts/api-server/src/quiz-nav.ts

Lines changed: 197 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@ import {
88
listQuizPlayersWithStatusForSession,
99
unbindQuizSocketPresence,
1010
} from "./quiz-players-registry";
11-
import { resetQuizRoomForSession } from "./lib/adepts-quiz-room-store";
11+
import { applySeatNickRosterToQuizRelay, resetQuizRoomForSession } from "./lib/adepts-quiz-room-store";
12+
import { broadcastAdeptsQuizSync } from "./adepts";
13+
import { seedPandoraFromQuiz } from "./game";
1214

1315
function queryAdeptsRoleLower(socket: Socket): string {
1416
const q = socket.handshake.query["adeptsRole"];
@@ -42,6 +44,49 @@ interface ChatEntry {
4244
text: string;
4345
}
4446

47+
interface PandoraLottoPublicState {
48+
phase: "setup" | "drum";
49+
names: string[];
50+
drumPhase: "spinning" | "rolling" | "revealed";
51+
drumKey: number;
52+
winnerIndex: number | null;
53+
}
54+
55+
const DEFAULT_PANDORA_LOTTO_PUBLIC: PandoraLottoPublicState = {
56+
phase: "setup",
57+
names: [],
58+
drumPhase: "spinning",
59+
drumKey: 0,
60+
winnerIndex: null,
61+
};
62+
63+
function sanitizePandoraLottoPublicPayload(payload: unknown): PandoraLottoPublicState {
64+
const po = payload && typeof payload === "object" ? (payload as Record<string, unknown>) : {};
65+
const phase = po["phase"] === "drum" ? "drum" : "setup";
66+
const rawNames = po["names"];
67+
const names = Array.isArray(rawNames)
68+
? rawNames.map((x) => String(x ?? "").trim().slice(0, 16)).filter(Boolean).slice(0, 12)
69+
: [];
70+
const dp = po["drumPhase"];
71+
const drumPhase =
72+
dp === "rolling" || dp === "revealed" || dp === "spinning" ? dp : "spinning";
73+
const rawKey = po["drumKey"];
74+
const kn = typeof rawKey === "number" ? rawKey : Number(rawKey);
75+
const drumKey = Number.isInteger(kn) && kn >= 0 && kn < 1_000_000 ? kn : 0;
76+
const wiRaw = po["winnerIndex"];
77+
let winnerIndex: number | null = null;
78+
if (wiRaw !== null && wiRaw !== undefined && names.length > 0) {
79+
const n = typeof wiRaw === "number" ? wiRaw : Number(wiRaw);
80+
if (Number.isInteger(n) && n >= 0 && n < names.length) winnerIndex = n;
81+
}
82+
if (phase === "setup") {
83+
return { phase: "setup", names, drumPhase: "spinning", drumKey, winnerIndex: null };
84+
}
85+
let win = winnerIndex;
86+
if (drumPhase === "spinning") win = null;
87+
return { phase: "drum", names, drumPhase, drumKey, winnerIndex: win };
88+
}
89+
4590
interface QuizNavSession {
4691
gameStarted: boolean;
4792
lastBoardIndex: number | null;
@@ -51,6 +96,12 @@ interface QuizNavSession {
5196
adeptsWheelActive: boolean;
5297
adeptsWheelReturnHref: string | null;
5398
adeptsWheelCurrentTurnSeat: number;
99+
pandoraRouletteActive: boolean;
100+
pandoraRouletteReturnHref: string | null;
101+
pandoraRouletteCurrentTurnSeat: number;
102+
pandoraRoulettePlayerNames: string[];
103+
pandoraLottoActive: boolean;
104+
pandoraLottoPublic: PandoraLottoPublicState | null;
54105
}
55106

56107
const navBySession = new Map<string, QuizNavSession>();
@@ -67,6 +118,12 @@ function getNav(sessionId: string): QuizNavSession {
67118
adeptsWheelActive: false,
68119
adeptsWheelReturnHref: null,
69120
adeptsWheelCurrentTurnSeat: 0,
121+
pandoraRouletteActive: false,
122+
pandoraRouletteReturnHref: null,
123+
pandoraRouletteCurrentTurnSeat: 0,
124+
pandoraRoulettePlayerNames: [],
125+
pandoraLottoActive: false,
126+
pandoraLottoPublic: null,
70127
};
71128
navBySession.set(sessionId, s);
72129
}
@@ -153,15 +210,21 @@ export function setupQuizNav(io: Server) {
153210
const n = getNav(sessionId);
154211
const po = payload && typeof payload === "object" ? (payload as Record<string, unknown>) : {};
155212
const raw = po["seatPlayerNicks"];
156-
n.seatPlayerNicks = Array.isArray(raw)
157-
? raw.map((x) => String(x ?? "").trim().slice(0, 64)).filter(Boolean).slice(0, 5)
213+
const row = Array.isArray(raw)
214+
? raw.map((x) => String(x ?? "").trim().slice(0, 64)).slice(0, 5)
158215
: [];
216+
while (row.length < 5) row.push("");
217+
n.seatPlayerNicks = row;
159218

160219
n.gameStarted = true;
161220
n.lobbyEmojiLineIndex = -1;
162221
if (n.lastBoardIndex === null || n.lastBoardIndex < 0 || n.lastBoardIndex > MAX_BOARD) {
163222
n.lastBoardIndex = 0;
164223
}
224+
225+
applySeatNickRosterToQuizRelay(sessionId, n.seatPlayerNicks);
226+
broadcastAdeptsQuizSync(io, sessionId, socket);
227+
165228
const out = lobbyPayload(sessionId);
166229
ns.to(sessionId).emit("lobbyState", out);
167230
ns.to(sessionId).emit("phase", { boardIndex: out.boardIndex });
@@ -196,6 +259,132 @@ export function setupQuizNav(io: Server) {
196259
logger.info({ sessionId, returnHref, currentTurnSeat }, "Quiz adepts wheel opened");
197260
});
198261

262+
socket.on("hostPandoraRouletteOpen", (payload: unknown) => {
263+
if (!isQuizNavLobbyHost(socket)) return;
264+
const n = getNav(sessionId);
265+
const po = payload && typeof payload === "object" ? (payload as Record<string, unknown>) : {};
266+
const hrefRaw = po["returnHref"];
267+
const returnHref =
268+
typeof hrefRaw === "string" && hrefRaw.length > 0 && hrefRaw.length < 2048 ? hrefRaw : null;
269+
const rawSeat = po["currentTurnSeat"];
270+
const seatNum = typeof rawSeat === "number" ? rawSeat : Number(rawSeat);
271+
const currentTurnSeat =
272+
Number.isInteger(seatNum) && seatNum >= 0 && seatNum <= 4 ? seatNum : 0;
273+
const rawNames = po["playerNames"];
274+
const playerNames = Array.isArray(rawNames)
275+
? rawNames.map((x) => String(x ?? "").trim().slice(0, 64)).slice(0, 5)
276+
: [];
277+
while (playerNames.length < 5) playerNames.push("");
278+
if (!returnHref) return;
279+
seedPandoraFromQuiz(sessionId, {
280+
playerNames,
281+
initialTurnSeat: currentTurnSeat,
282+
});
283+
n.pandoraRouletteActive = true;
284+
n.pandoraRouletteReturnHref = returnHref;
285+
n.pandoraRouletteCurrentTurnSeat = currentTurnSeat;
286+
n.pandoraRoulettePlayerNames = [...playerNames];
287+
ns.to(sessionId).emit("pandoraRouletteOpened", {
288+
returnHref,
289+
currentTurnSeat,
290+
playerNames,
291+
});
292+
logger.info({ sessionId, returnHref, currentTurnSeat }, "Quiz Pandora roulette opened");
293+
});
294+
295+
socket.on("hostPandoraLottoOpen", () => {
296+
if (!isQuizNavLobbyHost(socket)) return;
297+
const n = getNav(sessionId);
298+
n.pandoraLottoActive = true;
299+
n.pandoraLottoPublic = { ...DEFAULT_PANDORA_LOTTO_PUBLIC };
300+
ns.to(sessionId).emit("pandoraLottoOpened", {});
301+
socket.emit("pandoraLottoOpened", {});
302+
ns.to(sessionId).emit("pandoraLottoPublicState", n.pandoraLottoPublic);
303+
socket.emit("pandoraLottoPublicState", n.pandoraLottoPublic);
304+
logger.info({ sessionId }, "Quiz Pandora lotto opened");
305+
});
306+
307+
socket.on("hostPandoraLottoPublicSync", (payload: unknown) => {
308+
if (!isQuizNavLobbyHost(socket)) return;
309+
const n = getNav(sessionId);
310+
if (!n.pandoraLottoActive) return;
311+
const next = sanitizePandoraLottoPublicPayload(payload);
312+
n.pandoraLottoPublic = next;
313+
ns.to(sessionId).emit("pandoraLottoPublicState", next);
314+
socket.emit("pandoraLottoPublicState", next);
315+
});
316+
317+
socket.on("hostPandoraLottoClose", () => {
318+
if (!isQuizNavLobbyHost(socket)) return;
319+
const n = getNav(sessionId);
320+
321+
let targetSessionId = sessionId;
322+
let targetNav = n;
323+
324+
if (!n.pandoraLottoActive) {
325+
for (const [sid, nav] of navBySession.entries()) {
326+
if (nav.pandoraLottoActive) {
327+
targetSessionId = sid;
328+
targetNav = nav;
329+
break;
330+
}
331+
}
332+
if (!targetNav.pandoraLottoActive) return;
333+
}
334+
335+
targetNav.pandoraLottoActive = false;
336+
targetNav.pandoraLottoPublic = null;
337+
ns.to(targetSessionId).emit("pandoraLottoReturn", {});
338+
logger.info({ sessionId, targetSessionId }, "Quiz Pandora lotto closed");
339+
});
340+
341+
socket.on("requestPandoraLottoState", () => {
342+
const nav = getNav(sessionId);
343+
if (nav.pandoraLottoActive) {
344+
socket.emit("pandoraLottoOpened", {});
345+
const pub = nav.pandoraLottoPublic ?? { ...DEFAULT_PANDORA_LOTTO_PUBLIC };
346+
nav.pandoraLottoPublic = pub;
347+
socket.emit("pandoraLottoPublicState", pub);
348+
}
349+
});
350+
351+
socket.on("hostPandoraRouletteReturn", () => {
352+
if (!isQuizNavLobbyHost(socket)) return;
353+
const n = getNav(sessionId);
354+
355+
let targetSessionId = sessionId;
356+
let targetNav = n;
357+
358+
if (!n.pandoraRouletteActive || !n.pandoraRouletteReturnHref) {
359+
for (const [sid, nav] of navBySession.entries()) {
360+
if (nav.pandoraRouletteActive && nav.pandoraRouletteReturnHref) {
361+
targetSessionId = sid;
362+
targetNav = nav;
363+
break;
364+
}
365+
}
366+
if (!targetNav.pandoraRouletteActive || !targetNav.pandoraRouletteReturnHref) return;
367+
}
368+
369+
const href = targetNav.pandoraRouletteReturnHref;
370+
targetNav.pandoraRouletteActive = false;
371+
targetNav.pandoraRouletteReturnHref = null;
372+
targetNav.pandoraRoulettePlayerNames = [];
373+
ns.to(targetSessionId).emit("pandoraRouletteReturn", { returnHref: href });
374+
logger.info({ sessionId, targetSessionId, returnHref: href }, "Quiz Pandora roulette return");
375+
});
376+
377+
socket.on("requestPandoraRouletteState", () => {
378+
const n = getNav(sessionId);
379+
if (n.pandoraRouletteActive && n.pandoraRouletteReturnHref) {
380+
socket.emit("pandoraRouletteOpened", {
381+
returnHref: n.pandoraRouletteReturnHref,
382+
currentTurnSeat: n.pandoraRouletteCurrentTurnSeat,
383+
playerNames: [...n.pandoraRoulettePlayerNames],
384+
});
385+
}
386+
});
387+
199388
socket.on("hostAdeptsWheelReturn", () => {
200389
if (!isQuizNavLobbyHost(socket)) return;
201390
const n = getNav(sessionId);
@@ -274,6 +463,11 @@ export function setupQuizNav(io: Server) {
274463
n.lobbyEmojiLineIndex = -1;
275464
n.adeptsWheelActive = false;
276465
n.adeptsWheelReturnHref = null;
466+
n.pandoraRouletteActive = false;
467+
n.pandoraRouletteReturnHref = null;
468+
n.pandoraRoulettePlayerNames = [];
469+
n.pandoraLottoActive = false;
470+
n.pandoraLottoPublic = null;
277471
resetQuizRoomForSession(sessionId);
278472
clearQuizPlayers();
279473
ns.emit("quizLobbyRoster", { allSessions: true, players: [] });

artifacts/game-client/src/App.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,13 @@ import AdeptsQuizNotFound from "@/apps/adepts-game/pages/not-found";
1515
import { GamePhaseArrows } from "@/components/GamePhaseArrows";
1616
import { QuizNavSync } from "@/components/QuizNavSync";
1717
import { QuizAdeptsWheelSync } from "@/components/QuizAdeptsWheelSync";
18+
import { QuizPandoraRouletteSync } from "@/components/QuizPandoraRouletteSync";
19+
import { QuizPandoraLottoSync } from "@/components/QuizPandoraLottoSync";
1820
import { QuizReturnToLoginSync } from "@/components/QuizReturnToLoginSync";
1921
import { AdeptsQuizBoardGuard } from "@/components/AdeptsQuizBoardGuard";
2022
import { RequireLogin } from "@/components/RequireLogin";
2123
import { AdeptsLobbyPage } from "@/pages/AdeptsLobbyPage";
24+
import { PandoraLottoPage } from "@/pages/PandoraLottoPage";
2225

2326
const queryClient = new QueryClient();
2427

@@ -59,6 +62,7 @@ function App() {
5962
<Route path="/adepts" component={AdeptsWheelLegacyRedirect} />
6063
<Route path="/watch" component={ViewerPage} />
6164
<Route path="/spectate" component={SpectatorPage} />
65+
<Route path="/pandora-lotto" component={PandoraLottoPage} />
6266
<Route path="/game" component={GamePage} />
6367
<Route path="/adepts-lobby" nest>
6468
<RequireLogin>
@@ -90,6 +94,8 @@ function App() {
9094
<GamePhaseArrows />
9195
<QuizNavSync />
9296
<QuizAdeptsWheelSync />
97+
<QuizPandoraRouletteSync />
98+
<QuizPandoraLottoSync />
9399
<QuizReturnToLoginSync />
94100
</>
95101
</Router>

artifacts/game-client/src/apps/adepts-game/hooks/useGameState.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
import { useState, useEffect, useCallback, useRef } from "react";
22
import type { AdeptsBoardId, Player, Question } from "@/lib/adepts-quiz-types";
33
import type { QuizBoardHoverCell } from "@/lib/quizBoardHover";
4-
import { mergeSeatRosterIntoQuizPlayers } from "@/lib/quizLobbyClientAssignments";
4+
import {
5+
ADEPTS_QUIZ_ASSIGNMENTS_EVENT,
6+
mergeSeatRosterIntoQuizPlayers,
7+
} from "@/lib/quizLobbyClientAssignments";
58
import { consumeAdeptsWheelReturnCloseQuizCardFlag } from "@/lib/quizAdeptsWheelClient";
69
import type { AdeptsQuizBoardPayload } from "@/lib/adeptsQuizBoardApi";
710
import {
@@ -548,7 +551,14 @@ export function useGameState(boardId: AdeptsBoardId) {
548551
socket.emit("requestAdeptsSync");
549552
}
550553

554+
/** `lobbyState` пишет ростер в sessionStorage после первого sync — без повторного sync merge имён не случится. */
555+
const onAssignments = () => {
556+
if (socket.connected) socket.emit("requestAdeptsSync");
557+
};
558+
window.addEventListener(ADEPTS_QUIZ_ASSIGNMENTS_EVENT, onAssignments);
559+
551560
return () => {
561+
window.removeEventListener(ADEPTS_QUIZ_ASSIGNMENTS_EVENT, onAssignments);
552562
socket.off("connect", onConnect);
553563
socket.off("disconnect", onDisconnect);
554564
socket.off("sync", onAdeptsSync);

0 commit comments

Comments
 (0)