feat: UNO-style table, social hub, cosmetics, speed mode, store IAB

Game table & play
- UNO-style restyle: suit-aware bolder cards (+xl size), pulsing playable glow,
  big "YOUR TURN" pill, active-seat ring, trick-win particle burst, round
  confetti, match coin-rain.
- Per-league turn time via turnMsForStake: 15s starter/AI, 10s pro, 7s expert;
  mirrored server-side in GameRoom.TurnMs.
- Speed (Blitz) mode for vs-AI/private: 5s turns, race to 5, ~halved pacing.
- Matchmaking waits ~15s (randomized 12-18s) then fills bots; elapsed timer + hint.

Rewards / gifts
- Richer post-match modal (floating coins, XP bar), celebration overlay reveals
  the unlocked sticker pack, boosted daily rewards (client+server synced),
  themed 7-day daily with special day-7.

Social
- Public profile modal (identity, stats, achievement board) from leaderboard /
  friends / discover / end-of-game roster; rate-limited add-friend (10/hour).
- Social hub: Friends / Discover (player search + suggestions) / Messages inbox.
- Profile gender (shown in finder/profile) + social links with public/friends/
  hidden visibility, enforced server-side.

Cosmetics
- Distinct card backs: per-design pattern families (stripes/argyle/grid/dots/
  rays/scales/crosshatch/royal/filigree/gem) + luxury motifs (lib/cardBack.ts),
  consistent on table/shop/profile; +Peacock/Rose-Gold backs.
- Purchasable titles (shop Titles section); title shown under the seat on the
  table and in discover/public profile.
- 10 new sticker packs (banter/kol-kol, Persian trends, court cards, moods).
- Persistent level+XP bar on Home and every inner screen.

Payments
- Buy-coins gateway opens in a new tab (no SPA dead-end) + focus refresh.
- Store IAB scaffolding: Cafe Bazaar deep-link purchase + redirect-token capture,
  Myket native-bridge contract, server-side IabService.Verify for both stores,
  config-driven via Iab__* env. POST /api/coins/iab/verify (JWT).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-06 18:39:24 +03:30
