"use client"; import { create } from "zustand"; import { chooseCardAI, chooseTrumpAI } from "./hokm/ai"; import { advanceAfterTrick, chooseTrump as engineChooseTrump, createInitialState, dealForTrump, playCard, selectHakem, startNextRound, } from "./hokm/engine"; import { Card, GameState, Phase, Player, Rank, RoundResult, Seat, Suit, Team } from "./hokm/types"; import { avatarEmoji, ForfeitRequest, RewardResult, ServerGameState } from "./online/types"; import type { OnlineService } from "./online/service"; import { turnMsForStake } from "./online/gamification"; import { useSessionStore } from "./session-store"; import { sound } from "./sound"; const KOT_POINTS = 2; // Animation/pacing timings (ms) — UI matches these. export const TIMING = { hakemDraw: 1500, aiTrump: 1000, aiPlay: 850, trickPause: 1150, roundPause: 2600, } as const; /** Base turn time (starter league / vs-AI). Higher leagues use less — see * `turnMsForStake`. Kept for reference; scheduling derives the real value. */ export const TURN_MS = 15000; /** Grace period to wait for a disconnected player to return (live games). */ export const RECONNECT_MS = 15000; export type GameMode = "ai" | "online"; export interface SeatPlayer { name: string; avatar: string; // emoji (legacy/fallback) avatarId?: string; // avatar id (for the shared component) avatarImage?: string | null; // uploaded profile photo — shown everywhere when present level: number; id?: string; // real player's user id (for add-friend); absent for bots/you isBot?: boolean; title?: string | null; // equipped title id (shown under the avatar on the table) } export interface GameSettings { names: [string, string, string, string]; targetScore: number; /** Blitz/speed mode — fast turn clock + snappier pacing. */ speed?: boolean; } export interface OnlineMatchConfig { players: { displayName: string; avatar: string; level: number; avatarImage?: string | null }[]; // index = seat targetScore: number; stake: number; ranked: boolean; speed?: boolean; } interface MatchTally { tricksTeam0: number; kotFor: boolean; // your team kot'd opponents at least once kotAgainst: boolean; hakemRounds: number; // rounds you (seat 0) were the hakem } interface GameStore { game: GameState; started: boolean; mode: GameMode; seatPlayers: SeatPlayer[]; matchMeta: { ranked: boolean; stake: number; speed: boolean }; tally: MatchTally; /** epoch ms by which the current actor must act (for the turn-timer UI). */ turnDeadline: number | null; /** a seat that has dropped and we're waiting on (online). */ disconnectedSeat: Seat | null; reconnectDeadline: number | null; /** true when the match is driven by the live SignalR server. */ live: boolean; /** reward pushed by the server for a server-run (ranked) match. */ serverReward: RewardResult | null; /** the match is still alive but the player navigated away (resumable). */ paused: boolean; /** you forfeited (surrendered) this match. */ forfeited: boolean; /** a teammate is asking to forfeit and needs your confirmation. */ forfeitRequest: ForfeitRequest | null; /** a fresh online match just started — play the "players joining the table" intro once. */ matchIntroPending: boolean; newMatch: (settings: GameSettings) => void; newOnlineMatch: (cfg: OnlineMatchConfig) => void; enterServerMatch: (service: OnlineService) => void; applyServerState: (s: ServerGameState) => void; chooseTrump: (suit: Suit) => void; playHuman: (card: Card) => void; /** Leave the table without ending the match — keeps it resumable. */ minimize: () => void; /** Return to a minimized match (re-arms local AI timers; live keeps streaming). */ resume: () => void; /** Request to forfeit (surrender) the match. */ forfeit: () => void; /** Respond to a teammate's forfeit request. */ respondForfeit: (confirm: boolean) => void; /** Mark the match-intro reveal as played (so it doesn't replay on resume). */ consumeIntro: () => void; reset: () => void; } const AI_AVATARS = ["🦊", "🦁", "🦉", "🐯"]; let pending: ReturnType | null = null; let liveUnsub: (() => void) | null = null; let rewardUnsub: (() => void) | null = null; let forfeitUnsub: (() => void) | null = null; let liveSvc: OnlineService | null = null; function clearPending() { if (pending) { clearTimeout(pending); pending = null; } } function freshTally(): MatchTally { return { tricksTeam0: 0, kotFor: false, kotAgainst: false, hakemRounds: 0 }; } /** Deals already counted toward the hakem tally (client-run games). */ let countedHakemDeals = new Set(); function mapCard(c: { suit: string; rank: number; id: string }): Card { return { suit: c.suit as Suit, rank: c.rank as Rank, id: c.id }; } /** Map a server GameStateDto onto the client GameState. */ 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}`, })), })); return { phase: s.phase as Phase, players, deck: [], hakem: s.hakem as Seat | null, 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, lastRoundResult: s.lastRoundResult ? { winningTeam: s.lastRoundResult.winningTeam as Team, tricks: [s.lastRoundResult.tricks[0], s.lastRoundResult.tricks[1]], 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) })), targetScore: s.targetScore, dealId: s.dealId, }; } export const useGameStore = create((set, get) => { function recordRound(result: RoundResult | null) { if (!result) return; const t = get().tally; set({ tally: { tricksTeam0: t.tricksTeam0 + result.tricks[0], kotFor: t.kotFor || (result.winningTeam === 0 && result.kot), kotAgainst: t.kotAgainst || (result.winningTeam === 1 && result.kot), hakemRounds: t.hakemRounds, }, }); } function playSeatAI(seat: Seat) { const cur = get().game; if (cur.phase !== "playing" || cur.turn !== seat) return; const card = chooseCardAI(cur, seat); set({ game: playCard(cur, seat, card) }); sound.play("cardPlay"); scheduleAuto(); } function scheduleAuto() { clearPending(); const g = get().game; // Speed mode → snappier pacing (animations/pauses run ~half time). const fast = (ms: number) => (get().matchMeta.speed ? Math.round(ms * 0.5) : ms); switch (g.phase) { case "selecting-hakem": set({ turnDeadline: null, disconnectedSeat: null, reconnectDeadline: null }); pending = setTimeout(() => { set({ game: dealForTrump(get().game) }); sound.play("deal"); scheduleAuto(); }, fast(TIMING.hakemDraw)); break; case "choosing-trump": { const hakem = g.hakem!; // Tally hakem rounds for you (seat 0) — once per deal. if (hakem === 0 && !countedHakemDeals.has(g.dealId)) { countedHakemDeals.add(g.dealId); set({ tally: { ...get().tally, hakemRounds: get().tally.hakemRounds + 1 } }); } if (g.players[hakem].isHuman) { // human hakem: timed choice (less time in higher leagues), system auto-picks on timeout const turnMs = turnMsForStake(get().matchMeta.stake, get().matchMeta.speed); set({ turnDeadline: Date.now() + turnMs, disconnectedSeat: null, reconnectDeadline: null }); pending = setTimeout(() => { const cur = get().game; if (cur.phase !== "choosing-trump") return; const suit = chooseTrumpAI(cur.players[cur.hakem!].hand); set({ game: engineChooseTrump(cur, suit), turnDeadline: null }); scheduleAuto(); }, turnMs); } else { set({ turnDeadline: null }); pending = setTimeout(() => { const cur = get().game; const suit = chooseTrumpAI(cur.players[cur.hakem!].hand); set({ game: engineChooseTrump(cur, suit) }); sound.play("trump"); scheduleAuto(); }, fast(TIMING.aiTrump)); } break; } case "playing": { const seat = g.turn!; if (g.players[seat].isHuman) { // human turn: timed (less time in higher leagues); system plays a smart legal move on timeout const turnMs = turnMsForStake(get().matchMeta.stake, get().matchMeta.speed); set({ turnDeadline: Date.now() + turnMs, disconnectedSeat: null, reconnectDeadline: null }); pending = setTimeout(() => { const cur = get().game; if (cur.phase !== "playing" || cur.turn !== seat) return; set({ game: playCard(cur, seat, chooseCardAI(cur, seat)), turnDeadline: null }); sound.play("cardPlay"); scheduleAuto(); }, turnMs); } else { // Opponents (bots / filled seats) simply auto-play their turn — no // simulated disconnects, no pause, no banner. set({ turnDeadline: null }); pending = setTimeout(() => playSeatAI(seat), fast(TIMING.aiPlay)); } break; } case "trick-complete": set({ turnDeadline: null, disconnectedSeat: null, reconnectDeadline: null }); sound.play("trickWin"); pending = setTimeout(() => { const next = advanceAfterTrick(get().game, KOT_POINTS); set({ game: next }); if (next.phase === "match-over") { recordRound(next.lastRoundResult); sound.play(next.matchWinner === 0 ? "win" : "lose"); } else if (next.phase === "round-over" && next.lastRoundResult?.kot) { sound.play("kot"); } scheduleAuto(); }, fast(TIMING.trickPause)); break; case "round-over": set({ turnDeadline: null, disconnectedSeat: null, reconnectDeadline: null }); pending = setTimeout(() => { recordRound(get().game.lastRoundResult); set({ game: startNextRound(get().game) }); scheduleAuto(); }, fast(TIMING.roundPause)); break; default: set({ turnDeadline: null }); break; } } return { game: createInitialState({ names: ["", "", "", ""], targetScore: 7 }), started: false, mode: "ai", seatPlayers: [], matchMeta: { ranked: false, stake: 0, speed: false }, tally: freshTally(), turnDeadline: null, disconnectedSeat: null, reconnectDeadline: null, live: false, serverReward: null, paused: false, forfeited: false, forfeitRequest: null, matchIntroPending: false, newMatch: (settings) => { clearPending(); countedHakemDeals = new Set(); sound.init(); const initial = createInitialState(settings); set({ game: selectHakem(initial), started: true, mode: "ai", paused: false, forfeited: false, forfeitRequest: null, matchMeta: { ranked: false, stake: 0, speed: !!settings.speed }, tally: freshTally(), turnDeadline: null, disconnectedSeat: null, reconnectDeadline: null, seatPlayers: settings.names.map((name, i) => { const prof = i === 0 ? useSessionStore.getState().profile : null; return { name, avatar: AI_AVATARS[i], avatarId: prof?.avatar, // you → your avatar; bots fall back to the emoji avatarImage: prof?.avatarImage ?? null, level: 0, isBot: i > 0, // seat 0 is you title: i === 0 ? prof?.title ?? null : null, }; }), }); scheduleAuto(); }, newOnlineMatch: (cfg) => { clearPending(); countedHakemDeals = new Set(); sound.init(); const names = cfg.players.map((p) => p.displayName) as GameSettings["names"]; const initial = createInitialState({ names, targetScore: cfg.targetScore }); set({ game: selectHakem(initial), started: true, mode: "online", paused: false, forfeited: false, forfeitRequest: null, matchIntroPending: true, matchMeta: { ranked: cfg.ranked, stake: cfg.stake, speed: !!cfg.speed }, tally: freshTally(), turnDeadline: null, disconnectedSeat: null, reconnectDeadline: null, seatPlayers: cfg.players.map((p, i) => { const prof = i === 0 ? useSessionStore.getState().profile : null; return { name: p.displayName, avatar: avatarEmoji(p.avatar), avatarId: p.avatar, avatarImage: p.avatarImage ?? prof?.avatarImage ?? null, level: p.level, title: i === 0 ? prof?.title ?? null : null, }; }), }); scheduleAuto(); }, enterServerMatch: (service) => { clearPending(); sound.init(); liveSvc = service; if (liveUnsub) liveUnsub(); if (rewardUnsub) rewardUnsub(); if (forfeitUnsub) forfeitUnsub(); liveUnsub = service.onState((s) => get().applyServerState(s)); rewardUnsub = service.onReward((r) => set({ serverReward: r })); forfeitUnsub = service.onForfeit((r) => set({ forfeitRequest: r })); set({ game: createInitialState({ names: ["شما", "", "", ""], targetScore: 7 }), started: true, mode: "online", live: true, serverReward: null, paused: false, forfeited: false, forfeitRequest: null, matchIntroPending: true, matchMeta: { ranked: true, stake: 0, speed: false }, tally: freshTally(), turnDeadline: null, disconnectedSeat: null, reconnectDeadline: null, seatPlayers: [], }); }, applyServerState: (s) => { 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, })); // accumulate the reward tally when the match score grows (a round ended) const prevTotal = prev.matchScore[0] + prev.matchScore[1]; const newTotal = next.matchScore[0] + next.matchScore[1]; if (newTotal > prevTotal && next.lastRoundResult) recordRound(next.lastRoundResult); // sounds on transitions if (next.currentTrick.length > prev.currentTrick.length && next.currentTrick.length > 0) sound.play("cardPlay"); if (next.phase === "trick-complete" && prev.phase !== "trick-complete") sound.play("trickWin"); if (next.trump && !prev.trump) sound.play("trump"); if (next.phase === "match-over" && prev.phase !== "match-over") sound.play(next.matchWinner === 0 ? "win" : "lose"); else if (next.phase === "round-over" && prev.phase !== "round-over" && next.lastRoundResult?.kot) sound.play("kot"); set({ game: next, seatPlayers, matchMeta: { ranked: s.ranked, stake: s.stake, speed: false }, turnDeadline: s.turnDeadline ?? null, disconnectedSeat: (s.disconnectedSeat ?? null) as Seat | null, reconnectDeadline: s.disconnectedSeat != null ? Date.now() + RECONNECT_MS : null, }); }, chooseTrump: (suit) => { if (get().live) { liveSvc?.chooseTrump(suit); return; } const g = get().game; if (g.phase !== "choosing-trump") return; set({ game: engineChooseTrump(g, suit), turnDeadline: null }); sound.play("trump"); scheduleAuto(); }, playHuman: (card) => { if (get().live) { liveSvc?.playCard(card.id); return; } const g = get().game; if (g.phase !== "playing" || g.turn !== 0) return; set({ game: playCard(g, 0, card), turnDeadline: null }); sound.play("cardPlay"); scheduleAuto(); }, minimize: () => { // Keep the match alive and resumable. Single-player (AI) games pause their // local timers so nothing happens while you're away; live (server-run) // games keep streaming into the store via the still-active subscription. if (!get().live) { clearPending(); set({ turnDeadline: null, reconnectDeadline: null }); } set({ paused: true }); }, resume: () => { set({ paused: false }); // Re-arm the local driver for AI games; live games are already up to date. if (!get().live && get().started && get().game.phase !== "match-over") scheduleAuto(); }, forfeit: () => { // Live games: ask the server (teammate must confirm, server decides kot). if (get().live) { liveSvc?.requestForfeit(); return; } // Client-run (vs computer / private): end now as a loss. If your team won // no rounds it's a Kot loss; otherwise a normal loss. const g = get().game; if (g.phase === "match-over") return; clearPending(); set({ game: { ...g, phase: "match-over", matchWinner: 1 as Team, turn: null }, forfeited: true, turnDeadline: null, disconnectedSeat: null, reconnectDeadline: null, }); sound.play("lose"); }, respondForfeit: (confirm) => { if (get().live) { if (confirm) liveSvc?.confirmForfeit(); else liveSvc?.declineForfeit(); } set({ forfeitRequest: null }); }, consumeIntro: () => set({ matchIntroPending: false }), reset: () => { clearPending(); if (liveUnsub) { liveUnsub(); liveUnsub = null; } if (rewardUnsub) { rewardUnsub(); rewardUnsub = null; } if (forfeitUnsub) { forfeitUnsub(); forfeitUnsub = null; } liveSvc = null; set({ game: createInitialState({ names: ["", "", "", ""], targetScore: 7 }), started: false, mode: "ai", live: false, serverReward: null, paused: false, forfeited: false, forfeitRequest: null, matchIntroPending: false, seatPlayers: [], tally: freshTally(), turnDeadline: null, disconnectedSeat: null, reconnectDeadline: null, }); }, }; }); /** * True when the player has a running match that hasn't finished — used to enforce * "one game at a time": entry points should resume this instead of starting another. */ export function hasActiveMatch(): boolean { const s = useGameStore.getState(); return s.started && s.game.phase !== "match-over"; }