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:
+68
-2
@@ -12,7 +12,7 @@ import {
|
||||
startNextRound,
|
||||
} from "./hokm/engine";
|
||||
import { Card, GameState, Phase, Player, Rank, RoundResult, Seat, Suit, Team } from "./hokm/types";
|
||||
import { avatarEmoji, RewardResult, ServerGameState } from "./online/types";
|
||||
import { avatarEmoji, ForfeitRequest, RewardResult, ServerGameState } from "./online/types";
|
||||
import type { OnlineService } from "./online/service";
|
||||
import { sound } from "./sound";
|
||||
|
||||
@@ -58,6 +58,7 @@ interface MatchTally {
|
||||
tricksTeam0: number;
|
||||
kotFor: boolean; // your team kot'd opponents at least once
|
||||
kotAgainst: boolean;
|
||||
hakemRounds: number; // rounds you (seat 0) were the hakem
|
||||
}
|
||||
|
||||
interface GameStore {
|
||||
@@ -80,6 +81,10 @@ interface GameStore {
|
||||
serverReward: RewardResult | null;
|
||||
/** the match is still alive but the player navigated away (resumable). */
|
||||
paused: boolean;
|
||||
/** you forfeited (surrendered) this match. */
|
||||
forfeited: boolean;
|
||||
/** a teammate is asking to forfeit and needs your confirmation. */
|
||||
forfeitRequest: ForfeitRequest | null;
|
||||
|
||||
newMatch: (settings: GameSettings) => void;
|
||||
newOnlineMatch: (cfg: OnlineMatchConfig) => void;
|
||||
@@ -91,6 +96,10 @@ interface GameStore {
|
||||
minimize: () => void;
|
||||
/** Return to a minimized match (re-arms local AI timers; live keeps streaming). */
|
||||
resume: () => void;
|
||||
/** Request to forfeit (surrender) the match. */
|
||||
forfeit: () => void;
|
||||
/** Respond to a teammate's forfeit request. */
|
||||
respondForfeit: (confirm: boolean) => void;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
@@ -99,6 +108,7 @@ const AI_AVATARS = ["🦊", "🦁", "🦉", "🐯"];
|
||||
let pending: ReturnType<typeof setTimeout> | null = null;
|
||||
let liveUnsub: (() => void) | null = null;
|
||||
let rewardUnsub: (() => void) | null = null;
|
||||
let forfeitUnsub: (() => void) | null = null;
|
||||
let liveSvc: OnlineService | null = null;
|
||||
function clearPending() {
|
||||
if (pending) {
|
||||
@@ -108,9 +118,12 @@ function clearPending() {
|
||||
}
|
||||
|
||||
function freshTally(): MatchTally {
|
||||
return { tricksTeam0: 0, kotFor: false, kotAgainst: false };
|
||||
return { tricksTeam0: 0, kotFor: false, kotAgainst: false, hakemRounds: 0 };
|
||||
}
|
||||
|
||||
/** Deals already counted toward the hakem tally (client-run games). */
|
||||
let countedHakemDeals = new Set<number>();
|
||||
|
||||
function mapCard(c: { suit: string; rank: number; id: string }): Card {
|
||||
return { suit: c.suit as Suit, rank: c.rank as Rank, id: c.id };
|
||||
}
|
||||
@@ -166,6 +179,7 @@ export const useGameStore = create<GameStore>((set, get) => {
|
||||
tricksTeam0: t.tricksTeam0 + result.tricks[0],
|
||||
kotFor: t.kotFor || (result.winningTeam === 0 && result.kot),
|
||||
kotAgainst: t.kotAgainst || (result.winningTeam === 1 && result.kot),
|
||||
hakemRounds: t.hakemRounds,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -195,6 +209,11 @@ export const useGameStore = create<GameStore>((set, get) => {
|
||||
|
||||
case "choosing-trump": {
|
||||
const hakem = g.hakem!;
|
||||
// Tally hakem rounds for you (seat 0) — once per deal.
|
||||
if (hakem === 0 && !countedHakemDeals.has(g.dealId)) {
|
||||
countedHakemDeals.add(g.dealId);
|
||||
set({ tally: { ...get().tally, hakemRounds: get().tally.hakemRounds + 1 } });
|
||||
}
|
||||
if (g.players[hakem].isHuman) {
|
||||
// human hakem: timed choice, system auto-picks on timeout
|
||||
set({ turnDeadline: Date.now() + TURN_MS, disconnectedSeat: null, reconnectDeadline: null });
|
||||
@@ -300,9 +319,12 @@ export const useGameStore = create<GameStore>((set, get) => {
|
||||
live: false,
|
||||
serverReward: null,
|
||||
paused: false,
|
||||
forfeited: false,
|
||||
forfeitRequest: null,
|
||||
|
||||
newMatch: (settings) => {
|
||||
clearPending();
|
||||
countedHakemDeals = new Set();
|
||||
sound.init();
|
||||
const initial = createInitialState(settings);
|
||||
set({
|
||||
@@ -310,6 +332,8 @@ export const useGameStore = create<GameStore>((set, get) => {
|
||||
started: true,
|
||||
mode: "ai",
|
||||
paused: false,
|
||||
forfeited: false,
|
||||
forfeitRequest: null,
|
||||
matchMeta: { ranked: false, stake: 0 },
|
||||
tally: freshTally(),
|
||||
turnDeadline: null,
|
||||
@@ -326,6 +350,7 @@ export const useGameStore = create<GameStore>((set, get) => {
|
||||
|
||||
newOnlineMatch: (cfg) => {
|
||||
clearPending();
|
||||
countedHakemDeals = new Set();
|
||||
sound.init();
|
||||
const names = cfg.players.map((p) => p.displayName) as GameSettings["names"];
|
||||
const initial = createInitialState({ names, targetScore: cfg.targetScore });
|
||||
@@ -334,6 +359,8 @@ export const useGameStore = create<GameStore>((set, get) => {
|
||||
started: true,
|
||||
mode: "online",
|
||||
paused: false,
|
||||
forfeited: false,
|
||||
forfeitRequest: null,
|
||||
matchMeta: { ranked: cfg.ranked, stake: cfg.stake },
|
||||
tally: freshTally(),
|
||||
turnDeadline: null,
|
||||
@@ -354,8 +381,10 @@ export const useGameStore = create<GameStore>((set, get) => {
|
||||
liveSvc = service;
|
||||
if (liveUnsub) liveUnsub();
|
||||
if (rewardUnsub) rewardUnsub();
|
||||
if (forfeitUnsub) forfeitUnsub();
|
||||
liveUnsub = service.onState((s) => get().applyServerState(s));
|
||||
rewardUnsub = service.onReward((r) => set({ serverReward: r }));
|
||||
forfeitUnsub = service.onForfeit((r) => set({ forfeitRequest: r }));
|
||||
set({
|
||||
game: createInitialState({ names: ["شما", "", "", ""], targetScore: 7 }),
|
||||
started: true,
|
||||
@@ -363,6 +392,8 @@ export const useGameStore = create<GameStore>((set, get) => {
|
||||
live: true,
|
||||
serverReward: null,
|
||||
paused: false,
|
||||
forfeited: false,
|
||||
forfeitRequest: null,
|
||||
matchMeta: { ranked: true, stake: 0 },
|
||||
tally: freshTally(),
|
||||
turnDeadline: null,
|
||||
@@ -445,6 +476,35 @@ export const useGameStore = create<GameStore>((set, get) => {
|
||||
if (!get().live && get().started && get().game.phase !== "match-over") scheduleAuto();
|
||||
},
|
||||
|
||||
forfeit: () => {
|
||||
// Live games: ask the server (teammate must confirm, server decides kot).
|
||||
if (get().live) {
|
||||
liveSvc?.requestForfeit();
|
||||
return;
|
||||
}
|
||||
// Client-run (vs computer / private): end now as a loss. If your team won
|
||||
// no rounds it's a Kot loss; otherwise a normal loss.
|
||||
const g = get().game;
|
||||
if (g.phase === "match-over") return;
|
||||
clearPending();
|
||||
set({
|
||||
game: { ...g, phase: "match-over", matchWinner: 1 as Team, turn: null },
|
||||
forfeited: true,
|
||||
turnDeadline: null,
|
||||
disconnectedSeat: null,
|
||||
reconnectDeadline: null,
|
||||
});
|
||||
sound.play("lose");
|
||||
},
|
||||
|
||||
respondForfeit: (confirm) => {
|
||||
if (get().live) {
|
||||
if (confirm) liveSvc?.confirmForfeit();
|
||||
else liveSvc?.declineForfeit();
|
||||
}
|
||||
set({ forfeitRequest: null });
|
||||
},
|
||||
|
||||
reset: () => {
|
||||
clearPending();
|
||||
if (liveUnsub) {
|
||||
@@ -455,6 +515,10 @@ export const useGameStore = create<GameStore>((set, get) => {
|
||||
rewardUnsub();
|
||||
rewardUnsub = null;
|
||||
}
|
||||
if (forfeitUnsub) {
|
||||
forfeitUnsub();
|
||||
forfeitUnsub = null;
|
||||
}
|
||||
liveSvc = null;
|
||||
set({
|
||||
game: createInitialState({ names: ["", "", "", ""], targetScore: 7 }),
|
||||
@@ -463,6 +527,8 @@ export const useGameStore = create<GameStore>((set, get) => {
|
||||
live: false,
|
||||
serverReward: null,
|
||||
paused: false,
|
||||
forfeited: false,
|
||||
forfeitRequest: null,
|
||||
seatPlayers: [],
|
||||
tally: freshTally(),
|
||||
turnDeadline: null,
|
||||
|
||||
Reference in New Issue
Block a user