parent e450a6a2ed
commit cb27a16dc1
49 changed files with 3438 additions and 592 deletions
+42 -21
View File
@@ -14,6 +14,8 @@ import {
import { Card, GameState, Phase, Player, Rank, RoundResult, Seat, Suit, Team } from "./hokm/types";
import { avatarEmoji, ForfeitRequest, RewardResult, ServerGameState } from "./online/types";
import type { OnlineService } from "./online/service";
import { turnMsForStake } from "./online/gamification";
import { useSessionStore } from "./session-store";
import { sound } from "./sound";
const KOT_POINTS = 2;
@@ -27,8 +29,9 @@ export const TIMING = {
roundPause: 2600,
} as const;
/** How long a player has to act before the system plays for them. */
export const TURN_MS = 20000;
/** Base turn time (starter league / vs-AI). Higher leagues use less — see
* `turnMsForStake`. Kept for reference; scheduling derives the real value. */
export const TURN_MS = 15000;
/** Grace period to wait for a disconnected player to return. */
export const RECONNECT_MS = 15000;
/** Per-turn chance an online opponent briefly drops (mock). */
@@ -42,11 +45,14 @@ export interface SeatPlayer {
level: number;
id?: string; // real player's user id (for add-friend); absent for bots/you
isBot?: boolean;
title?: string | null; // equipped title id (shown under the avatar on the table)
}
export interface GameSettings {
names: [string, string, string, string];
targetScore: number;
/** Blitz/speed mode — fast turn clock + snappier pacing. */
speed?: boolean;
}
export interface OnlineMatchConfig {
@@ -54,6 +60,7 @@ export interface OnlineMatchConfig {
targetScore: number;
stake: number;
ranked: boolean;
speed?: boolean;
}
interface MatchTally {
@@ -68,7 +75,7 @@ interface GameStore {
started: boolean;
mode: GameMode;
seatPlayers: SeatPlayer[];
matchMeta: { ranked: boolean; stake: number };
matchMeta: { ranked: boolean; stake: number; speed: boolean };
tally: MatchTally;
/** epoch ms by which the current actor must act (for the turn-timer UI). */
@@ -198,6 +205,8 @@ export const useGameStore = create<GameStore>((set, get) => {
function scheduleAuto() {
clearPending();
const g = get().game;
// Speed mode → snappier pacing (animations/pauses run ~half time).
const fast = (ms: number) => (get().matchMeta.speed ? Math.round(ms * 0.5) : ms);
switch (g.phase) {
case "selecting-hakem":
@@ -206,7 +215,7 @@ export const useGameStore = create<GameStore>((set, get) => {
set({ game: dealForTrump(get().game) });
sound.play("deal");
scheduleAuto();
}, TIMING.hakemDraw);
}, fast(TIMING.hakemDraw));
break;
case "choosing-trump": {
@@ -217,15 +226,16 @@ export const useGameStore = create<GameStore>((set, get) => {
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 });
// human hakem: timed choice (less time in higher leagues), system auto-picks on timeout
const turnMs = turnMsForStake(get().matchMeta.stake, get().matchMeta.speed);
set({ turnDeadline: Date.now() + turnMs, disconnectedSeat: null, reconnectDeadline: null });
pending = setTimeout(() => {
const cur = get().game;
if (cur.phase !== "choosing-trump") return;
const suit = chooseTrumpAI(cur.players[cur.hakem!].hand);
set({ game: engineChooseTrump(cur, suit), turnDeadline: null });
scheduleAuto();
}, TURN_MS);
}, turnMs);
} else {
set({ turnDeadline: null });
pending = setTimeout(() => {
@@ -234,7 +244,7 @@ export const useGameStore = create<GameStore>((set, get) => {
set({ game: engineChooseTrump(cur, suit) });
sound.play("trump");
scheduleAuto();
}, TIMING.aiTrump);
}, fast(TIMING.aiTrump));
}
break;
}
@@ -242,15 +252,16 @@ export const useGameStore = create<GameStore>((set, get) => {
case "playing": {
const seat = g.turn!;
if (g.players[seat].isHuman) {
// human turn: timed; system plays a smart legal move on timeout
set({ turnDeadline: Date.now() + TURN_MS, disconnectedSeat: null, reconnectDeadline: null });
// human turn: timed (less time in higher leagues); system plays a smart legal move on timeout
const turnMs = turnMsForStake(get().matchMeta.stake, get().matchMeta.speed);
set({ turnDeadline: Date.now() + turnMs, disconnectedSeat: null, reconnectDeadline: null });
pending = setTimeout(() => {
const cur = get().game;
if (cur.phase !== "playing" || cur.turn !== seat) return;
set({ game: playCard(cur, seat, chooseCardAI(cur, seat)), turnDeadline: null });
sound.play("cardPlay");
scheduleAuto();
}, TURN_MS);
}, turnMs);
} else {
const st = get();
if (
@@ -271,7 +282,7 @@ export const useGameStore = create<GameStore>((set, get) => {
}, back);
} else {
set({ turnDeadline: null });
pending = setTimeout(() => playSeatAI(seat), TIMING.aiPlay);
pending = setTimeout(() => playSeatAI(seat), fast(TIMING.aiPlay));
}
}
break;
@@ -290,7 +301,7 @@ export const useGameStore = create<GameStore>((set, get) => {
sound.play("kot");
}
scheduleAuto();
}, TIMING.trickPause);
}, fast(TIMING.trickPause));
break;
case "round-over":
@@ -299,7 +310,7 @@ export const useGameStore = create<GameStore>((set, get) => {
recordRound(get().game.lastRoundResult);
set({ game: startNextRound(get().game) });
scheduleAuto();
}, TIMING.roundPause);
}, fast(TIMING.roundPause));
break;
default:
@@ -313,7 +324,7 @@ export const useGameStore = create<GameStore>((set, get) => {
started: false,
mode: "ai",
seatPlayers: [],
matchMeta: { ranked: false, stake: 0 },
matchMeta: { ranked: false, stake: 0, speed: false },
tally: freshTally(),
turnDeadline: null,
disconnectedSeat: null,
@@ -336,7 +347,7 @@ export const useGameStore = create<GameStore>((set, get) => {
paused: false,
forfeited: false,
forfeitRequest: null,
matchMeta: { ranked: false, stake: 0 },
matchMeta: { ranked: false, stake: 0, speed: !!settings.speed },
tally: freshTally(),
turnDeadline: null,
disconnectedSeat: null,
@@ -346,6 +357,7 @@ export const useGameStore = create<GameStore>((set, get) => {
avatar: AI_AVATARS[i],
level: 0,
isBot: i > 0, // seat 0 is you
title: i === 0 ? useSessionStore.getState().profile?.title ?? null : null,
})),
});
scheduleAuto();
@@ -364,15 +376,16 @@ export const useGameStore = create<GameStore>((set, get) => {
paused: false,
forfeited: false,
forfeitRequest: null,
matchMeta: { ranked: cfg.ranked, stake: cfg.stake },
matchMeta: { ranked: cfg.ranked, stake: cfg.stake, speed: !!cfg.speed },
tally: freshTally(),
turnDeadline: null,
disconnectedSeat: null,
reconnectDeadline: null,
seatPlayers: cfg.players.map((p) => ({
seatPlayers: cfg.players.map((p, i) => ({
name: p.displayName,
avatar: avatarEmoji(p.avatar),
level: p.level,
title: i === 0 ? useSessionStore.getState().profile?.title ?? null : null,
})),
});
scheduleAuto();
@@ -397,7 +410,7 @@ export const useGameStore = create<GameStore>((set, get) => {
paused: false,
forfeited: false,
forfeitRequest: null,
matchMeta: { ranked: true, stake: 0 },
matchMeta: { ranked: true, stake: 0, speed: false },
tally: freshTally(),
turnDeadline: null,
disconnectedSeat: null,
@@ -409,9 +422,17 @@ export const useGameStore = create<GameStore>((set, get) => {
applyServerState: (s) => {
const prev = get().game;
const next = mapServerState(s);
const me = useSessionStore.getState().profile;
const seatPlayers: SeatPlayer[] = [...s.seatPlayers]
.sort((a, b) => a.seat - b.seat)
.map((sp) => ({ name: sp.name, avatar: avatarEmoji(sp.avatar), level: sp.level, id: sp.userId, isBot: sp.isBot }));
.map((sp) => ({
name: sp.name,
avatar: avatarEmoji(sp.avatar),
level: sp.level,
id: sp.userId,
isBot: sp.isBot,
title: sp.userId && me && sp.userId === me.id ? me.title ?? null : null,
}));
// accumulate the reward tally when the match score grows (a round ended)
const prevTotal = prev.matchScore[0] + prev.matchScore[1];
@@ -431,7 +452,7 @@ export const useGameStore = create<GameStore>((set, get) => {
set({
game: next,
seatPlayers,
matchMeta: { ranked: s.ranked, stake: s.stake },
matchMeta: { ranked: s.ranked, stake: s.stake, speed: false },
turnDeadline: s.turnDeadline ?? null,
disconnectedSeat: (s.disconnectedSeat ?? null) as Seat | null,
reconnectDeadline: s.disconnectedSeat != null ? Date.now() + RECONNECT_MS : null,