100+ achievements, forfeit, leagues floor, bot humanize, 95k starter
Achievements: generator-driven, now 100+ across 7 categories (added Rulership) mirrored client + server with identical ids/goals/coins. New tracked stats: hakemRounds (be the hakem — incl. "7× Hakem"), roundsWon, plus losses metric. Custom achievement-only sticker packs (Rulership 👑, Firestorm 🔥) with new inline-SVG art (crown-gold, seven-zip, streak-fire), unlocked by hakem_7 / streak_10. Server GameRoom tallies hakem rounds per seat + rounds won per team; client tallies the same for vs-computer/private games (dealId-deduped). Forfeit (surrender): a player can request forfeit; if the teammate is a bot it auto-confirms, otherwise the human teammate gets a confirm/decline prompt (20s timeout). Result: forfeiting with ≥1 round won = normal loss; 0 rounds = Kot. Wired client↔server over the hub (RequestForfeit/ConfirmForfeit/DeclineForfeit + "forfeit" event); offline/vs-computer ends immediately in the store. Flag button + confirm dialogs in the table. Online count: never shows below 50 — live service floors the real count with a drifting believable number (mock base lowered to ~50–170). Matchmaking: real players get a longer priority window (9s) before bots fill; bots now occasionally react after winning a trick (humanize). Coins: starter pack is 95,000 Toman (50k coins); packs rescaled up (server + mock). Verified: dotnet build + tsc + next build clean; sim unlocks 57 achievements/500 matches; live server: starter=95000, a 7-hakem win unlocks hakem_7 + wins_1 with hakemRounds/roundsWon persisted. Images rebuilt on :1500/:1505. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { Crown, LogOut, SmilePlus, Volume2, VolumeX, WifiOff } from "lucide-react";
|
||||
import { Crown, Flag, LogOut, SmilePlus, Volume2, VolumeX, WifiOff } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { TURN_MS, useGameStore } from "@/lib/game-store";
|
||||
import { useSoundStore } from "@/lib/sound-store";
|
||||
@@ -46,11 +46,15 @@ function useCardSkins() {
|
||||
};
|
||||
}
|
||||
|
||||
export function GameTable({ onExit }: { onExit?: () => void } = {}) {
|
||||
export function GameTable({
|
||||
onExit,
|
||||
onForfeit,
|
||||
}: { onExit?: () => void; onForfeit?: () => void } = {}) {
|
||||
const game = useGameStore((s) => s.game);
|
||||
const reset = useGameStore((s) => s.reset);
|
||||
const mode = useGameStore((s) => s.mode);
|
||||
const { t } = useI18n();
|
||||
const [askFf, setAskFf] = useState(false);
|
||||
|
||||
const sfx = useSoundStore((s) => s.sfx);
|
||||
const music = useSoundStore((s) => s.music);
|
||||
@@ -84,6 +88,15 @@ export function GameTable({ onExit }: { onExit?: () => void } = {}) {
|
||||
<Volume2 className="size-4 text-gold-400" />
|
||||
)}
|
||||
</button>
|
||||
{onForfeit && (
|
||||
<button
|
||||
onClick={() => setAskFf(true)}
|
||||
className="glass rounded-full p-2.5 hover:bg-navy-800 transition"
|
||||
title={t("forfeit.title")}
|
||||
>
|
||||
<Flag className="size-4 text-rose-300/90" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={exit}
|
||||
className="glass rounded-full p-2.5 hover:bg-navy-800 transition"
|
||||
@@ -94,6 +107,39 @@ export function GameTable({ onExit }: { onExit?: () => void } = {}) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* forfeit confirm (requester) */}
|
||||
<AnimatePresence>
|
||||
{askFf && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 z-[70] flex items-center justify-center bg-navy-950/80 backdrop-blur-sm p-5"
|
||||
>
|
||||
<div 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.ask")}</p>
|
||||
<p className="text-cream/45 text-xs mt-1">{t("forfeit.rule")}</p>
|
||||
<div className="flex gap-2 mt-5">
|
||||
<button
|
||||
onClick={() => {
|
||||
setAskFf(false);
|
||||
onForfeit?.();
|
||||
}}
|
||||
className="flex-1 rounded-xl py-3 bg-rose-500/80 text-white font-bold"
|
||||
>
|
||||
{t("forfeit.confirm")}
|
||||
</button>
|
||||
<button onClick={() => setAskFf(false)} className="flex-1 btn-gold rounded-xl py-3">
|
||||
{t("forfeit.keepPlaying")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Felt table */}
|
||||
<div className="absolute inset-0 flex items-center justify-center p-4">
|
||||
<div className="felt relative w-[min(94vw,1100px)] h-[min(82vh,720px)] rounded-[42%]">
|
||||
|
||||
@@ -187,6 +187,43 @@ const STICKERS: Record<string, React.ReactNode> = {
|
||||
</text>
|
||||
</>
|
||||
),
|
||||
|
||||
/* ------------------- custom (achievement-unlocked) ------------------ */
|
||||
"crown-gold": (
|
||||
<>
|
||||
<defs>
|
||||
<linearGradient id="cg" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stopColor="#ffe488" />
|
||||
<stop offset="1" stopColor="#d4a017" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<circle cx="50" cy="50" r="44" fill="#2a1a4d" stroke="#d4af37" strokeWidth="2" />
|
||||
<path d="M24 64 L24 40 L36 52 L50 32 L64 52 L76 40 L76 64 Z" fill="url(#cg)" stroke="#7a5a00" strokeWidth="2" strokeLinejoin="round" />
|
||||
<rect x="24" y="64" width="52" height="8" rx="2" fill="url(#cg)" stroke="#7a5a00" strokeWidth="2" />
|
||||
<circle cx="50" cy="30" r="4" fill="#ff5fa2" />
|
||||
<circle cx="24" cy="40" r="3" fill="#6aa6ff" />
|
||||
<circle cx="76" cy="40" r="3" fill="#6aa6ff" />
|
||||
</>
|
||||
),
|
||||
"seven-zip": (
|
||||
<>
|
||||
<circle cx="50" cy="50" r="44" fill="#0d6b5e" stroke="#2dd4bf" strokeWidth="2" />
|
||||
<text x="50" y="63" textAnchor="middle" fontFamily="Arial, sans-serif" fontWeight="900" fontSize="34" fill="#effdf8">7–0</text>
|
||||
</>
|
||||
),
|
||||
"streak-fire": (
|
||||
<>
|
||||
<defs>
|
||||
<linearGradient id="sf" x1="0" y1="1" x2="0" y2="0">
|
||||
<stop offset="0" stopColor="#ff3d00" />
|
||||
<stop offset="0.6" stopColor="#ff9100" />
|
||||
<stop offset="1" stopColor="#ffea00" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<circle cx="50" cy="50" r="44" fill="#2b0a0a" stroke="#ff6b35" strokeWidth="2" />
|
||||
<path d="M50 18 C58 34 70 38 66 56 C64 70 54 78 50 78 C46 78 34 72 34 56 C34 46 42 44 44 36 C50 42 48 50 52 52 C58 50 54 38 50 18 Z" fill="url(#sf)" />
|
||||
</>
|
||||
),
|
||||
};
|
||||
|
||||
export const STICKER_IDS = Object.keys(STICKERS);
|
||||
|
||||
@@ -77,7 +77,7 @@ export function BuyCoinsScreen() {
|
||||
>
|
||||
{p.tag && (
|
||||
<span className="absolute -top-2 rounded-full btn-gold text-[10px] font-bold px-2 py-0.5">
|
||||
{p.tag === "best" ? t("buy.best") : t("buy.popular")}
|
||||
{p.tag === "best" ? t("buy.best") : p.tag === "starter" ? t("buy.starter") : t("buy.popular")}
|
||||
</span>
|
||||
)}
|
||||
<Coins className="size-7 text-gold-400" />
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
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";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import { getService } from "@/lib/online/service";
|
||||
import { pushNotification } from "@/lib/notification-store";
|
||||
import { MatchSummary, RewardResult } from "@/lib/online/types";
|
||||
@@ -18,9 +20,13 @@ export function GameScreen() {
|
||||
const tally = useGameStore((s) => s.tally);
|
||||
const meta = useGameStore((s) => s.matchMeta);
|
||||
const reset = useGameStore((s) => s.reset);
|
||||
const forfeited = useGameStore((s) => s.forfeited);
|
||||
const forfeitRequest = useGameStore((s) => s.forfeitRequest);
|
||||
const respondForfeit = useGameStore((s) => s.respondForfeit);
|
||||
const returnTo = useUIStore((s) => s.returnTo);
|
||||
const go = useUIStore((s) => s.go);
|
||||
const refreshProfile = useSessionStore((s) => s.refreshProfile);
|
||||
const { t } = useI18n();
|
||||
|
||||
const [reward, setReward] = useState<RewardResult | null>(null);
|
||||
const submitted = useRef(false);
|
||||
@@ -69,12 +75,15 @@ export function GameScreen() {
|
||||
stake: meta.stake,
|
||||
won: game.matchWinner === 0,
|
||||
kotFor: tally.kotFor,
|
||||
kotAgainst: tally.kotAgainst,
|
||||
// Forfeiting with 0 rounds won = a Kot loss.
|
||||
kotAgainst: tally.kotAgainst || (forfeited && game.matchScore[0] === 0),
|
||||
tricksWon: tally.tricksTeam0,
|
||||
rounds: game.matchScore[0] + game.matchScore[1],
|
||||
trump: game.trump,
|
||||
// shutout = you won and the opponent never scored a round (e.g. 7–0)
|
||||
shutout: game.matchWinner === 0 && game.matchScore[1] === 0,
|
||||
hakemRounds: tally.hakemRounds,
|
||||
roundsWon: game.matchScore[0],
|
||||
};
|
||||
getService()
|
||||
.submitMatchResult(summary)
|
||||
@@ -84,7 +93,7 @@ export function GameScreen() {
|
||||
notifyAchievements(r);
|
||||
});
|
||||
}
|
||||
}, [live, mode, game.phase, game.matchWinner, game.matchScore, game.trump, meta, tally, refreshProfile]);
|
||||
}, [live, mode, game.phase, game.matchWinner, game.matchScore, game.trump, meta, tally, forfeited, refreshProfile]);
|
||||
|
||||
// Server-run ranked games: the reward arrives via the hub.
|
||||
useEffect(() => {
|
||||
@@ -96,9 +105,45 @@ export function GameScreen() {
|
||||
}
|
||||
}, [live, serverReward, refreshProfile]);
|
||||
|
||||
const canForfeit = game.phase !== "match-over" && !forfeited;
|
||||
|
||||
return (
|
||||
<>
|
||||
<GameTable onExit={exit} />
|
||||
<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>
|
||||
)}
|
||||
|
||||
{reward && (
|
||||
<PostMatchRewardsModal
|
||||
reward={reward}
|
||||
|
||||
Reference in New Issue
Block a user