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",