fix(online): rotate server state to viewer's seat — non-seat-0 players can play
The server is authoritative with ABSOLUTE seats and tells each client its own seat via mySeat, but the client copied seats verbatim — so any player not at absolute seat 0 had their hand at players[mySeat] while the table read players[0] and "your turn" checked turn===0. Result: they couldn't play (server auto-played after the timeout → "hang"), and the turn highlight was identical for everyone instead of rotating per viewer. Fix (client-only; server was correct): new viewerRot(mySeat) rotates every seat-indexed value into the viewer's frame (viewer → local seat 0): players/hands, turn, hakem, leadSeat, lastTrickWinner, currentTrick, hakemDraw, seat roster, disconnectedSeat, and the team arrays (matchScore/roundTricks/lastRoundResult/ matchWinner — odd seats swap team order). Store mySeat and rotate reaction bubbles too (they carried absolute seats). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -3,7 +3,7 @@
|
|||||||
import { AnimatePresence, motion } from "framer-motion";
|
import { AnimatePresence, motion } from "framer-motion";
|
||||||
import { Crown, Flag, LogOut, SmilePlus, Volume2, VolumeX, Zap } from "lucide-react";
|
import { Crown, Flag, LogOut, SmilePlus, Volume2, VolumeX, Zap } from "lucide-react";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { useGameStore } from "@/lib/game-store";
|
import { useGameStore, viewerRot } from "@/lib/game-store";
|
||||||
import { useSoundStore } from "@/lib/sound-store";
|
import { useSoundStore } from "@/lib/sound-store";
|
||||||
import { Avatar } from "@/components/online/Avatar";
|
import { Avatar } from "@/components/online/Avatar";
|
||||||
import { legalMoves } from "@/lib/hokm/engine";
|
import { legalMoves } from "@/lib/hokm/engine";
|
||||||
@@ -776,8 +776,10 @@ function Reactions() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const unsub = getService().onReaction((seat, emoji) => {
|
const unsub = getService().onReaction((seat, emoji) => {
|
||||||
const id = `${seat}-${Date.now()}-${Math.random()}`;
|
// Reactions carry the sender's ABSOLUTE seat — rotate into this viewer's frame.
|
||||||
setBubbles((b) => [...b, { id, seat, emoji }]);
|
const local = viewerRot(useGameStore.getState().mySeat).seat(seat) ?? seat;
|
||||||
|
const id = `${local}-${Date.now()}-${Math.random()}`;
|
||||||
|
setBubbles((b) => [...b, { id, seat: local, emoji }]);
|
||||||
setTimeout(() => setBubbles((b) => b.filter((x) => x.id !== id)), 2600);
|
setTimeout(() => setBubbles((b) => b.filter((x) => x.id !== id)), 2600);
|
||||||
});
|
});
|
||||||
return unsub;
|
return unsub;
|
||||||
|
|||||||
+72
-33
@@ -96,6 +96,9 @@ interface GameStore {
|
|||||||
forfeitRequest: ForfeitRequest | null;
|
forfeitRequest: ForfeitRequest | null;
|
||||||
/** a fresh online match just started — play the "players joining the table" intro once. */
|
/** a fresh online match just started — play the "players joining the table" intro once. */
|
||||||
matchIntroPending: boolean;
|
matchIntroPending: boolean;
|
||||||
|
/** the viewer's ABSOLUTE seat on the live server (for rotating server-absolute
|
||||||
|
* events like reactions into the local frame). null offline. */
|
||||||
|
mySeat: Seat | null;
|
||||||
|
|
||||||
newMatch: (settings: GameSettings) => void;
|
newMatch: (settings: GameSettings) => void;
|
||||||
newOnlineMatch: (cfg: OnlineMatchConfig) => void;
|
newOnlineMatch: (cfg: OnlineMatchConfig) => void;
|
||||||
@@ -142,42 +145,69 @@ function mapCard(c: { suit: string; rank: number; id: string }): Card {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Map a server GameStateDto onto the client GameState. */
|
/** Map a server GameStateDto onto the client GameState. */
|
||||||
|
/** Local seat = absolute seat rotated so the viewer (mySeat) sits at seat 0. */
|
||||||
|
export function viewerRot(mySeat: number | null | undefined) {
|
||||||
|
const v = (((mySeat ?? 0) % 4) + 4) % 4;
|
||||||
|
return {
|
||||||
|
v,
|
||||||
|
/** absolute seat → local seat (viewer → 0) */
|
||||||
|
seat: (a: number | null | undefined): Seat | null =>
|
||||||
|
a == null ? null : ((((a - v) % 4) + 4) % 4) as Seat,
|
||||||
|
/** absolute team value → local team value */
|
||||||
|
team: (t: number | null | undefined): Team | null =>
|
||||||
|
t == null ? null : (((t + v) % 2) as Team),
|
||||||
|
/** re-index a [team0, team1] absolute array into the viewer's local frame */
|
||||||
|
teamArr: (arr: number[]): [number, number] => [arr[v % 2] ?? 0, arr[(1 + v) % 2] ?? 0],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function mapServerState(s: ServerGameState): GameState {
|
function mapServerState(s: ServerGameState): GameState {
|
||||||
const players: Player[] = s.players.map((p) => ({
|
// The server is authoritative with ABSOLUTE seats; every client must rotate so
|
||||||
seat: p.seat as Seat,
|
// it sits at the bottom (local seat 0) — otherwise only absolute-seat-0 can play
|
||||||
name: p.name,
|
// and the turn highlight is identical for everyone.
|
||||||
isHuman: p.isHuman,
|
const r = viewerRot(s.mySeat);
|
||||||
team: p.team as Team,
|
const bySeat: Record<number, ServerGameState["players"][number]> = {};
|
||||||
hand: p.hand
|
for (const p of s.players) bySeat[p.seat] = p;
|
||||||
|
|
||||||
|
const players: Player[] = ([0, 1, 2, 3] as const).map((l) => {
|
||||||
|
const p = bySeat[(l + r.v) % 4];
|
||||||
|
return {
|
||||||
|
seat: l as Seat,
|
||||||
|
name: p?.name ?? "",
|
||||||
|
isHuman: p?.isHuman ?? false,
|
||||||
|
team: (l % 2) as Team,
|
||||||
|
hand: p?.hand
|
||||||
? p.hand.map(mapCard)
|
? p.hand.map(mapCard)
|
||||||
: Array.from({ length: p.handCount }, (_, i) => ({
|
: Array.from({ length: p?.handCount ?? 0 }, (_, i) => ({
|
||||||
suit: "spades" as Suit,
|
suit: "spades" as Suit,
|
||||||
rank: 2 as Rank,
|
rank: 2 as Rank,
|
||||||
id: `hidden-${p.seat}-${i}`,
|
id: `hidden-${l}-${i}`,
|
||||||
})),
|
})),
|
||||||
}));
|
};
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
phase: s.phase as Phase,
|
phase: s.phase as Phase,
|
||||||
players,
|
players,
|
||||||
deck: [],
|
deck: [],
|
||||||
hakem: s.hakem as Seat | null,
|
hakem: r.seat(s.hakem),
|
||||||
trump: (s.trump as Suit) ?? null,
|
trump: (s.trump as Suit) ?? null,
|
||||||
turn: s.turn as Seat | null,
|
turn: r.seat(s.turn),
|
||||||
currentTrick: s.currentTrick.map((pc) => ({ seat: pc.seat as Seat, card: mapCard(pc.card) })),
|
currentTrick: s.currentTrick.map((pc) => ({ seat: r.seat(pc.seat)!, card: mapCard(pc.card) })),
|
||||||
leadSeat: s.leadSeat as Seat | null,
|
leadSeat: r.seat(s.leadSeat),
|
||||||
roundTricks: [s.roundTricks[0] ?? 0, s.roundTricks[1] ?? 0],
|
roundTricks: r.teamArr(s.roundTricks),
|
||||||
matchScore: [s.matchScore[0] ?? 0, s.matchScore[1] ?? 0],
|
matchScore: r.teamArr(s.matchScore),
|
||||||
lastTrickWinner: s.lastTrickWinner as Seat | null,
|
lastTrickWinner: r.seat(s.lastTrickWinner),
|
||||||
lastRoundResult: s.lastRoundResult
|
lastRoundResult: s.lastRoundResult
|
||||||
? {
|
? {
|
||||||
winningTeam: s.lastRoundResult.winningTeam as Team,
|
winningTeam: r.team(s.lastRoundResult.winningTeam) as Team,
|
||||||
tricks: [s.lastRoundResult.tricks[0], s.lastRoundResult.tricks[1]],
|
tricks: r.teamArr(s.lastRoundResult.tricks),
|
||||||
kot: s.lastRoundResult.kot,
|
kot: s.lastRoundResult.kot,
|
||||||
points: s.lastRoundResult.points,
|
points: s.lastRoundResult.points,
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
matchWinner: s.matchWinner as Team | null,
|
matchWinner: r.team(s.matchWinner),
|
||||||
hakemDraw: s.hakemDraw.map((pc) => ({ seat: pc.seat as Seat, card: mapCard(pc.card) })),
|
hakemDraw: s.hakemDraw.map((pc) => ({ seat: r.seat(pc.seat)!, card: mapCard(pc.card) })),
|
||||||
targetScore: s.targetScore,
|
targetScore: s.targetScore,
|
||||||
dealId: s.dealId,
|
dealId: s.dealId,
|
||||||
};
|
};
|
||||||
@@ -322,6 +352,7 @@ export const useGameStore = create<GameStore>((set, get) => {
|
|||||||
forfeited: false,
|
forfeited: false,
|
||||||
forfeitRequest: null,
|
forfeitRequest: null,
|
||||||
matchIntroPending: false,
|
matchIntroPending: false,
|
||||||
|
mySeat: null,
|
||||||
|
|
||||||
newMatch: (settings) => {
|
newMatch: (settings) => {
|
||||||
clearPending();
|
clearPending();
|
||||||
@@ -423,18 +454,24 @@ export const useGameStore = create<GameStore>((set, get) => {
|
|||||||
const prev = get().game;
|
const prev = get().game;
|
||||||
const next = mapServerState(s);
|
const next = mapServerState(s);
|
||||||
const me = useSessionStore.getState().profile;
|
const me = useSessionStore.getState().profile;
|
||||||
const seatPlayers: SeatPlayer[] = [...s.seatPlayers]
|
// Rotate the seat roster into the viewer's frame too, so local seat 0 = you,
|
||||||
.sort((a, b) => a.seat - b.seat)
|
// 2 = partner, 1/3 = opponents (matches the rotated game state above).
|
||||||
.map((sp) => ({
|
const r = viewerRot(s.mySeat);
|
||||||
name: sp.name,
|
const bySeat: Record<number, ServerGameState["seatPlayers"][number]> = {};
|
||||||
avatar: avatarEmoji(sp.avatar),
|
for (const sp of s.seatPlayers) bySeat[sp.seat] = sp;
|
||||||
avatarId: sp.avatar,
|
const seatPlayers: SeatPlayer[] = ([0, 1, 2, 3] as const).map((l) => {
|
||||||
avatarImage: sp.avatarImage ?? null,
|
const sp = bySeat[(l + r.v) % 4];
|
||||||
level: sp.level,
|
return {
|
||||||
id: sp.userId,
|
name: sp?.name ?? "",
|
||||||
isBot: sp.isBot,
|
avatar: avatarEmoji(sp?.avatar ?? "a-fox"),
|
||||||
title: sp.userId && me && sp.userId === me.id ? me.title ?? null : null,
|
avatarId: sp?.avatar,
|
||||||
}));
|
avatarImage: sp?.avatarImage ?? null,
|
||||||
|
level: sp?.level ?? 0,
|
||||||
|
id: sp?.userId,
|
||||||
|
isBot: sp?.isBot,
|
||||||
|
title: sp?.userId && me && sp.userId === me.id ? me.title ?? null : null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
// accumulate the reward tally when the match score grows (a round ended)
|
// accumulate the reward tally when the match score grows (a round ended)
|
||||||
const prevTotal = prev.matchScore[0] + prev.matchScore[1];
|
const prevTotal = prev.matchScore[0] + prev.matchScore[1];
|
||||||
@@ -454,9 +491,10 @@ export const useGameStore = create<GameStore>((set, get) => {
|
|||||||
set({
|
set({
|
||||||
game: next,
|
game: next,
|
||||||
seatPlayers,
|
seatPlayers,
|
||||||
|
mySeat: (s.mySeat ?? null) as Seat | null,
|
||||||
matchMeta: { ranked: s.ranked, stake: s.stake, speed: false },
|
matchMeta: { ranked: s.ranked, stake: s.stake, speed: false },
|
||||||
turnDeadline: s.turnDeadline ?? null,
|
turnDeadline: s.turnDeadline ?? null,
|
||||||
disconnectedSeat: (s.disconnectedSeat ?? null) as Seat | null,
|
disconnectedSeat: r.seat(s.disconnectedSeat),
|
||||||
reconnectDeadline: s.disconnectedSeat != null ? Date.now() + RECONNECT_MS : null,
|
reconnectDeadline: s.disconnectedSeat != null ? Date.now() + RECONNECT_MS : null,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -558,6 +596,7 @@ export const useGameStore = create<GameStore>((set, get) => {
|
|||||||
forfeited: false,
|
forfeited: false,
|
||||||
forfeitRequest: null,
|
forfeitRequest: null,
|
||||||
matchIntroPending: false,
|
matchIntroPending: false,
|
||||||
|
mySeat: null,
|
||||||
seatPlayers: [],
|
seatPlayers: [],
|
||||||
tally: freshTally(),
|
tally: freshTally(),
|
||||||
turnDeadline: null,
|
turnDeadline: null,
|
||||||
|
|||||||
Reference in New Issue
Block a user