Skip to content

Commit 5b578f9

Browse files
committed
Это пиздец, я победил рулетку (-15$ и пару часов жизни ;) Теперь рулетка работает как надо, очень хочется надеяться что не сильно будет баговаться. ЛОТО перенесено на доску квиза, изменений ёбаный насрал, ну всё равно это никто читать не будет - оно работает и славно!
1 parent c96ac46 commit 5b578f9

11 files changed

Lines changed: 320 additions & 32 deletions

File tree

artifacts/api-server/src/game.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,65 @@ function nextOnlineTurn(state: GameState, from: number): number {
111111
return first >= 0 ? first : 0;
112112
}
113113

114+
/** Индекс места застрелившегося в русской рулетке (0–4), если игра в состоянии game over. */
115+
export function peekPandoraEliminatedSeatIndex(sessionId: string): number | null {
116+
const gs = getState(sessionId);
117+
if (!gs.gameOver || gs.eliminatedIndex === null) return null;
118+
return gs.eliminatedIndex;
119+
}
120+
121+
/**
122+
* После «Барабан Лото»: место застрелившегося занимает победитель (ник на слоте),
123+
* старый игрок отключается от комнаты рулетки (уходит зрителем на доску квиза).
124+
* Не добавляет ник в bannedNames (в отличие от обычного rematch).
125+
*/
126+
export function applyPandoraLottoWinnerReplace(
127+
io: Server,
128+
sessionId: string,
129+
winnerNick: string,
130+
): { ok: true; eliminatedSeatIndex: number } | { ok: false; reason: string } {
131+
const gs = getState(sessionId);
132+
if (!gs.gameOver || gs.eliminatedIndex === null) {
133+
return { ok: false, reason: "not_eliminated_state" };
134+
}
135+
const elimIdx = gs.eliminatedIndex;
136+
const safeWinner = String(winnerNick ?? "").trim().slice(0, 20);
137+
if (!safeWinner) return { ok: false, reason: "empty_winner" };
138+
139+
const oldSlot = gs.slots[elimIdx];
140+
const oldSocketId = oldSlot?.socketId ?? null;
141+
142+
gs.slots[elimIdx] = { socketId: null, name: safeWinner, isOnline: false };
143+
gs.scores[elimIdx] = 0;
144+
gs.bulletPos = -1;
145+
gs.currentPos = 0;
146+
gs.isSpinning = false;
147+
gs.gameOver = false;
148+
gs.roundCount = 0;
149+
gs.eliminatedIndex = null;
150+
gs.turn = nextOnlineTurn(gs, elimIdx);
151+
152+
if (oldSocketId) {
153+
try {
154+
const sock = io.sockets.sockets.get(oldSocketId);
155+
if (sock) sock.disconnect(true);
156+
} catch {
157+
/* ignore */
158+
}
159+
}
160+
161+
io.to(sessionId).emit("rematch", {
162+
playerNames: buildPlayerNames(gs),
163+
onlineStatus: buildOnlineStatus(gs),
164+
turn: gs.turn,
165+
count: filledSlotCount(gs),
166+
eliminatedIndex: null,
167+
scores: gs.scores,
168+
});
169+
170+
return { ok: true, eliminatedSeatIndex: elimIdx };
171+
}
172+
114173
export function setupGame(io: Server) {
115174
function adminResetSession(sessionId: string): void {
116175
const sid = sessionId.trim().slice(0, 128) || "default";

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

Lines changed: 89 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,11 @@ import {
1010
} from "./quiz-players-registry";
1111
import { applySeatNickRosterToQuizRelay, resetQuizRoomForSession } from "./lib/adepts-quiz-room-store";
1212
import { broadcastAdeptsQuizSync } from "./adepts";
13-
import { seedPandoraFromQuiz } from "./game";
13+
import {
14+
applyPandoraLottoWinnerReplace,
15+
peekPandoraEliminatedSeatIndex,
16+
seedPandoraFromQuiz,
17+
} from "./game";
1418

1519
function queryAdeptsRoleLower(socket: Socket): string {
1620
const q = socket.handshake.query["adeptsRole"];
@@ -297,11 +301,16 @@ export function setupQuizNav(io: Server) {
297301
const n = getNav(sessionId);
298302
n.pandoraLottoActive = true;
299303
n.pandoraLottoPublic = { ...DEFAULT_PANDORA_LOTTO_PUBLIC };
300-
ns.to(sessionId).emit("pandoraLottoOpened", {});
301-
socket.emit("pandoraLottoOpened", {});
304+
const boardIndex =
305+
n.lastBoardIndex !== null && n.lastBoardIndex >= 0 && n.lastBoardIndex <= MAX_BOARD
306+
? n.lastBoardIndex
307+
: 0;
308+
const openPayload = { boardIndex, broadcastSession: true as const };
309+
ns.to(sessionId).emit("pandoraLottoOpened", openPayload);
310+
socket.emit("pandoraLottoOpened", openPayload);
302311
ns.to(sessionId).emit("pandoraLottoPublicState", n.pandoraLottoPublic);
303312
socket.emit("pandoraLottoPublicState", n.pandoraLottoPublic);
304-
logger.info({ sessionId }, "Quiz Pandora lotto opened");
313+
logger.info({ sessionId, boardIndex }, "Quiz Pandora lotto opened");
305314
});
306315

307316
socket.on("hostPandoraLottoPublicSync", (payload: unknown) => {
@@ -314,6 +323,68 @@ export function setupQuizNav(io: Server) {
314323
socket.emit("pandoraLottoPublicState", next);
315324
});
316325

326+
socket.on("hostPandoraLottoConfirmReplace", (payload: unknown) => {
327+
if (!isQuizNavLobbyHost(socket)) return;
328+
const n = getNav(sessionId);
329+
if (!n.pandoraLottoActive) return;
330+
331+
const po = payload && typeof payload === "object" ? (payload as Record<string, unknown>) : {};
332+
const winnerNick =
333+
typeof po["winnerNick"] === "string" ? po["winnerNick"].trim().slice(0, 64) : "";
334+
if (!winnerNick) return;
335+
336+
const elimPeek = peekPandoraEliminatedSeatIndex(sessionId);
337+
if (elimPeek === null) {
338+
logger.warn({ sessionId }, "hostPandoraLottoConfirmReplace no eliminated seat");
339+
return;
340+
}
341+
342+
const row = [...n.seatPlayerNicks];
343+
while (row.length < 5) row.push("");
344+
const normalized = row.map((x) => String(x ?? "").trim().slice(0, 64));
345+
while (normalized.length < 5) normalized.push("");
346+
const wKey = winnerNick.trim().toLowerCase();
347+
for (let i = 0; i < 5; i += 1) {
348+
if (i === elimPeek) continue;
349+
const nk = (normalized[i] ?? "").trim().toLowerCase();
350+
if (nk.length > 0 && nk === wKey) {
351+
logger.warn({ sessionId, winnerNick }, "hostPandoraLottoConfirmReplace duplicate nick on roster");
352+
return;
353+
}
354+
}
355+
356+
const result = applyPandoraLottoWinnerReplace(io, sessionId, winnerNick);
357+
if (!result.ok) {
358+
logger.warn(
359+
{ sessionId, reason: result.reason },
360+
"hostPandoraLottoConfirmReplace rejected",
361+
);
362+
return;
363+
}
364+
365+
const elimIdx = result.eliminatedSeatIndex;
366+
normalized[elimIdx] = winnerNick.trim().slice(0, 64);
367+
n.seatPlayerNicks = normalized.slice(0, 5);
368+
n.pandoraRoulettePlayerNames = [...n.seatPlayerNicks];
369+
370+
applySeatNickRosterToQuizRelay(sessionId, n.seatPlayerNicks);
371+
broadcastAdeptsQuizSync(io, sessionId, socket);
372+
373+
const out = lobbyPayload(sessionId);
374+
ns.to(sessionId).emit("lobbyState", out);
375+
socket.emit("lobbyState", out);
376+
377+
n.pandoraLottoActive = false;
378+
n.pandoraLottoPublic = null;
379+
380+
const boardIndex =
381+
n.lastBoardIndex !== null && n.lastBoardIndex >= 0 && n.lastBoardIndex <= MAX_BOARD
382+
? n.lastBoardIndex
383+
: 0;
384+
ns.to(sessionId).emit("pandoraLottoReturn", { toQuizBoard: true, boardIndex });
385+
logger.info({ sessionId, elimIdx, winnerNick }, "Quiz Pandora lotto confirm replace");
386+
});
387+
317388
socket.on("hostPandoraLottoClose", () => {
318389
if (!isQuizNavLobbyHost(socket)) return;
319390
const n = getNav(sessionId);
@@ -341,7 +412,7 @@ export function setupQuizNav(io: Server) {
341412
socket.on("requestPandoraLottoState", () => {
342413
const nav = getNav(sessionId);
343414
if (nav.pandoraLottoActive) {
344-
socket.emit("pandoraLottoOpened", {});
415+
/** Не шлём `pandoraLottoOpened` — иначе снова сработает редирект в `QuizPandoraLottoSync`. */
345416
const pub = nav.pandoraLottoPublic ?? { ...DEFAULT_PANDORA_LOTTO_PUBLIC };
346417
nav.pandoraLottoPublic = pub;
347418
socket.emit("pandoraLottoPublicState", pub);
@@ -376,11 +447,15 @@ export function setupQuizNav(io: Server) {
376447

377448
socket.on("requestPandoraRouletteState", () => {
378449
const n = getNav(sessionId);
450+
/** Пока открыт «Барабан Лото», не реплеить рулетку — иначе клиенты на доске получают `pandoraRouletteOpened` и улетают на `/game`/`/spectate`. */
451+
if (n.pandoraLottoActive) return;
379452
if (n.pandoraRouletteActive && n.pandoraRouletteReturnHref) {
453+
/** Не путать с вещанием от `hostPandoraRouletteOpen`: клиент на доске не должен уезжать на рулетку при реплее. */
380454
socket.emit("pandoraRouletteOpened", {
381455
returnHref: n.pandoraRouletteReturnHref,
382456
currentTurnSeat: n.pandoraRouletteCurrentTurnSeat,
383457
playerNames: [...n.pandoraRoulettePlayerNames],
458+
stateReplay: true,
384459
});
385460
}
386461
});
@@ -433,6 +508,15 @@ export function setupQuizNav(io: Server) {
433508
return;
434509
}
435510
n.lastBoardIndex = boardIndex;
511+
/**
512+
* После возврата с рулетки на доску флаг часто остаётся true; смена доски ведущим = работа с квизом,
513+
* реплей `requestPandoraRouletteState` не должен снова открывать рулетку.
514+
*/
515+
if (n.pandoraRouletteActive) {
516+
n.pandoraRouletteActive = false;
517+
n.pandoraRouletteReturnHref = null;
518+
n.pandoraRoulettePlayerNames = [];
519+
}
436520
socket.to(sessionId).emit("phase", { boardIndex });
437521
/** Echo: `socket.to` excludes sender; host must receive `phase` for `QuizNavSync` (no client-side `assign` in `GamePhaseNav`). */
438522
socket.emit("phase", { boardIndex });

artifacts/game-client/src/apps/adepts-game/pages/Home.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { GamePhaseNav } from "@/components/GamePhaseArrows";
99
import { QuizBoardReloadButton } from "@/components/QuizBoardReloadButton";
1010
import { useRole } from "@/hooks/useRole";
1111
import { ChatPanel } from "@/components/ChatPanel";
12+
import { QuizBoardPandoraLottoOverlay } from "@/components/QuizBoardPandoraLottoOverlay";
1213
import { getQuizNavSocket } from "@/hooks/quizNavSocket";
1314

1415
function resolveUrl(url: string): string {
@@ -273,6 +274,8 @@ export default function Home({ boardId }: { boardId: AdeptsBoardId }) {
273274
}
274275
/>
275276
)}
277+
278+
<QuizBoardPandoraLottoOverlay />
276279
</div>
277280
);
278281
}

artifacts/game-client/src/components/AdeptsQuizBoardGuard.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,9 @@ export function AdeptsQuizBoardGuard({ children }: { children: ReactNode }) {
3737
/* ignore */
3838
}
3939
getQuizNavSocket().emit("requestAdeptsWheelState");
40-
getQuizNavSocket().emit("requestPandoraRouletteState");
40+
/** Лото приоритетнее рулетки: иначе два ответа подряд и редирект на `/spectate` перебивает `/pandora-lotto`. */
4141
getQuizNavSocket().emit("requestPandoraLottoState");
42+
getQuizNavSocket().emit("requestPandoraRouletteState");
4243
}, [gameStarted]);
4344

4445
if (lobbyState == null) {

artifacts/game-client/src/components/LottoModal.tsx

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -533,7 +533,8 @@ function BurstParticle({ tx, ty, color, shape, size, rot, delay }: typeof BURST[
533533

534534
export type LottoModalProps = {
535535
onClose: () => void;
536-
onConfirm: () => void;
536+
/** Ник с шара победителя — подставляется на место застрелившегося на доске квиза. */
537+
onConfirm: (winnerNick: string) => void;
537538
/** Режим наблюдателя — состояние с сервера (`pandoraLottoPublicState`) */
538539
readOnly?: boolean;
539540
snapshot?: PandoraLottoPublicState | null;
@@ -566,6 +567,7 @@ export function LottoModal({
566567

567568
const spectatorPool = useQuizSpectatorNicksForLotto(autoSpectatorExcludeNicks);
568569
const spectatorPoolSig = spectatorPool.join("\u0001");
570+
const autoExcludeNickSig = autoSpectatorExcludeNicks.join("\u0001");
569571

570572
const viewerSyncPending = readOnly && snapshot === null;
571573

@@ -589,6 +591,19 @@ export function LottoModal({
589591
getQuizNavSocket().emit("hostPandoraLottoPublicSync", payload);
590592
}, [readOnly, phase, names, drumPhase, drumKey, pickedWinner]);
591593

594+
/** Убрать из списка ведущего и игроков по местам (если уже успели попасть до прихода ростера). */
595+
useEffect(() => {
596+
if (readOnly || phase !== "setup") return;
597+
const ex = new Set(
598+
autoSpectatorExcludeNicks.map((n) => n.trim().toLowerCase()).filter(Boolean),
599+
);
600+
if (ex.size === 0) return;
601+
setNames((prev) => {
602+
const filtered = prev.filter((n) => !ex.has(n.trim().toLowerCase()));
603+
return filtered.length === prev.length ? prev : filtered;
604+
});
605+
}, [readOnly, phase, autoExcludeNickSig]);
606+
592607
/** Автодобавление зрителей из лобби квиза (не ведущий, не игроки за столом рулетки). */
593608
useEffect(() => {
594609
if (readOnly || phase !== "setup") return;
@@ -711,7 +726,13 @@ export function LottoModal({
711726

712727
const confirmChoice = () => {
713728
if (musicRef.current) { musicRef.current.pause(); musicRef.current = null; }
714-
onConfirm();
729+
const winIdx = disp.winnerIndex;
730+
const nick =
731+
winIdx !== null && winIdx >= 0 && winIdx < disp.names.length
732+
? disp.names[winIdx]!.trim().slice(0, 64)
733+
: "";
734+
if (!nick) return;
735+
onConfirm(nick);
715736
};
716737
const closeModal = () => {
717738
if (musicRef.current) { musicRef.current.pause(); musicRef.current = null; }
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { useMemo } from "react";
2+
import { LottoModal } from "@/components/LottoModal";
3+
import { getQuizNavSocket } from "@/hooks/quizNavSocket";
4+
import { usePandoraLottoPublicViewer } from "@/hooks/usePandoraLottoPublicViewer";
5+
import { useQuizLobbyState } from "@/hooks/useQuizLobbyState";
6+
import { useRole } from "@/hooks/useRole";
7+
8+
/**
9+
* Полноэкранное «Барабан Лото» поверх квиз-доски после `hostPandoraLottoOpen`
10+
* (все уже на `/adepts-game/…` по редиректу из `QuizPandoraLottoSync`).
11+
*/
12+
export function QuizBoardPandoraLottoOverlay() {
13+
const { isHost } = useRole();
14+
const { lobbyState } = useQuizLobbyState();
15+
const snapshot = usePandoraLottoPublicViewer();
16+
17+
const lottoAutoExcludeNicks = useMemo(() => {
18+
const out: string[] = [];
19+
const seen = new Set<string>();
20+
const push = (raw: string | undefined) => {
21+
const t = raw?.trim();
22+
if (!t) return;
23+
const k = t.toLowerCase();
24+
if (seen.has(k)) return;
25+
seen.add(k);
26+
out.push(t);
27+
};
28+
if (isHost) push(localStorage.getItem("player_nick") ?? undefined);
29+
for (const n of lobbyState?.seatPlayerNicks ?? []) push(n);
30+
return out;
31+
}, [isHost, lobbyState?.seatPlayerNicks]);
32+
33+
const closeLottoSession = () => {
34+
getQuizNavSocket().emit("hostPandoraLottoClose");
35+
};
36+
37+
const confirmReplace = (winnerNick: string) => {
38+
const nick = winnerNick.trim().slice(0, 64);
39+
if (!nick) return;
40+
getQuizNavSocket().emit("hostPandoraLottoConfirmReplace", { winnerNick: nick });
41+
};
42+
43+
if (snapshot === null) return null;
44+
45+
return (
46+
<div className="fixed inset-0 z-[200]">
47+
{isHost ? (
48+
<LottoModal
49+
onClose={closeLottoSession}
50+
onConfirm={confirmReplace}
51+
autoSpectatorExcludeNicks={lottoAutoExcludeNicks}
52+
/>
53+
) : (
54+
<LottoModal
55+
readOnly
56+
snapshot={snapshot}
57+
onClose={() => {}}
58+
onConfirm={(_winnerNick: string) => {}}
59+
/>
60+
)}
61+
</div>
62+
);
63+
}

0 commit comments

Comments
 (0)