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
+44
View File
@@ -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>
);
}
+19 -1
View File
@@ -25,11 +25,29 @@ export function GameScreen() {
const [reward, setReward] = useState<RewardResult | null>(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();
}}
/>
)}