Resume: exiting a match keeps it alive instead of destroying it
CI/CD / CI - API (dotnet build + engine sim) (push) Failing after 1m40s
CI/CD / CI - Web (tsc + next build) (push) Failing after 1m19s
CI/CD / Deploy - local stack (db + server + web) (push) Has been skipped

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:
soroush.asadi
2026-06-04 20:58:05 +03:30
parent a1c2cc0889
commit d66208e39e
5 changed files with 144 additions and 2 deletions
+43 -1
View File
@@ -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)}
<DailyRewardModal />
<NotificationToaster />
<ResumeGameBar />
<CapacitorBack />
{loading && null}
</>