Resume: exiting a match keeps it alive instead of destroying it
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 <noreply@anthropic.com>
This commit is contained in:
+43
-1
@@ -16,6 +16,7 @@ import { NotificationsScreen } from "@/components/screens/NotificationsScreen";
|
|||||||
import { AuthScreen } from "@/components/screens/AuthScreen";
|
import { AuthScreen } from "@/components/screens/AuthScreen";
|
||||||
import { DailyRewardModal } from "@/components/online/DailyRewardModal";
|
import { DailyRewardModal } from "@/components/online/DailyRewardModal";
|
||||||
import { NotificationToaster } from "@/components/online/NotificationToaster";
|
import { NotificationToaster } from "@/components/online/NotificationToaster";
|
||||||
|
import { ResumeGameBar } from "@/components/online/ResumeGameBar";
|
||||||
import { CapacitorBack } from "@/components/CapacitorBack";
|
import { CapacitorBack } from "@/components/CapacitorBack";
|
||||||
import { useSessionStore } from "@/lib/session-store";
|
import { useSessionStore } from "@/lib/session-store";
|
||||||
import { useGameStore } from "@/lib/game-store";
|
import { useGameStore } from "@/lib/game-store";
|
||||||
@@ -85,12 +86,52 @@ export default function Page() {
|
|||||||
})
|
})
|
||||||
.catch(() => {});
|
.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 onPop = (e: PopStateEvent) => {
|
||||||
const raw = ((e.state?.screen as Screen) ?? screenFromHash());
|
const raw = ((e.state?.screen as Screen) ?? screenFromHash());
|
||||||
useUIStore.getState().syncFromPop(resolveScreen(raw));
|
useUIStore.getState().syncFromPop(resolveScreen(raw));
|
||||||
};
|
};
|
||||||
window.addEventListener("popstate", onPop);
|
window.addEventListener("popstate", onPop);
|
||||||
return () => window.removeEventListener("popstate", onPop);
|
return () => {
|
||||||
|
window.removeEventListener("popstate", onPop);
|
||||||
|
resumeUnsub?.();
|
||||||
|
rewardUnsub?.();
|
||||||
|
};
|
||||||
}, [init]);
|
}, [init]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -98,6 +139,7 @@ export default function Page() {
|
|||||||
{renderScreen(screen)}
|
{renderScreen(screen)}
|
||||||
<DailyRewardModal />
|
<DailyRewardModal />
|
||||||
<NotificationToaster />
|
<NotificationToaster />
|
||||||
|
<ResumeGameBar />
|
||||||
<CapacitorBack />
|
<CapacitorBack />
|
||||||
{loading && null}
|
{loading && null}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<div className="fixed inset-x-0 bottom-4 z-[60] flex justify-center px-4 pointer-events-none">
|
||||||
|
<button
|
||||||
|
onClick={onResume}
|
||||||
|
className="pointer-events-auto btn-gold flex items-center gap-2 rounded-full px-5 py-3 shadow-xl shadow-black/40 animate-pulse"
|
||||||
|
>
|
||||||
|
<span className="grid size-7 place-items-center rounded-full bg-black/20">
|
||||||
|
<Play className="size-4" fill="currentColor" />
|
||||||
|
</span>
|
||||||
|
<span className="font-bold">{t("resume.cta")}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -25,11 +25,29 @@ export function GameScreen() {
|
|||||||
const [reward, setReward] = useState<RewardResult | null>(null);
|
const [reward, setReward] = useState<RewardResult | null>(null);
|
||||||
const submitted = useRef(false);
|
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 = () => {
|
const exit = () => {
|
||||||
|
if (useGameStore.getState().game.phase === "match-over") reset();
|
||||||
|
go(returnTo);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Match truly finished (reward dismissed): tear the match down.
|
||||||
|
const finish = () => {
|
||||||
reset();
|
reset();
|
||||||
go(returnTo);
|
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) => {
|
const notifyAchievements = (r: RewardResult) => {
|
||||||
for (const a of r.newAchievements)
|
for (const a of r.newAchievements)
|
||||||
pushNotification({
|
pushNotification({
|
||||||
@@ -85,7 +103,7 @@ export function GameScreen() {
|
|||||||
won={game.matchWinner === 0}
|
won={game.matchWinner === 0}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
setReward(null);
|
setReward(null);
|
||||||
exit();
|
finish();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -78,6 +78,8 @@ interface GameStore {
|
|||||||
live: boolean;
|
live: boolean;
|
||||||
/** reward pushed by the server for a server-run (ranked) match. */
|
/** reward pushed by the server for a server-run (ranked) match. */
|
||||||
serverReward: RewardResult | null;
|
serverReward: RewardResult | null;
|
||||||
|
/** the match is still alive but the player navigated away (resumable). */
|
||||||
|
paused: boolean;
|
||||||
|
|
||||||
newMatch: (settings: GameSettings) => void;
|
newMatch: (settings: GameSettings) => void;
|
||||||
newOnlineMatch: (cfg: OnlineMatchConfig) => void;
|
newOnlineMatch: (cfg: OnlineMatchConfig) => void;
|
||||||
@@ -85,6 +87,10 @@ interface GameStore {
|
|||||||
applyServerState: (s: ServerGameState) => void;
|
applyServerState: (s: ServerGameState) => void;
|
||||||
chooseTrump: (suit: Suit) => void;
|
chooseTrump: (suit: Suit) => void;
|
||||||
playHuman: (card: Card) => 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;
|
reset: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -293,6 +299,7 @@ export const useGameStore = create<GameStore>((set, get) => {
|
|||||||
reconnectDeadline: null,
|
reconnectDeadline: null,
|
||||||
live: false,
|
live: false,
|
||||||
serverReward: null,
|
serverReward: null,
|
||||||
|
paused: false,
|
||||||
|
|
||||||
newMatch: (settings) => {
|
newMatch: (settings) => {
|
||||||
clearPending();
|
clearPending();
|
||||||
@@ -302,6 +309,7 @@ export const useGameStore = create<GameStore>((set, get) => {
|
|||||||
game: selectHakem(initial),
|
game: selectHakem(initial),
|
||||||
started: true,
|
started: true,
|
||||||
mode: "ai",
|
mode: "ai",
|
||||||
|
paused: false,
|
||||||
matchMeta: { ranked: false, stake: 0 },
|
matchMeta: { ranked: false, stake: 0 },
|
||||||
tally: freshTally(),
|
tally: freshTally(),
|
||||||
turnDeadline: null,
|
turnDeadline: null,
|
||||||
@@ -325,6 +333,7 @@ export const useGameStore = create<GameStore>((set, get) => {
|
|||||||
game: selectHakem(initial),
|
game: selectHakem(initial),
|
||||||
started: true,
|
started: true,
|
||||||
mode: "online",
|
mode: "online",
|
||||||
|
paused: false,
|
||||||
matchMeta: { ranked: cfg.ranked, stake: cfg.stake },
|
matchMeta: { ranked: cfg.ranked, stake: cfg.stake },
|
||||||
tally: freshTally(),
|
tally: freshTally(),
|
||||||
turnDeadline: null,
|
turnDeadline: null,
|
||||||
@@ -353,6 +362,7 @@ export const useGameStore = create<GameStore>((set, get) => {
|
|||||||
mode: "online",
|
mode: "online",
|
||||||
live: true,
|
live: true,
|
||||||
serverReward: null,
|
serverReward: null,
|
||||||
|
paused: false,
|
||||||
matchMeta: { ranked: true, stake: 0 },
|
matchMeta: { ranked: true, stake: 0 },
|
||||||
tally: freshTally(),
|
tally: freshTally(),
|
||||||
turnDeadline: null,
|
turnDeadline: null,
|
||||||
@@ -418,6 +428,23 @@ export const useGameStore = create<GameStore>((set, get) => {
|
|||||||
scheduleAuto();
|
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: () => {
|
reset: () => {
|
||||||
clearPending();
|
clearPending();
|
||||||
if (liveUnsub) {
|
if (liveUnsub) {
|
||||||
@@ -435,6 +462,7 @@ export const useGameStore = create<GameStore>((set, get) => {
|
|||||||
mode: "ai",
|
mode: "ai",
|
||||||
live: false,
|
live: false,
|
||||||
serverReward: null,
|
serverReward: null,
|
||||||
|
paused: false,
|
||||||
seatPlayers: [],
|
seatPlayers: [],
|
||||||
tally: freshTally(),
|
tally: freshTally(),
|
||||||
turnDeadline: null,
|
turnDeadline: null,
|
||||||
|
|||||||
@@ -29,6 +29,11 @@ const fa: Dict = {
|
|||||||
"home.lang": "English",
|
"home.lang": "English",
|
||||||
"home.onlineCount": "{n} نفر آنلاین",
|
"home.onlineCount": "{n} نفر آنلاین",
|
||||||
|
|
||||||
|
"resume.title": "بازی در جریان",
|
||||||
|
"resume.cta": "بازگشت به بازی",
|
||||||
|
"resume.matchEnded": "بازی به پایان رسید",
|
||||||
|
"resume.matchEndedBody": "نتیجه و جایزه را ببینید",
|
||||||
|
|
||||||
"seat.you": "شما",
|
"seat.you": "شما",
|
||||||
"team.us": "ما",
|
"team.us": "ما",
|
||||||
"team.them": "حریف",
|
"team.them": "حریف",
|
||||||
@@ -270,6 +275,11 @@ const en: Dict = {
|
|||||||
"home.lang": "فارسی",
|
"home.lang": "فارسی",
|
||||||
"home.onlineCount": "{n} players online",
|
"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",
|
"seat.you": "You",
|
||||||
"team.us": "Us",
|
"team.us": "Us",
|
||||||
"team.them": "Them",
|
"team.them": "Them",
|
||||||
|
|||||||
Reference in New Issue
Block a user