diff --git a/src/components/GameTable.tsx b/src/components/GameTable.tsx index 33138ff..d1805a6 100644 --- a/src/components/GameTable.tsx +++ b/src/components/GameTable.tsx @@ -3,7 +3,7 @@ import { AnimatePresence, motion } from "framer-motion"; import { Crown, Flag, LogOut, SmilePlus, Volume2, VolumeX, Zap } from "lucide-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 { Avatar } from "@/components/online/Avatar"; import { legalMoves } from "@/lib/hokm/engine"; @@ -776,8 +776,10 @@ function Reactions() { useEffect(() => { const unsub = getService().onReaction((seat, emoji) => { - const id = `${seat}-${Date.now()}-${Math.random()}`; - setBubbles((b) => [...b, { id, seat, emoji }]); + // Reactions carry the sender's ABSOLUTE seat — rotate into this viewer's frame. + 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); }); return unsub; diff --git a/src/lib/game-store.ts b/src/lib/game-store.ts index 11ef8de..5daee73 100644 --- a/src/lib/game-store.ts +++ b/src/lib/game-store.ts @@ -96,6 +96,9 @@ interface GameStore { forfeitRequest: ForfeitRequest | null; /** a fresh online match just started — play the "players joining the table" intro once. */ 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; 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. */ +/** 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 { - const players: Player[] = s.players.map((p) => ({ - seat: p.seat as Seat, - name: p.name, - isHuman: p.isHuman, - team: p.team as Team, - hand: p.hand - ? p.hand.map(mapCard) - : Array.from({ length: p.handCount }, (_, i) => ({ - suit: "spades" as Suit, - rank: 2 as Rank, - id: `hidden-${p.seat}-${i}`, - })), - })); + // The server is authoritative with ABSOLUTE seats; every client must rotate so + // it sits at the bottom (local seat 0) — otherwise only absolute-seat-0 can play + // and the turn highlight is identical for everyone. + const r = viewerRot(s.mySeat); + const bySeat: Record = {}; + 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) + : Array.from({ length: p?.handCount ?? 0 }, (_, i) => ({ + suit: "spades" as Suit, + rank: 2 as Rank, + id: `hidden-${l}-${i}`, + })), + }; + }); + return { phase: s.phase as Phase, players, deck: [], - hakem: s.hakem as Seat | null, + hakem: r.seat(s.hakem), trump: (s.trump as Suit) ?? null, - turn: s.turn as Seat | null, - currentTrick: s.currentTrick.map((pc) => ({ seat: pc.seat as Seat, card: mapCard(pc.card) })), - leadSeat: s.leadSeat as Seat | null, - roundTricks: [s.roundTricks[0] ?? 0, s.roundTricks[1] ?? 0], - matchScore: [s.matchScore[0] ?? 0, s.matchScore[1] ?? 0], - lastTrickWinner: s.lastTrickWinner as Seat | null, + turn: r.seat(s.turn), + currentTrick: s.currentTrick.map((pc) => ({ seat: r.seat(pc.seat)!, card: mapCard(pc.card) })), + leadSeat: r.seat(s.leadSeat), + roundTricks: r.teamArr(s.roundTricks), + matchScore: r.teamArr(s.matchScore), + lastTrickWinner: r.seat(s.lastTrickWinner), lastRoundResult: s.lastRoundResult ? { - winningTeam: s.lastRoundResult.winningTeam as Team, - tricks: [s.lastRoundResult.tricks[0], s.lastRoundResult.tricks[1]], + winningTeam: r.team(s.lastRoundResult.winningTeam) as Team, + tricks: r.teamArr(s.lastRoundResult.tricks), kot: s.lastRoundResult.kot, points: s.lastRoundResult.points, } : null, - matchWinner: s.matchWinner as Team | null, - hakemDraw: s.hakemDraw.map((pc) => ({ seat: pc.seat as Seat, card: mapCard(pc.card) })), + matchWinner: r.team(s.matchWinner), + hakemDraw: s.hakemDraw.map((pc) => ({ seat: r.seat(pc.seat)!, card: mapCard(pc.card) })), targetScore: s.targetScore, dealId: s.dealId, }; @@ -322,6 +352,7 @@ export const useGameStore = create((set, get) => { forfeited: false, forfeitRequest: null, matchIntroPending: false, + mySeat: null, newMatch: (settings) => { clearPending(); @@ -423,18 +454,24 @@ export const useGameStore = create((set, get) => { const prev = get().game; const next = mapServerState(s); const me = useSessionStore.getState().profile; - const seatPlayers: SeatPlayer[] = [...s.seatPlayers] - .sort((a, b) => a.seat - b.seat) - .map((sp) => ({ - name: sp.name, - avatar: avatarEmoji(sp.avatar), - avatarId: sp.avatar, - avatarImage: sp.avatarImage ?? null, - level: sp.level, - id: sp.userId, - isBot: sp.isBot, - title: sp.userId && me && sp.userId === me.id ? me.title ?? null : null, - })); + // Rotate the seat roster into the viewer's frame too, so local seat 0 = you, + // 2 = partner, 1/3 = opponents (matches the rotated game state above). + const r = viewerRot(s.mySeat); + const bySeat: Record = {}; + for (const sp of s.seatPlayers) bySeat[sp.seat] = sp; + const seatPlayers: SeatPlayer[] = ([0, 1, 2, 3] as const).map((l) => { + const sp = bySeat[(l + r.v) % 4]; + return { + name: sp?.name ?? "", + avatar: avatarEmoji(sp?.avatar ?? "a-fox"), + 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) const prevTotal = prev.matchScore[0] + prev.matchScore[1]; @@ -454,9 +491,10 @@ export const useGameStore = create((set, get) => { set({ game: next, seatPlayers, + mySeat: (s.mySeat ?? null) as Seat | null, matchMeta: { ranked: s.ranked, stake: s.stake, speed: false }, 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, }); }, @@ -558,6 +596,7 @@ export const useGameStore = create((set, get) => { forfeited: false, forfeitRequest: null, matchIntroPending: false, + mySeat: null, seatPlayers: [], tally: freshTally(), turnDeadline: null,