2026-06-04 10:11:00 +03:30
|
|
|
|
"use client";
|
|
|
|
|
|
|
2026-06-04 22:47:36 +03:30
|
|
|
|
import { motion } from "framer-motion";
|
2026-06-04 10:11:00 +03:30
|
|
|
|
import { useEffect, useRef, useState } from "react";
|
|
|
|
|
|
import { GameTable } from "@/components/GameTable";
|
|
|
|
|
|
import { PostMatchRewardsModal } from "@/components/online/PostMatchRewardsModal";
|
|
|
|
|
|
import { useGameStore } from "@/lib/game-store";
|
|
|
|
|
|
import { useSessionStore } from "@/lib/session-store";
|
|
|
|
|
|
import { useUIStore } from "@/lib/ui-store";
|
2026-06-04 22:47:36 +03:30
|
|
|
|
import { useI18n } from "@/lib/i18n";
|
2026-06-04 10:11:00 +03:30
|
|
|
|
import { getService } from "@/lib/online/service";
|
2026-06-04 15:52:06 +03:30
|
|
|
|
import { pushNotification } from "@/lib/notification-store";
|
2026-06-06 18:39:24 +03:30
|
|
|
|
import { celebrate } from "@/lib/celebration-store";
|
2026-06-04 10:11:00 +03:30
|
|
|
|
import { MatchSummary, RewardResult } from "@/lib/online/types";
|
|
|
|
|
|
|
|
|
|
|
|
export function GameScreen() {
|
|
|
|
|
|
const game = useGameStore((s) => s.game);
|
|
|
|
|
|
const mode = useGameStore((s) => s.mode);
|
2026-06-04 17:32:47 +03:30
|
|
|
|
const live = useGameStore((s) => s.live);
|
|
|
|
|
|
const serverReward = useGameStore((s) => s.serverReward);
|
2026-06-04 10:11:00 +03:30
|
|
|
|
const tally = useGameStore((s) => s.tally);
|
|
|
|
|
|
const meta = useGameStore((s) => s.matchMeta);
|
|
|
|
|
|
const reset = useGameStore((s) => s.reset);
|
2026-06-04 22:47:36 +03:30
|
|
|
|
const forfeited = useGameStore((s) => s.forfeited);
|
|
|
|
|
|
const forfeitRequest = useGameStore((s) => s.forfeitRequest);
|
|
|
|
|
|
const respondForfeit = useGameStore((s) => s.respondForfeit);
|
2026-06-04 10:11:00 +03:30
|
|
|
|
const returnTo = useUIStore((s) => s.returnTo);
|
|
|
|
|
|
const go = useUIStore((s) => s.go);
|
|
|
|
|
|
const refreshProfile = useSessionStore((s) => s.refreshProfile);
|
2026-06-04 22:47:36 +03:30
|
|
|
|
const { t } = useI18n();
|
2026-06-04 10:11:00 +03:30
|
|
|
|
|
|
|
|
|
|
const [reward, setReward] = useState<RewardResult | null>(null);
|
|
|
|
|
|
const submitted = useRef(false);
|
|
|
|
|
|
|
2026-06-04 20:58:05 +03:30
|
|
|
|
// 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.
|
2026-06-04 10:11:00 +03:30
|
|
|
|
const exit = () => {
|
2026-06-04 20:58:05 +03:30
|
|
|
|
if (useGameStore.getState().game.phase === "match-over") reset();
|
|
|
|
|
|
go(returnTo);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// Match truly finished (reward dismissed): tear the match down.
|
|
|
|
|
|
const finish = () => {
|
2026-06-04 10:11:00 +03:30
|
|
|
|
reset();
|
|
|
|
|
|
go(returnTo);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-06-04 20:58:05 +03:30
|
|
|
|
// 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();
|
|
|
|
|
|
};
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
2026-06-04 17:32:47 +03:30
|
|
|
|
const notifyAchievements = (r: RewardResult) => {
|
|
|
|
|
|
for (const a of r.newAchievements)
|
|
|
|
|
|
pushNotification({
|
|
|
|
|
|
kind: "achievement",
|
|
|
|
|
|
titleFa: "دستاورد جدید",
|
|
|
|
|
|
titleEn: "New achievement",
|
|
|
|
|
|
bodyFa: a.nameFa,
|
|
|
|
|
|
bodyEn: a.nameEn,
|
|
|
|
|
|
icon: a.icon,
|
|
|
|
|
|
});
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-06-06 18:39:24 +03:30
|
|
|
|
// Splashy celebration overlay for every achievement / level-up earned in the
|
|
|
|
|
|
// match — fired once the post-match reward summary is dismissed so each unlock
|
|
|
|
|
|
// gets its own animated moment (queued by the celebration store).
|
|
|
|
|
|
const celebrateRewards = (r: RewardResult | null) => {
|
|
|
|
|
|
if (!r) return;
|
|
|
|
|
|
if (r.leveledUp) {
|
|
|
|
|
|
celebrate({
|
|
|
|
|
|
variant: "xp",
|
|
|
|
|
|
icon: "🎚️",
|
|
|
|
|
|
title: t("reward.levelUp"),
|
|
|
|
|
|
levelBefore: r.levelBefore,
|
|
|
|
|
|
levelAfter: r.levelAfter,
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
for (const a of r.newAchievements) {
|
|
|
|
|
|
celebrate({
|
|
|
|
|
|
variant: "purchase",
|
|
|
|
|
|
icon: a.icon,
|
|
|
|
|
|
title: t("reward.newAchievement"),
|
|
|
|
|
|
achievements: [a],
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-06-04 17:32:47 +03:30
|
|
|
|
// Client-run games (private rooms / casual): submit the result to the server.
|
2026-06-04 10:11:00 +03:30
|
|
|
|
useEffect(() => {
|
2026-06-04 17:32:47 +03:30
|
|
|
|
if (!live && mode === "online" && game.phase === "match-over" && !submitted.current) {
|
2026-06-04 10:11:00 +03:30
|
|
|
|
submitted.current = true;
|
|
|
|
|
|
const summary: MatchSummary = {
|
|
|
|
|
|
ranked: meta.ranked,
|
|
|
|
|
|
stake: meta.stake,
|
|
|
|
|
|
won: game.matchWinner === 0,
|
|
|
|
|
|
kotFor: tally.kotFor,
|
2026-06-05 10:40:14 +03:30
|
|
|
|
kotAgainst: tally.kotAgainst,
|
2026-06-04 10:11:00 +03:30
|
|
|
|
tricksWon: tally.tricksTeam0,
|
|
|
|
|
|
rounds: game.matchScore[0] + game.matchScore[1],
|
|
|
|
|
|
trump: game.trump,
|
2026-06-04 21:47:38 +03:30
|
|
|
|
// shutout = you won and the opponent never scored a round (e.g. 7–0)
|
2026-06-05 10:40:14 +03:30
|
|
|
|
shutout: !forfeited && game.matchWinner === 0 && game.matchScore[1] === 0,
|
2026-06-04 22:47:36 +03:30
|
|
|
|
hakemRounds: tally.hakemRounds,
|
|
|
|
|
|
roundsWon: game.matchScore[0],
|
2026-06-05 10:40:14 +03:30
|
|
|
|
forfeit: forfeited,
|
2026-06-04 10:11:00 +03:30
|
|
|
|
};
|
|
|
|
|
|
getService()
|
|
|
|
|
|
.submitMatchResult(summary)
|
|
|
|
|
|
.then((r) => {
|
|
|
|
|
|
setReward(r);
|
|
|
|
|
|
refreshProfile();
|
2026-06-04 17:32:47 +03:30
|
|
|
|
notifyAchievements(r);
|
2026-06-04 10:11:00 +03:30
|
|
|
|
});
|
|
|
|
|
|
}
|
2026-06-04 22:47:36 +03:30
|
|
|
|
}, [live, mode, game.phase, game.matchWinner, game.matchScore, game.trump, meta, tally, forfeited, refreshProfile]);
|
2026-06-04 17:32:47 +03:30
|
|
|
|
|
|
|
|
|
|
// Server-run ranked games: the reward arrives via the hub.
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (live && serverReward && !submitted.current) {
|
|
|
|
|
|
submitted.current = true;
|
|
|
|
|
|
setReward(serverReward);
|
|
|
|
|
|
refreshProfile();
|
|
|
|
|
|
notifyAchievements(serverReward);
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [live, serverReward, refreshProfile]);
|
2026-06-04 10:11:00 +03:30
|
|
|
|
|
2026-06-04 22:47:36 +03:30
|
|
|
|
const canForfeit = game.phase !== "match-over" && !forfeited;
|
|
|
|
|
|
|
2026-06-04 10:11:00 +03:30
|
|
|
|
return (
|
|
|
|
|
|
<>
|
2026-06-04 22:47:36 +03:30
|
|
|
|
<GameTable
|
|
|
|
|
|
onExit={exit}
|
|
|
|
|
|
onForfeit={canForfeit ? () => useGameStore.getState().forfeit() : undefined}
|
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
|
|
{/* teammate asked to forfeit — confirm or decline */}
|
|
|
|
|
|
{forfeitRequest && (
|
|
|
|
|
|
<motion.div
|
|
|
|
|
|
initial={{ opacity: 0 }}
|
|
|
|
|
|
animate={{ opacity: 1 }}
|
|
|
|
|
|
className="fixed inset-0 z-[70] flex items-center justify-center bg-navy-950/80 backdrop-blur-sm p-5"
|
|
|
|
|
|
>
|
|
|
|
|
|
<motion.div
|
|
|
|
|
|
initial={{ scale: 0.85, y: 20 }}
|
|
|
|
|
|
animate={{ scale: 1, y: 0 }}
|
|
|
|
|
|
className="glass rounded-3xl p-6 w-full max-w-xs text-center"
|
|
|
|
|
|
>
|
|
|
|
|
|
<div className="text-4xl mb-2">🏳️</div>
|
|
|
|
|
|
<h2 className="gold-text text-xl font-black">{t("forfeit.title")}</h2>
|
|
|
|
|
|
<p className="text-cream/70 text-sm mt-2">
|
|
|
|
|
|
{t("forfeit.teammateAsks").replace("{name}", forfeitRequest.byName)}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
<p className="text-cream/45 text-xs mt-1">{t("forfeit.rule")}</p>
|
|
|
|
|
|
<div className="flex gap-2 mt-5">
|
|
|
|
|
|
<button onClick={() => respondForfeit(true)} className="flex-1 rounded-xl py-3 bg-rose-500/80 text-white font-bold">
|
|
|
|
|
|
{t("forfeit.confirm")}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button onClick={() => respondForfeit(false)} className="flex-1 btn-gold rounded-xl py-3">
|
|
|
|
|
|
{t("forfeit.keepPlaying")}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</motion.div>
|
|
|
|
|
|
</motion.div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
2026-06-04 10:11:00 +03:30
|
|
|
|
{reward && (
|
|
|
|
|
|
<PostMatchRewardsModal
|
|
|
|
|
|
reward={reward}
|
|
|
|
|
|
won={game.matchWinner === 0}
|
|
|
|
|
|
onClose={() => {
|
2026-06-06 18:39:24 +03:30
|
|
|
|
const r = reward;
|
2026-06-04 10:11:00 +03:30
|
|
|
|
setReward(null);
|
2026-06-06 18:39:24 +03:30
|
|
|
|
celebrateRewards(r);
|
2026-06-04 20:58:05 +03:30
|
|
|
|
finish();
|
2026-06-04 10:11:00 +03:30
|
|
|
|
}}
|
|
|
|
|
|
/>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|