From d66208e39e06db06db14df8fd8b8e8535d077ed0 Mon Sep 17 00:00:00 2001 From: "soroush.asadi" Date: Thu, 4 Jun 2026 20:58:05 +0330 Subject: [PATCH] Resume: exiting a match keeps it alive instead of destroying it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Leaving the table (back button, browser/hardware back) no longer resets the game — it minimizes it and stays resumable: - game-store: add `paused` + minimize()/resume(). Single-player (AI) matches pause their local timers so nothing happens while away; live (server-run) matches keep streaming via the still-active subscription (the .NET GameRoom already runs the match to completion and re-broadcasts state on reconnect). - GameScreen: an unmount effect minimizes any in-progress match no matter how you leave; only a finished match (reward dismissed) tears down. - ResumeGameBar: floating "return to game" pill shown from any screen while a match is alive, or while a finished match still has an unseen reward. - page.tsx: after a full reload, re-enter live mode (minimized) when the server re-broadcasts state, and notify when a match you left finishes while away. Verified: tsc + next build clean; web image rebuilt and serving on :1500. Co-Authored-By: Claude Opus 4.8 --- src/app/page.tsx | 44 ++++++++++++++++++++++++- src/components/online/ResumeGameBar.tsx | 44 +++++++++++++++++++++++++ src/components/screens/GameScreen.tsx | 20 ++++++++++- src/lib/game-store.ts | 28 ++++++++++++++++ src/lib/i18n.tsx | 10 ++++++ 5 files changed, 144 insertions(+), 2 deletions(-) create mode 100644 src/components/online/ResumeGameBar.tsx diff --git a/src/app/page.tsx b/src/app/page.tsx index bb4ab67..64fc7f1 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -16,6 +16,7 @@ import { NotificationsScreen } from "@/components/screens/NotificationsScreen"; import { AuthScreen } from "@/components/screens/AuthScreen"; import { DailyRewardModal } from "@/components/online/DailyRewardModal"; import { NotificationToaster } from "@/components/online/NotificationToaster"; +import { ResumeGameBar } from "@/components/online/ResumeGameBar"; import { CapacitorBack } from "@/components/CapacitorBack"; import { useSessionStore } from "@/lib/session-store"; import { useGameStore } from "@/lib/game-store"; @@ -85,12 +86,52 @@ export default function Page() { }) .catch(() => {}); + // Resume an in-progress server match after a full reload: the server + // re-broadcasts state on (re)connect — if we're not already in a game and + // not mid-matchmaking, re-enter live mode (minimized) so the resume pill shows. + const svc = getService(); + let resumeUnsub: (() => void) | undefined; + let rewardUnsub: (() => void) | undefined; + if (svc.live) { + resumeUnsub = svc.onState((s) => { + const gs = useGameStore.getState(); + const scr = useUIStore.getState().screen; + if ( + !gs.started && + scr !== "matchmaking" && + scr !== "game" && + s.matchWinner == null && + s.phase !== "match-over" + ) { + gs.enterServerMatch(svc); + gs.applyServerState(s); + gs.minimize(); + } + }); + // Nudge the player when a match they left finishes while they're away. + rewardUnsub = svc.onReward(() => { + if (useUIStore.getState().screen !== "game") + pushNotification({ + kind: "system", + titleFa: "بازی به پایان رسید", + titleEn: "Match ended", + bodyFa: "نتیجه و جایزه را ببینید", + bodyEn: "See the result and reward", + icon: "🏆", + }); + }); + } + const onPop = (e: PopStateEvent) => { const raw = ((e.state?.screen as Screen) ?? screenFromHash()); useUIStore.getState().syncFromPop(resolveScreen(raw)); }; window.addEventListener("popstate", onPop); - return () => window.removeEventListener("popstate", onPop); + return () => { + window.removeEventListener("popstate", onPop); + resumeUnsub?.(); + rewardUnsub?.(); + }; }, [init]); return ( @@ -98,6 +139,7 @@ export default function Page() { {renderScreen(screen)} + {loading && null} diff --git a/src/components/online/ResumeGameBar.tsx b/src/components/online/ResumeGameBar.tsx new file mode 100644 index 0000000..95c8ee9 --- /dev/null +++ b/src/components/online/ResumeGameBar.tsx @@ -0,0 +1,44 @@ +"use client"; + +import { Play } from "lucide-react"; +import { useGameStore } from "@/lib/game-store"; +import { useUIStore } from "@/lib/ui-store"; +import { useI18n } from "@/lib/i18n"; + +/** + * Floating "return to game" pill, shown whenever a match is still alive but the + * player has navigated away from the table. Tapping it re-arms the match and + * jumps back to the game screen. Hidden while on the table or once the match ends. + */ +export function ResumeGameBar() { + const { t } = useI18n(); + const started = useGameStore((s) => s.started); + const phase = useGameStore((s) => s.game.phase); + const serverReward = useGameStore((s) => s.serverReward); + const screen = useUIStore((s) => s.screen); + + // Show while a match is in progress, OR after it ended with a reward still + // unseen (the match finished while the player was away from the table). + const visible = + started && screen !== "game" && (phase !== "match-over" || serverReward != null); + if (!visible) return null; + + const onResume = () => { + useGameStore.getState().resume(); + useUIStore.getState().goGame(useUIStore.getState().screen); + }; + + return ( +
+ +
+ ); +} diff --git a/src/components/screens/GameScreen.tsx b/src/components/screens/GameScreen.tsx index bb51828..64c4822 100644 --- a/src/components/screens/GameScreen.tsx +++ b/src/components/screens/GameScreen.tsx @@ -25,11 +25,29 @@ export function GameScreen() { const [reward, setReward] = useState(null); const submitted = useRef(false); + // Leaving the table (back button, browser/hardware back) keeps the match alive + // & resumable — pause is handled by the unmount effect below. A finished match + // is torn down instead. const exit = () => { + if (useGameStore.getState().game.phase === "match-over") reset(); + go(returnTo); + }; + + // Match truly finished (reward dismissed): tear the match down. + const finish = () => { reset(); go(returnTo); }; + // Any way the table unmounts (exit button, hardware/browser back), keep an + // in-progress match alive and resumable instead of letting it run unattended. + useEffect(() => { + return () => { + const gs = useGameStore.getState(); + if (gs.started && gs.game.phase !== "match-over") gs.minimize(); + }; + }, []); + const notifyAchievements = (r: RewardResult) => { for (const a of r.newAchievements) pushNotification({ @@ -85,7 +103,7 @@ export function GameScreen() { won={game.matchWinner === 0} onClose={() => { setReward(null); - exit(); + finish(); }} /> )} diff --git a/src/lib/game-store.ts b/src/lib/game-store.ts index 0d664c6..8623aa4 100644 --- a/src/lib/game-store.ts +++ b/src/lib/game-store.ts @@ -78,6 +78,8 @@ interface GameStore { 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; newMatch: (settings: GameSettings) => void; newOnlineMatch: (cfg: OnlineMatchConfig) => void; @@ -85,6 +87,10 @@ interface GameStore { 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; reset: () => void; } @@ -293,6 +299,7 @@ export const useGameStore = create((set, get) => { reconnectDeadline: null, live: false, serverReward: null, + paused: false, newMatch: (settings) => { clearPending(); @@ -302,6 +309,7 @@ export const useGameStore = create((set, get) => { game: selectHakem(initial), started: true, mode: "ai", + paused: false, matchMeta: { ranked: false, stake: 0 }, tally: freshTally(), turnDeadline: null, @@ -325,6 +333,7 @@ export const useGameStore = create((set, get) => { game: selectHakem(initial), started: true, mode: "online", + paused: false, matchMeta: { ranked: cfg.ranked, stake: cfg.stake }, tally: freshTally(), turnDeadline: null, @@ -353,6 +362,7 @@ export const useGameStore = create((set, get) => { mode: "online", live: true, serverReward: null, + paused: false, matchMeta: { ranked: true, stake: 0 }, tally: freshTally(), turnDeadline: null, @@ -418,6 +428,23 @@ export const useGameStore = create((set, get) => { 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(); + }, + reset: () => { clearPending(); if (liveUnsub) { @@ -435,6 +462,7 @@ export const useGameStore = create((set, get) => { mode: "ai", live: false, serverReward: null, + paused: false, seatPlayers: [], tally: freshTally(), turnDeadline: null, diff --git a/src/lib/i18n.tsx b/src/lib/i18n.tsx index 6b6b885..2ebd21d 100644 --- a/src/lib/i18n.tsx +++ b/src/lib/i18n.tsx @@ -29,6 +29,11 @@ const fa: Dict = { "home.lang": "English", "home.onlineCount": "{n} نفر آنلاین", + "resume.title": "بازی در جریان", + "resume.cta": "بازگشت به بازی", + "resume.matchEnded": "بازی به پایان رسید", + "resume.matchEndedBody": "نتیجه و جایزه را ببینید", + "seat.you": "شما", "team.us": "ما", "team.them": "حریف", @@ -270,6 +275,11 @@ const en: Dict = { "home.lang": "فارسی", "home.onlineCount": "{n} players online", + "resume.title": "Game in progress", + "resume.cta": "Return to game", + "resume.matchEnded": "Match ended", + "resume.matchEndedBody": "See the result and reward", + "seat.you": "You", "team.us": "Us", "team.them": "Them",