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:
@@ -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