Build Hokm card game: offline vs-AI + online social/gamification (mock backend)

- Pure-TS Hokm engine (deal, hakem, trump, tricks, scoring, Kot) + AI bots
- Persian-luxury RTL UI (Next 16 / React 19 / Tailwind v4 / Framer Motion / Zustand)
- Online platform behind OnlineService seam (mock now, .NET SignalR later):
  auth (phone OTP + email/Google), profiles, friends, private rooms with
  partner pick, ranked matchmaking, leaderboard, shop
- Gamification: ranks/leagues, coins, XP/levels, daily rewards, achievements
- i18n fa/en, PWA manifest, engine + gamification sims

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-04 10:11:00 +03:30
parent dff1a34f95
commit e2d0a602b6
41 changed files with 5766 additions and 93 deletions
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
+228
View File
@@ -0,0 +1,228 @@
"use client";
import { create } from "zustand";
import { chooseCardAI, chooseTrumpAI } from "./hokm/ai";
import {
advanceAfterTrick,
chooseTrump as engineChooseTrump,
createInitialState,
dealForTrump,
playCard,
selectHakem,
startNextRound,
} from "./hokm/engine";
import { Card, GameState, RoundResult, Suit } from "./hokm/types";
import { avatarEmoji } from "./online/types";
const KOT_POINTS = 2;
// Animation/pacing timings (ms) — UI matches these.
export const TIMING = {
hakemDraw: 1500,
aiTrump: 1000,
aiPlay: 850,
trickPause: 1150,
roundPause: 2600,
} as const;
export type GameMode = "ai" | "online";
export interface SeatPlayer {
name: string;
avatar: string; // emoji
level: number;
}
export interface GameSettings {
names: [string, string, string, string];
targetScore: number;
}
export interface OnlineMatchConfig {
players: { displayName: string; avatar: string; level: number }[]; // index = seat
targetScore: number;
stake: number;
ranked: boolean;
}
interface MatchTally {
tricksTeam0: number;
kotFor: boolean; // your team kot'd opponents at least once
kotAgainst: boolean;
}
interface GameStore {
game: GameState;
started: boolean;
mode: GameMode;
seatPlayers: SeatPlayer[];
matchMeta: { ranked: boolean; stake: number };
tally: MatchTally;
newMatch: (settings: GameSettings) => void;
newOnlineMatch: (cfg: OnlineMatchConfig) => void;
chooseTrump: (suit: Suit) => void;
playHuman: (card: Card) => void;
reset: () => void;
}
const AI_AVATARS = ["🦊", "🦁", "🦉", "🐯"];
let pending: ReturnType<typeof setTimeout> | null = null;
function clearPending() {
if (pending) {
clearTimeout(pending);
pending = null;
}
}
function freshTally(): MatchTally {
return { tricksTeam0: 0, kotFor: false, kotAgainst: false };
}
export const useGameStore = create<GameStore>((set, get) => {
function recordRound(result: RoundResult | null) {
if (!result) return;
const t = get().tally;
set({
tally: {
tricksTeam0: t.tricksTeam0 + result.tricks[0],
kotFor: t.kotFor || (result.winningTeam === 0 && result.kot),
kotAgainst: t.kotAgainst || (result.winningTeam === 1 && result.kot),
},
});
}
function scheduleAuto() {
clearPending();
const g = get().game;
switch (g.phase) {
case "selecting-hakem":
pending = setTimeout(() => {
set({ game: dealForTrump(get().game) });
scheduleAuto();
}, TIMING.hakemDraw);
break;
case "choosing-trump": {
const hakem = g.hakem!;
if (!g.players[hakem].isHuman) {
pending = setTimeout(() => {
const cur = get().game;
const suit = chooseTrumpAI(cur.players[cur.hakem!].hand);
set({ game: engineChooseTrump(cur, suit) });
scheduleAuto();
}, TIMING.aiTrump);
}
break;
}
case "playing": {
const seat = g.turn!;
if (!g.players[seat].isHuman) {
pending = setTimeout(() => {
const cur = get().game;
const s = cur.turn!;
const card = chooseCardAI(cur, s);
set({ game: playCard(cur, s, card) });
scheduleAuto();
}, TIMING.aiPlay);
}
break;
}
case "trick-complete":
pending = setTimeout(() => {
const next = advanceAfterTrick(get().game, KOT_POINTS);
set({ game: next });
// record the round once when it finalizes into match-over
if (next.phase === "match-over") recordRound(next.lastRoundResult);
scheduleAuto();
}, TIMING.trickPause);
break;
case "round-over":
pending = setTimeout(() => {
recordRound(get().game.lastRoundResult);
set({ game: startNextRound(get().game) });
scheduleAuto();
}, TIMING.roundPause);
break;
default:
break;
}
}
return {
game: createInitialState({ names: ["", "", "", ""], targetScore: 7 }),
started: false,
mode: "ai",
seatPlayers: [],
matchMeta: { ranked: false, stake: 0 },
tally: freshTally(),
newMatch: (settings) => {
clearPending();
const initial = createInitialState(settings);
set({
game: selectHakem(initial),
started: true,
mode: "ai",
matchMeta: { ranked: false, stake: 0 },
tally: freshTally(),
seatPlayers: settings.names.map((name, i) => ({
name,
avatar: AI_AVATARS[i],
level: 0,
})),
});
scheduleAuto();
},
newOnlineMatch: (cfg) => {
clearPending();
const names = cfg.players.map((p) => p.displayName) as GameSettings["names"];
const initial = createInitialState({ names, targetScore: cfg.targetScore });
set({
game: selectHakem(initial),
started: true,
mode: "online",
matchMeta: { ranked: cfg.ranked, stake: cfg.stake },
tally: freshTally(),
seatPlayers: cfg.players.map((p) => ({
name: p.displayName,
avatar: avatarEmoji(p.avatar),
level: p.level,
})),
});
scheduleAuto();
},
chooseTrump: (suit) => {
const g = get().game;
if (g.phase !== "choosing-trump") return;
set({ game: engineChooseTrump(g, suit) });
scheduleAuto();
},
playHuman: (card) => {
const g = get().game;
if (g.phase !== "playing" || g.turn !== 0) return;
set({ game: playCard(g, 0, card) });
scheduleAuto();
},
reset: () => {
clearPending();
set({
game: createInitialState({ names: ["", "", "", ""], targetScore: 7 }),
started: false,
mode: "ai",
seatPlayers: [],
tally: freshTally(),
});
},
};
});
+72
View File
@@ -0,0 +1,72 @@
import { legalMoves, trickWinner } from "./engine";
import { Card, GameState, Seat, Suit, SUITS, teamOf } from "./types";
/** Pick trump from the hakem's opening cards: longest suit, break ties by strength. */
export function chooseTrumpAI(hand: Card[]): Suit {
let best: Suit = "spades";
let bestScore = -1;
for (const suit of SUITS) {
const cards = hand.filter((c) => c.suit === suit);
// weight count heavily, add rank strength as a tie-breaker
const strength = cards.reduce((s, c) => s + Math.max(0, c.rank - 9), 0);
const score = cards.length * 10 + strength;
if (score > bestScore) {
bestScore = score;
best = suit;
}
}
return best;
}
function lowestRank(cards: Card[]): Card {
return cards.reduce((lo, c) => (c.rank < lo.rank ? c : lo));
}
/** Prefer dumping low non-trump; keep trump for when it matters. */
function dumpCard(legal: Card[], trump: Suit | null): Card {
const nonTrump = legal.filter((c) => c.suit !== trump);
const pool = nonTrump.length > 0 ? nonTrump : legal;
return lowestRank(pool);
}
/** Decide which card the AI at `seat` should play. */
export function chooseCardAI(state: GameState, seat: Seat): Card {
const legal = legalMoves(state, seat);
if (legal.length === 1) return legal[0];
const trump = state.trump;
const trick = state.currentTrick;
// Leading the trick.
if (trick.length === 0) {
const nonTrump = legal.filter((c) => c.suit !== trump);
const aces = nonTrump.filter((c) => c.rank === 14);
if (aces.length > 0) return aces[0];
// Lead a low non-trump to probe; keep aces/trump in reserve.
if (nonTrump.length > 0) return lowestRank(nonTrump);
return lowestRank(legal);
}
// Following.
const best = trick.reduce((b, pc) =>
trickWinner([b, pc], trump) === pc.seat ? pc : b
);
const partnerWinning = teamOf(best.seat) === teamOf(seat);
const winningCards = legal.filter(
(card) => trickWinner([...trick, { seat, card }], trump) === seat
);
if (partnerWinning) {
// Partner already winning — don't waste a high card.
return dumpCard(legal, trump);
}
if (winningCards.length > 0) {
// Win as cheaply as possible.
return lowestRank(winningCards);
}
// Can't win — discard the cheapest card.
return dumpCard(legal, trump);
}
+30
View File
@@ -0,0 +1,30 @@
import { Card, RANKS, SUITS } from "./types";
export function createDeck(): Card[] {
const deck: Card[] = [];
for (const suit of SUITS) {
for (const rank of RANKS) {
deck.push({ suit, rank, id: `${suit}-${rank}` });
}
}
return deck;
}
/** FisherYates shuffle. Returns a new array. */
export function shuffle<T>(input: readonly T[]): T[] {
const arr = input.slice();
for (let i = arr.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[arr[i], arr[j]] = [arr[j], arr[i]];
}
return arr;
}
/** Sort a hand for display: group by suit, high rank first. */
export function sortHand(hand: Card[]): Card[] {
const suitOrder = { spades: 0, hearts: 1, clubs: 2, diamonds: 3 };
return hand.slice().sort((a, b) => {
if (a.suit !== b.suit) return suitOrder[a.suit] - suitOrder[b.suit];
return b.rank - a.rank;
});
}
+293
View File
@@ -0,0 +1,293 @@
import { createDeck, shuffle } from "./deck";
import {
Card,
GameState,
PlayedCard,
Player,
RoundResult,
Seat,
Suit,
Team,
nextSeat,
partnerOf,
teamOf,
} from "./types";
export const TRICKS_TO_WIN_ROUND = 7;
export interface MatchOptions {
names: [string, string, string, string];
/** rounds needed to win the match */
targetScore?: number;
/** double points when opponents take zero tricks */
kotPoints?: number;
}
function makePlayers(names: [string, string, string, string]): Player[] {
return ([0, 1, 2, 3] as Seat[]).map((seat) => ({
seat,
name: names[seat],
isHuman: seat === 0,
team: teamOf(seat),
hand: [],
}));
}
export function createInitialState(opts: MatchOptions): GameState {
return {
phase: "idle",
players: makePlayers(opts.names),
deck: [],
hakem: null,
trump: null,
turn: null,
currentTrick: [],
leadSeat: null,
roundTricks: [0, 0],
matchScore: [0, 0],
lastTrickWinner: null,
lastRoundResult: null,
matchWinner: null,
hakemDraw: [],
targetScore: opts.targetScore ?? 7,
dealId: 0,
};
}
/**
* Draw cards face-up, one per seat in rotation starting at seat 0,
* until an Ace appears. That seat becomes the first hakem.
*/
export function selectHakem(state: GameState): GameState {
const deck = shuffle(createDeck());
const draws: PlayedCard[] = [];
let seat: Seat = 0;
let hakem: Seat = 0;
for (const card of deck) {
draws.push({ seat, card });
if (card.rank === 14) {
hakem = seat;
break;
}
seat = nextSeat(seat);
}
return {
...state,
phase: "selecting-hakem",
hakem,
hakemDraw: draws,
};
}
/**
* Deal the opening 5 cards to the hakem so they can choose trump.
* Remaining cards stay in the deck for the post-trump deal.
*/
export function dealForTrump(state: GameState): GameState {
if (state.hakem == null) throw new Error("hakem not selected");
const deck = shuffle(createDeck());
const players = state.players.map((p) => ({ ...p, hand: [] as Card[] }));
const first5 = deck.slice(0, 5);
players[state.hakem].hand = first5;
return {
...state,
phase: "choosing-trump",
players,
deck: deck.slice(5),
trump: null,
turn: state.hakem,
currentTrick: [],
leadSeat: null,
roundTricks: [0, 0],
lastTrickWinner: null,
lastRoundResult: null,
hakemDraw: [],
dealId: state.dealId + 1,
};
}
/** Hakem locks in the trump suit; remaining cards are dealt out (13 each). */
export function chooseTrump(state: GameState, trump: Suit): GameState {
if (state.phase !== "choosing-trump") throw new Error("not choosing trump");
if (state.hakem == null) throw new Error("hakem not selected");
const players = state.players.map((p) => ({ ...p, hand: p.hand.slice() }));
const deck = state.deck.slice();
// Hakem already has 5 — give 8 more. Others get 13.
for (const p of players) {
const need = 13 - p.hand.length;
p.hand.push(...deck.splice(0, need));
}
return {
...state,
phase: "playing",
trump,
players,
deck,
turn: state.hakem,
leadSeat: state.hakem,
currentTrick: [],
};
}
/** Cards a seat is allowed to play right now (follow-suit rule). */
export function legalMoves(state: GameState, seat: Seat): Card[] {
const hand = state.players[seat].hand;
if (state.currentTrick.length === 0) return hand;
const leadSuit = state.currentTrick[0].card.suit;
const sameSuit = hand.filter((c) => c.suit === leadSuit);
return sameSuit.length > 0 ? sameSuit : hand;
}
export function isLegalPlay(state: GameState, seat: Seat, card: Card): boolean {
if (state.turn !== seat) return false;
return legalMoves(state, seat).some((c) => c.id === card.id);
}
/** Determine which played card wins a completed (or partial) trick. */
export function trickWinner(trick: PlayedCard[], trump: Suit | null): Seat {
if (trick.length === 0) throw new Error("empty trick");
const leadSuit = trick[0].card.suit;
let best = trick[0];
for (const pc of trick.slice(1)) {
const bestIsTrump = trump != null && best.card.suit === trump;
const pcIsTrump = trump != null && pc.card.suit === trump;
if (pcIsTrump && !bestIsTrump) {
best = pc;
} else if (pcIsTrump === bestIsTrump) {
// same trump-ness: higher rank wins, but only if following the lead/trump suit
const relevantSuit = bestIsTrump ? trump : leadSuit;
if (pc.card.suit === relevantSuit && pc.card.rank > best.card.rank) {
best = pc;
}
}
}
return best.seat;
}
/**
* Play a card. Returns the new state. When the trick completes (4 cards),
* phase becomes "trick-complete" and lastTrickWinner is set; call
* advanceAfterTrick() to collect it and continue.
*/
export function playCard(state: GameState, seat: Seat, card: Card): GameState {
if (!isLegalPlay(state, seat, card)) {
throw new Error(`illegal play: seat ${seat} ${card.id}`);
}
const players = state.players.map((p) =>
p.seat === seat ? { ...p, hand: p.hand.filter((c) => c.id !== card.id) } : p
);
const currentTrick = [...state.currentTrick, { seat, card }];
const leadSeat = state.leadSeat ?? seat;
if (currentTrick.length < 4) {
return {
...state,
players,
currentTrick,
leadSeat,
turn: nextSeat(seat),
};
}
// Trick complete.
const winner = trickWinner(currentTrick, state.trump);
return {
...state,
players,
currentTrick,
leadSeat,
turn: null,
phase: "trick-complete",
lastTrickWinner: winner,
};
}
function buildRoundResult(
roundTricks: [number, number],
winningTeam: Team,
kotPoints: number
): RoundResult {
const loser = (1 - winningTeam) as Team;
const kot = roundTricks[loser] === 0;
return {
winningTeam,
tricks: roundTricks,
kot,
points: kot ? kotPoints : 1,
};
}
/**
* Collect the finished trick: credit the winner, then either continue the
* round, end the round, or end the match.
*/
export function advanceAfterTrick(
state: GameState,
kotPoints = 2
): GameState {
if (state.phase !== "trick-complete" || state.lastTrickWinner == null) {
return state;
}
const winner = state.lastTrickWinner;
const wTeam = teamOf(winner);
const roundTricks: [number, number] = [...state.roundTricks];
roundTricks[wTeam] += 1;
const someoneWonRound = roundTricks[wTeam] >= TRICKS_TO_WIN_ROUND;
if (someoneWonRound) {
const result = buildRoundResult(roundTricks, wTeam, kotPoints);
const matchScore: [number, number] = [...state.matchScore];
matchScore[wTeam] += result.points;
const matchWinner =
matchScore[wTeam] >= state.targetScore ? wTeam : null;
return {
...state,
roundTricks,
matchScore,
currentTrick: [],
lastTrickWinner: winner,
lastRoundResult: result,
matchWinner,
turn: null,
phase: matchWinner != null ? "match-over" : "round-over",
};
}
return {
...state,
roundTricks,
currentTrick: [],
leadSeat: winner,
turn: winner,
lastTrickWinner: winner,
phase: "playing",
};
}
/**
* Start the next round after one ends. Hakem stays if their team won the
* round; otherwise it passes to the next seat. Deals fresh cards for trump.
*/
export function startNextRound(state: GameState): GameState {
if (state.hakem == null) throw new Error("no hakem");
const result = state.lastRoundResult;
let hakem = state.hakem;
if (result && teamOf(hakem) !== result.winningTeam) {
hakem = nextSeat(hakem);
}
return dealForTrump({ ...state, hakem });
}
/** Convenience: did the hakem's team win the just-finished round? */
export function hakemHeld(state: GameState): boolean {
if (state.hakem == null || !state.lastRoundResult) return false;
return teamOf(state.hakem) === state.lastRoundResult.winningTeam;
}
export { partnerOf, teamOf, nextSeat };
+134
View File
@@ -0,0 +1,134 @@
// Core Hokm domain types — framework-agnostic, no React/DOM imports.
export type Suit = "spades" | "hearts" | "diamonds" | "clubs";
// 2..10, then J Q K A (Ace high in Hokm)
export type Rank = 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14;
export interface Card {
suit: Suit;
rank: Rank;
/** stable id, e.g. "spades-14" */
id: string;
}
/** Seats are clockwise: 0 (you/bottom), 1 (right), 2 (top), 3 (left). */
export type Seat = 0 | 1 | 2 | 3;
/** Teams: team 0 = seats 0 & 2, team 1 = seats 1 & 3. */
export type Team = 0 | 1;
export interface Player {
seat: Seat;
name: string;
isHuman: boolean;
team: Team;
hand: Card[];
}
export type Phase =
| "idle" // before a match starts
| "selecting-hakem" // drawing for first Ace
| "choosing-trump" // hakem picks hokm suit
| "playing" // tricks in progress
| "trick-complete" // brief pause showing trick winner
| "round-over" // a 13-trick round finished
| "match-over"; // someone reached target round score
export interface PlayedCard {
seat: Seat;
card: Card;
}
export interface RoundResult {
winningTeam: Team;
/** trick counts at round end, indexed by team */
tricks: [number, number];
/** true if losing team took zero tricks */
kot: boolean;
/** points awarded to winning team this round */
points: number;
}
export interface GameState {
phase: Phase;
players: Player[];
/** Undealt cards remaining (server-authoritative; ignored by UI). */
deck: Card[];
/** The hakem ( حاکم) seat — leads first trick, chose trump. */
hakem: Seat | null;
trump: Suit | null;
/** Whose turn it is to act (play a card, or choose trump). */
turn: Seat | null;
/** Cards on the table for the current trick, in play order. */
currentTrick: PlayedCard[];
/** Seat that led the current trick. */
leadSeat: Seat | null;
/** Tricks won this round, by team. */
roundTricks: [number, number];
/** Rounds (points) won across the match, by team. */
matchScore: [number, number];
/** Winner of the last completed trick (for the pause/animation). */
lastTrickWinner: Seat | null;
lastRoundResult: RoundResult | null;
matchWinner: Team | null;
/** Cards revealed during hakem selection (face-up draw). */
hakemDraw: PlayedCard[];
/** Points required to win the match (rounds). */
targetScore: number;
/** Increment to help the UI key animations per deal. */
dealId: number;
}
export const SUITS: Suit[] = ["spades", "hearts", "diamonds", "clubs"];
export const RANKS: Rank[] = [2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14];
export const SUIT_SYMBOL: Record<Suit, string> = {
spades: "♠",
hearts: "♥",
diamonds: "♦",
clubs: "♣",
};
export const SUIT_IS_RED: Record<Suit, boolean> = {
spades: false,
hearts: true,
diamonds: true,
clubs: false,
};
export function rankLabel(rank: Rank): string {
switch (rank) {
case 14:
return "A";
case 13:
return "K";
case 12:
return "Q";
case 11:
return "J";
default:
return String(rank);
}
}
export function teamOf(seat: Seat): Team {
return (seat % 2) as Team;
}
export function partnerOf(seat: Seat): Seat {
return ((seat + 2) % 4) as Seat;
}
export function nextSeat(seat: Seat): Seat {
return ((seat + 1) % 4) as Seat;
}
+435
View File
@@ -0,0 +1,435 @@
"use client";
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from "react";
export type Locale = "fa" | "en";
type Dict = Record<string, string>;
const fa: Dict = {
"app.title": "حکم",
"app.subtitle": "بازی کارت اصیل ایرانی",
"app.tagline": "تجربه‌ای لوکس از بازی حکم، با حریف‌های هوشمند",
"home.play": "شروع بازی",
"home.continue": "ادامه بازی",
"home.vsAI": "بازی با کامپیوتر",
"home.target": "امتیاز برد",
"home.targetHint": "تعداد دست برای برنده شدن",
"home.yourName": "نام شما",
"home.start": "بزن بریم",
"home.howTo": "آموزش بازی",
"home.lang": "English",
"seat.you": "شما",
"team.us": "ما",
"team.them": "حریف",
"team.0": "تیم ما",
"team.1": "تیم حریف",
"hakem.title": "تعیین حاکم",
"hakem.desc": "ورق می‌چینیم تا اولین آس بیاید",
"hakem.is": "حاکم: {name}",
"trump.title": "حکم را انتخاب کنید",
"trump.desc": "شما حاکم هستید — خال حکم را تعیین کنید",
"trump.waiting": "{name} در حال انتخاب حکم است…",
"trump.label": "حکم",
"turn.you": "نوبت شماست",
"turn.other": "نوبت {name}",
"trick.wins": "{name} دست را برد",
"round.over": "پایان دست",
"round.kot": "کُت! ",
"round.won": "{team} برنده شد",
"round.score": "امتیاز: {us} - {them}",
"round.next": "دست بعد…",
"match.over": "پایان بازی",
"match.youWin": "شما بردید! 🏆",
"match.youLose": "این بار باختید",
"match.again": "بازی دوباره",
"match.menu": "منوی اصلی",
"score.title": "امتیاز",
"score.tricks": "دست‌ها",
"hud.menu": "منو",
"hud.quit": "خروج",
"menu.vsComputer": "بازی با کامپیوتر",
"menu.vsComputerDesc": "تمرین با حریف‌های هوشمند",
"menu.online": "بازی آنلاین",
"menu.onlineDesc": "با دوستان یا بازیکن‌های واقعی",
"menu.profile": "پروفایل",
"menu.friends": "دوستان",
"menu.leaderboard": "جدول امتیازات",
"menu.shop": "فروشگاه",
"menu.signIn": "ورود / ثبت‌نام",
"menu.guest": "مهمان",
"menu.signOut": "خروج از حساب",
"common.back": "بازگشت",
"common.coins": "سکه",
"common.level": "سطح",
"common.rating": "امتیاز",
"common.save": "ذخیره",
"common.cancel": "انصراف",
"common.confirm": "تأیید",
"common.soon": "به‌زودی",
"common.copy": "کپی",
"common.copied": "کپی شد",
"profile.title": "پروفایل",
"profile.stats": "آمار",
"profile.games": "بازی‌ها",
"profile.wins": "بردها",
"profile.winrate": "درصد برد",
"profile.kots": "کُت‌ها",
"profile.streak": "بهترین نوار",
"profile.achievements": "دستاوردها",
"profile.editName": "ویرایش نام",
"profile.chooseAvatar": "انتخاب آواتار",
"friends.title": "دوستان",
"friends.add": "افزودن",
"friends.addPlaceholder": "نام کاربری یا شماره",
"friends.requests": "درخواست‌ها",
"friends.online": "آنلاین",
"friends.offline": "آفلاین",
"friends.inGame": "در حال بازی",
"friends.invite": "دعوت",
"friends.accept": "قبول",
"friends.decline": "رد",
"friends.remove": "حذف",
"friends.empty": "هنوز دوستی ندارید",
"lobby.title": "بازی آنلاین",
"lobby.createRoom": "ساخت اتاق خصوصی",
"lobby.createDesc": "هم‌تیمی و حریف‌ها را خودتان انتخاب کنید",
"lobby.random": "بازی رتبه‌ای",
"lobby.randomDesc": "حریف تصادفی و کسب امتیاز و سکه",
"room.title": "اتاق بازی",
"room.code": "کد اتاق",
"room.partner": "هم‌تیمی",
"room.opponents": "حریف‌ها",
"room.choosePartner": "انتخاب هم‌تیمی",
"room.invite": "دعوت دوست",
"room.addBot": "ربات",
"room.empty": "خالی",
"room.waiting": "در انتظار…",
"room.start": "شروع بازی",
"room.stake": "شرط",
"room.leave": "ترک اتاق",
"room.pickFriend": "یک دوست را انتخاب کنید",
"mm.title": "جستجوی بازیکن",
"mm.searching": "در حال یافتن حریف…",
"mm.found": "بازیکنان پیدا شدند!",
"mm.ready": "آماده شروع",
"mm.cancel": "لغو",
"mm.start": "ورود به بازی",
"lead.title": "جدول امتیازات",
"lead.rank": "رتبه",
"shop.title": "فروشگاه",
"shop.buy": "خرید",
"shop.owned": "موجود",
"shop.avatars": "آواتارها",
"shop.themes": "تم‌ها",
"shop.notEnough": "سکه کافی نیست",
"auth.title": "ورود به حکم",
"auth.subtitle": "برای بازی آنلاین وارد شوید",
"auth.phone": "موبایل",
"auth.email": "ایمیل",
"auth.phoneLabel": "شماره موبایل",
"auth.phonePlaceholder": "۰۹۱۲۳۴۵۶۷۸۹",
"auth.sendCode": "ارسال کد",
"auth.codeLabel": "کد تأیید",
"auth.codePlaceholder": "کد ۴ رقمی",
"auth.verify": "تأیید و ورود",
"auth.devCode": "کد آزمایشی: {code}",
"auth.emailLabel": "ایمیل",
"auth.passLabel": "رمز عبور",
"auth.nameLabel": "نام نمایشی",
"auth.signIn": "ورود",
"auth.signUp": "ثبت‌نام",
"auth.google": "ورود با گوگل",
"auth.toggleSignup": "حساب ندارید؟ ثبت‌نام کنید",
"auth.toggleSignin": "حساب دارید؟ وارد شوید",
"auth.invalidCode": "کد نادرست است",
"reward.title": "پاداش بازی",
"reward.rating": "امتیاز رتبه‌ای",
"reward.coins": "سکه",
"reward.xp": "تجربه",
"reward.levelUp": "ارتقای سطح!",
"reward.promoted": "ارتقای لیگ!",
"reward.demoted": "سقوط لیگ",
"reward.newAchievement": "دستاورد جدید",
"reward.continue": "ادامه",
"reward.win": "بردید! 🏆",
"reward.lose": "باختید",
"daily.title": "پاداش روزانه",
"daily.day": "روز {n}",
"daily.claim": "دریافت",
"daily.claimed": "دریافت شد",
"daily.come": "فردا برگردید",
"rank.label": "لیگ",
};
const en: Dict = {
"app.title": "Hokm",
"app.subtitle": "The classic Persian card game",
"app.tagline": "A luxury Hokm experience with smart opponents",
"home.play": "Play",
"home.continue": "Continue",
"home.vsAI": "Play vs Computer",
"home.target": "Target score",
"home.targetHint": "Rounds needed to win",
"home.yourName": "Your name",
"home.start": "Let's go",
"home.howTo": "How to play",
"home.lang": "فارسی",
"seat.you": "You",
"team.us": "Us",
"team.them": "Them",
"team.0": "Our team",
"team.1": "Their team",
"hakem.title": "Choosing the Hakem",
"hakem.desc": "Dealing face-up until the first Ace",
"hakem.is": "Hakem: {name}",
"trump.title": "Choose the trump",
"trump.desc": "You are the Hakem — pick the trump suit",
"trump.waiting": "{name} is choosing trump…",
"trump.label": "Trump",
"turn.you": "Your turn",
"turn.other": "{name}'s turn",
"trick.wins": "{name} wins the trick",
"round.over": "Round over",
"round.kot": "Kot! ",
"round.won": "{team} wins",
"round.score": "Score: {us} - {them}",
"round.next": "Next round…",
"match.over": "Game over",
"match.youWin": "You win! 🏆",
"match.youLose": "You lost this time",
"match.again": "Play again",
"match.menu": "Main menu",
"score.title": "Score",
"score.tricks": "Tricks",
"hud.menu": "Menu",
"hud.quit": "Quit",
"menu.vsComputer": "Play vs Computer",
"menu.vsComputerDesc": "Practice against smart bots",
"menu.online": "Play Online",
"menu.onlineDesc": "With friends or real players",
"menu.profile": "Profile",
"menu.friends": "Friends",
"menu.leaderboard": "Leaderboard",
"menu.shop": "Shop",
"menu.signIn": "Sign in / Sign up",
"menu.guest": "Guest",
"menu.signOut": "Sign out",
"common.back": "Back",
"common.coins": "Coins",
"common.level": "Level",
"common.rating": "Rating",
"common.save": "Save",
"common.cancel": "Cancel",
"common.confirm": "Confirm",
"common.soon": "Coming soon",
"common.copy": "Copy",
"common.copied": "Copied",
"profile.title": "Profile",
"profile.stats": "Stats",
"profile.games": "Games",
"profile.wins": "Wins",
"profile.winrate": "Win rate",
"profile.kots": "Kots",
"profile.streak": "Best streak",
"profile.achievements": "Achievements",
"profile.editName": "Edit name",
"profile.chooseAvatar": "Choose avatar",
"friends.title": "Friends",
"friends.add": "Add",
"friends.addPlaceholder": "Username or phone",
"friends.requests": "Requests",
"friends.online": "Online",
"friends.offline": "Offline",
"friends.inGame": "In game",
"friends.invite": "Invite",
"friends.accept": "Accept",
"friends.decline": "Decline",
"friends.remove": "Remove",
"friends.empty": "No friends yet",
"lobby.title": "Play Online",
"lobby.createRoom": "Create private room",
"lobby.createDesc": "Choose your partner and opponents",
"lobby.random": "Ranked match",
"lobby.randomDesc": "Random opponents, earn rating & coins",
"room.title": "Game Room",
"room.code": "Room code",
"room.partner": "Partner",
"room.opponents": "Opponents",
"room.choosePartner": "Choose partner",
"room.invite": "Invite friend",
"room.addBot": "Bot",
"room.empty": "Empty",
"room.waiting": "Waiting…",
"room.start": "Start game",
"room.stake": "Stake",
"room.leave": "Leave room",
"room.pickFriend": "Pick a friend",
"mm.title": "Finding players",
"mm.searching": "Searching for opponents…",
"mm.found": "Players found!",
"mm.ready": "Ready to start",
"mm.cancel": "Cancel",
"mm.start": "Enter game",
"lead.title": "Leaderboard",
"lead.rank": "Rank",
"shop.title": "Shop",
"shop.buy": "Buy",
"shop.owned": "Owned",
"shop.avatars": "Avatars",
"shop.themes": "Themes",
"shop.notEnough": "Not enough coins",
"auth.title": "Sign in to Hokm",
"auth.subtitle": "Sign in to play online",
"auth.phone": "Phone",
"auth.email": "Email",
"auth.phoneLabel": "Mobile number",
"auth.phonePlaceholder": "0912 345 6789",
"auth.sendCode": "Send code",
"auth.codeLabel": "Verification code",
"auth.codePlaceholder": "4-digit code",
"auth.verify": "Verify & sign in",
"auth.devCode": "Dev code: {code}",
"auth.emailLabel": "Email",
"auth.passLabel": "Password",
"auth.nameLabel": "Display name",
"auth.signIn": "Sign in",
"auth.signUp": "Sign up",
"auth.google": "Continue with Google",
"auth.toggleSignup": "No account? Sign up",
"auth.toggleSignin": "Have an account? Sign in",
"auth.invalidCode": "Invalid code",
"reward.title": "Match rewards",
"reward.rating": "Rating",
"reward.coins": "Coins",
"reward.xp": "XP",
"reward.levelUp": "Level up!",
"reward.promoted": "Promoted!",
"reward.demoted": "Demoted",
"reward.newAchievement": "New achievement",
"reward.continue": "Continue",
"reward.win": "You won! 🏆",
"reward.lose": "You lost",
"daily.title": "Daily reward",
"daily.day": "Day {n}",
"daily.claim": "Claim",
"daily.claimed": "Claimed",
"daily.come": "Come back tomorrow",
"rank.label": "League",
};
const DICTS: Record<Locale, Dict> = { fa, en };
interface I18nValue {
locale: Locale;
dir: "rtl" | "ltr";
t: (key: string, vars?: Record<string, string | number>) => string;
setLocale: (l: Locale) => void;
toggle: () => void;
}
const I18nContext = createContext<I18nValue | null>(null);
export function I18nProvider({ children }: { children: React.ReactNode }) {
const [locale, setLocaleState] = useState<Locale>("fa");
useEffect(() => {
const saved = localStorage.getItem("hokm.locale") as Locale | null;
if (saved === "fa" || saved === "en") setLocaleState(saved);
}, []);
const setLocale = useCallback((l: Locale) => {
setLocaleState(l);
localStorage.setItem("hokm.locale", l);
}, []);
const dir: "rtl" | "ltr" = locale === "fa" ? "rtl" : "ltr";
useEffect(() => {
document.documentElement.lang = locale;
document.documentElement.dir = dir;
}, [locale, dir]);
const t = useCallback(
(key: string, vars?: Record<string, string | number>) => {
let str = DICTS[locale][key] ?? DICTS.en[key] ?? key;
if (vars) {
for (const [k, v] of Object.entries(vars)) {
str = str.replace(new RegExp(`\\{${k}\\}`, "g"), String(v));
}
}
return str;
},
[locale]
);
const value = useMemo<I18nValue>(
() => ({
locale,
dir,
t,
setLocale,
toggle: () => setLocale(locale === "fa" ? "en" : "fa"),
}),
[locale, dir, t, setLocale]
);
return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>;
}
export function useI18n(): I18nValue {
const ctx = useContext(I18nContext);
if (!ctx) throw new Error("useI18n must be used within I18nProvider");
return ctx;
}
+131
View File
@@ -0,0 +1,131 @@
"use client";
import { create } from "zustand";
import { CreateRoomOptions, MatchmakingOptions, getService } from "./online/service";
import {
Friend,
FriendRequest,
LeaderboardEntry,
MatchmakingState,
Room,
} from "./online/types";
interface OnlineStore {
friends: Friend[];
requests: FriendRequest[];
room: Room | null;
matchmaking: MatchmakingState;
leaderboard: LeaderboardEntry[];
loadFriends: () => Promise<void>;
addFriend: (q: string) => Promise<{ ok: boolean; messageFa: string; messageEn: string }>;
acceptRequest: (id: string) => Promise<void>;
declineRequest: (id: string) => Promise<void>;
removeFriend: (id: string) => Promise<void>;
createRoom: (opts: CreateRoomOptions) => Promise<void>;
setPartner: (friendId: string | null) => Promise<void>;
inviteToSeat: (seat: 1 | 3, friendId: string) => Promise<void>;
addBot: (seat: 1 | 2 | 3) => Promise<void>;
clearSeat: (seat: 1 | 2 | 3) => Promise<void>;
startRoom: () => Promise<void>;
leaveRoom: () => Promise<void>;
startMatchmaking: (opts: MatchmakingOptions) => Promise<void>;
cancelMatchmaking: () => Promise<void>;
loadLeaderboard: () => Promise<void>;
}
let roomUnsub: (() => void) | null = null;
let mmUnsub: (() => void) | null = null;
let friendUnsub: (() => void) | null = null;
export const useOnlineStore = create<OnlineStore>((set, get) => ({
friends: [],
requests: [],
room: null,
matchmaking: { phase: "idle", players: [], elapsedMs: 0, ranked: true, stake: 0 },
leaderboard: [],
loadFriends: async () => {
const svc = getService();
const [friends, requests] = await Promise.all([svc.listFriends(), svc.listRequests()]);
set({ friends, requests });
if (!friendUnsub) friendUnsub = svc.onFriends((f) => set({ friends: f }));
},
addFriend: async (q) => {
const res = await getService().addFriend(q);
if (res.ok) await get().loadFriends();
return res;
},
acceptRequest: async (id) => {
await getService().acceptRequest(id);
const requests = await getService().listRequests();
set({ requests });
},
declineRequest: async (id) => {
await getService().declineRequest(id);
set({ requests: get().requests.filter((r) => r.id !== id) });
},
removeFriend: async (id) => {
await getService().removeFriend(id);
},
createRoom: async (opts) => {
const svc = getService();
const room = await svc.createRoom(opts);
set({ room });
if (roomUnsub) roomUnsub();
roomUnsub = svc.onRoom((r) => set({ room: { ...r } }));
},
setPartner: async (friendId) => {
const r = await getService().setPartner(get().room!.id, friendId);
set({ room: { ...r } });
},
inviteToSeat: async (seat, friendId) => {
const r = await getService().inviteToSeat(get().room!.id, seat, friendId);
set({ room: { ...r } });
},
addBot: async (seat) => {
const r = await getService().addBot(get().room!.id, seat);
set({ room: { ...r } });
},
clearSeat: async (seat) => {
const r = await getService().clearSeat(get().room!.id, seat);
set({ room: { ...r } });
},
startRoom: async () => {
const r = await getService().startRoom(get().room!.id);
set({ room: { ...r } });
},
leaveRoom: async () => {
if (get().room) await getService().leaveRoom(get().room!.id);
if (roomUnsub) {
roomUnsub();
roomUnsub = null;
}
set({ room: null });
},
startMatchmaking: async (opts) => {
const svc = getService();
if (mmUnsub) mmUnsub();
mmUnsub = svc.onMatchmaking((s) => set({ matchmaking: s }));
await svc.startMatchmaking(opts);
},
cancelMatchmaking: async () => {
await getService().cancelMatchmaking();
if (mmUnsub) {
mmUnsub();
mmUnsub = null;
}
set({ matchmaking: { phase: "idle", players: [], elapsedMs: 0, ranked: true, stake: 0 } });
},
loadLeaderboard: async () => {
const leaderboard = await getService().getLeaderboard();
set({ leaderboard });
},
}));
+286
View File
@@ -0,0 +1,286 @@
// Pure gamification rules: ranks/leagues, rating, XP/levels, coins,
// daily rewards, achievements. No side effects, no storage — unit-testable.
import {
AchievementDef,
AchievementUnlock,
LeagueInfo,
MatchSummary,
PlayerStats,
RankTier,
RankTierId,
RewardResult,
UserProfile,
} from "./types";
/* ------------------------------- Ranks ------------------------------- */
export const RANK_TIERS: RankTier[] = [
{ id: "bronze", nameFa: "برنز", nameEn: "Bronze", floor: 0, color: "#cd7f32" },
{ id: "silver", nameFa: "نقره", nameEn: "Silver", floor: 1100, color: "#c0c7d0" },
{ id: "gold", nameFa: "طلا", nameEn: "Gold", floor: 1300, color: "#e6b800" },
{ id: "platinum", nameFa: "پلاتین", nameEn: "Platinum", floor: 1500, color: "#46c2c2" },
{ id: "diamond", nameFa: "الماس", nameEn: "Diamond", floor: 1700, color: "#6aa6ff" },
{ id: "master", nameFa: "استاد", nameEn: "Master", floor: 1900, color: "#c77dff" },
];
const ROMAN = ["", "I", "II", "III"];
export function divisionLabel(division: number | null): string {
if (division == null) return "";
return ROMAN[division] ?? "";
}
export function tierById(id: RankTierId): RankTier {
return RANK_TIERS.find((t) => t.id === id) ?? RANK_TIERS[0];
}
export function getLeagueInfo(rating: number): LeagueInfo {
const r = Math.max(0, Math.round(rating));
let idx = 0;
for (let i = 0; i < RANK_TIERS.length; i++) {
if (r >= RANK_TIERS[i].floor) idx = i;
}
const tier = RANK_TIERS[idx];
const isLast = idx === RANK_TIERS.length - 1;
if (isLast) {
return { tier, division: null, rating: r, nextThreshold: null, progress: 1 };
}
const nextTierFloor = RANK_TIERS[idx + 1].floor;
const band = nextTierFloor - tier.floor;
const third = band / 3;
// division 3 (III) is lowest, 1 (I) is highest
const within = r - tier.floor;
let division: number;
let divStart: number;
let divEnd: number;
if (within < third) {
division = 3;
divStart = tier.floor;
divEnd = tier.floor + third;
} else if (within < 2 * third) {
division = 2;
divStart = tier.floor + third;
divEnd = tier.floor + 2 * third;
} else {
division = 1;
divStart = tier.floor + 2 * third;
divEnd = nextTierFloor;
}
const progress = Math.min(1, Math.max(0, (r - divStart) / (divEnd - divStart)));
return { tier, division, rating: r, nextThreshold: Math.round(divEnd), progress };
}
/* ------------------------------ Rating ------------------------------- */
const K_FACTOR = 32;
/** Elo-style rating delta for a ranked match (0 for casual). */
export function ratingDelta(
summary: MatchSummary,
myRating: number,
oppRating: number
): number {
if (!summary.ranked) return 0;
const expected = 1 / (1 + Math.pow(10, (oppRating - myRating) / 400));
const score = summary.won ? 1 : 0;
let delta = K_FACTOR * (score - expected);
if (summary.won && summary.kotFor) delta += 8;
if (!summary.won && summary.kotAgainst) delta -= 8;
const rounded = Math.round(delta);
// never let a win cost rating or a loss gain it
if (summary.won) return Math.max(1, rounded);
return Math.min(-1, rounded);
}
/* ------------------------------- Coins ------------------------------- */
export function coinDelta(summary: MatchSummary): number {
const base = summary.won ? (summary.ranked ? 50 : 25) : 10;
const stakeNet = summary.won ? summary.stake : -summary.stake;
const kotBonus = summary.won && summary.kotFor ? 40 : 0;
return base + stakeNet + kotBonus;
}
/* ------------------------------- XP ---------------------------------- */
/** XP required to advance from `level` to `level + 1`. */
export function xpNeededForLevel(level: number): number {
return 100 * level;
}
export function matchXp(summary: MatchSummary): number {
return (
40 +
(summary.won ? 80 : 0) +
summary.tricksWon * 5 +
(summary.kotFor ? 30 : 0)
);
}
export interface LevelProgress {
level: number;
xp: number; // xp within the current level
leveledUp: boolean;
}
export function addXp(level: number, xpInLevel: number, gained: number): LevelProgress {
let lvl = level;
let xp = xpInLevel + gained;
let leveledUp = false;
while (xp >= xpNeededForLevel(lvl)) {
xp -= xpNeededForLevel(lvl);
lvl += 1;
leveledUp = true;
}
return { level: lvl, xp, leveledUp };
}
/* --------------------------- Achievements ---------------------------- */
export const ACHIEVEMENTS: AchievementDef[] = [
{ id: "first_win", nameFa: "اولین برد", nameEn: "First Win", descFa: "اولین بازی خود را ببرید", descEn: "Win your first game", icon: "🥇", goal: 1, coinReward: 100 },
{ id: "first_kot", nameFa: "اولین کُت", nameEn: "First Kot", descFa: "حریف را کُت کنید", descEn: "Inflict a Kot on opponents", icon: "🔥", goal: 1, coinReward: 150 },
{ id: "wins_10", nameFa: "۱۰ برد", nameEn: "10 Wins", descFa: "۱۰ بازی ببرید", descEn: "Win 10 games", icon: "🎯", goal: 10, coinReward: 300 },
{ id: "wins_100", nameFa: "۱۰۰ برد", nameEn: "100 Wins", descFa: "۱۰۰ بازی ببرید", descEn: "Win 100 games", icon: "👑", goal: 100, coinReward: 2000 },
{ id: "streak_5", nameFa: "نوار ۵ برد", nameEn: "5 Win Streak", descFa: "۵ برد پیاپی", descEn: "Win 5 in a row", icon: "⚡", goal: 5, coinReward: 400 },
{ id: "reach_gold", nameFa: "رسیدن به طلا", nameEn: "Reach Gold", descFa: "به لیگ طلا برسید", descEn: "Reach the Gold league", icon: "🏅", goal: 1, coinReward: 500 },
{ id: "games_50", nameFa: "۵۰ بازی", nameEn: "50 Games", descFa: "۵۰ بازی انجام دهید", descEn: "Play 50 games", icon: "🎮", goal: 50, coinReward: 350 },
];
/** Current raw progress value for an achievement from stats + rating. */
export function achievementProgress(
id: string,
stats: PlayerStats,
rating: number
): number {
switch (id) {
case "first_win":
return Math.min(1, stats.wins);
case "first_kot":
return Math.min(1, stats.kotsFor);
case "wins_10":
return Math.min(10, stats.wins);
case "wins_100":
return Math.min(100, stats.wins);
case "streak_5":
return Math.min(5, stats.bestWinStreak);
case "reach_gold":
return rating >= tierById("gold").floor ? 1 : 0;
case "games_50":
return Math.min(50, stats.games);
default:
return 0;
}
}
/* ---------------------- Apply a match result ------------------------- */
function applyStats(stats: PlayerStats, summary: MatchSummary): PlayerStats {
const wins = stats.wins + (summary.won ? 1 : 0);
const losses = stats.losses + (summary.won ? 0 : 1);
const currentWinStreak = summary.won ? stats.currentWinStreak + 1 : 0;
return {
games: stats.games + 1,
wins,
losses,
kotsFor: stats.kotsFor + (summary.kotFor ? 1 : 0),
kotsAgainst: stats.kotsAgainst + (summary.kotAgainst ? 1 : 0),
tricks: stats.tricks + summary.tricksWon,
currentWinStreak,
bestWinStreak: Math.max(stats.bestWinStreak, currentWinStreak),
};
}
/**
* Apply a finished match to a profile. Returns a new profile + a RewardResult
* describing every delta for the post-match UI.
*/
export function applyMatchResult(
profile: UserProfile,
summary: MatchSummary,
oppRating: number
): { profile: UserProfile; reward: RewardResult } {
const ratingBefore = profile.rating;
const coinsBefore = profile.coins;
const levelBefore = profile.level;
const rDelta = ratingDelta(summary, profile.rating, oppRating);
const ratingAfter = Math.max(0, ratingBefore + rDelta);
const cDelta = coinDelta(summary);
const xpGain = matchXp(summary);
const lvl = addXp(profile.level, profile.xp, xpGain);
const stats = applyStats(profile.stats, summary);
// Evaluate achievements against the new state.
const achievements = { ...profile.achievements };
const unlocked = [...profile.unlocked];
const newAchievements: AchievementUnlock[] = [];
let achievementCoins = 0;
for (const def of ACHIEVEMENTS) {
const prog = achievementProgress(def.id, stats, ratingAfter);
achievements[def.id] = prog;
if (prog >= def.goal && !unlocked.includes(def.id)) {
unlocked.push(def.id);
achievementCoins += def.coinReward;
newAchievements.push({
id: def.id,
nameFa: def.nameFa,
nameEn: def.nameEn,
icon: def.icon,
coinReward: def.coinReward,
});
}
}
const coinsAfter = Math.max(0, coinsBefore + cDelta + achievementCoins);
const leagueBefore = getLeagueInfo(ratingBefore);
const leagueAfter = getLeagueInfo(ratingAfter);
const tierIndex = (id: RankTierId) => RANK_TIERS.findIndex((t) => t.id === id);
const rankValue = (l: LeagueInfo) =>
tierIndex(l.tier.id) * 10 - (l.division ?? 0);
const promoted = rankValue(leagueAfter) > rankValue(leagueBefore);
const demoted = rankValue(leagueAfter) < rankValue(leagueBefore);
const newProfile: UserProfile = {
...profile,
rating: ratingAfter,
coins: coinsAfter,
level: lvl.level,
xp: lvl.xp,
stats,
achievements,
unlocked,
};
const reward: RewardResult = {
ratingBefore,
ratingAfter,
ratingDelta: ratingAfter - ratingBefore,
coinsBefore,
coinsAfter,
coinsDelta: coinsAfter - coinsBefore,
xpGained: xpGain,
levelBefore,
levelAfter: lvl.level,
leveledUp: lvl.level > levelBefore,
newAchievements,
promoted,
demoted,
};
return { profile: newProfile, reward };
}
/* --------------------------- Daily reward ---------------------------- */
export const DAILY_REWARDS = [100, 150, 200, 300, 400, 500, 1000];
export function dailyRewardFor(day: number): number {
return DAILY_REWARDS[Math.min(day, DAILY_REWARDS.length) - 1] ?? 100;
}
+607
View File
@@ -0,0 +1,607 @@
// In-memory + localStorage mock implementing OnlineService.
// Simulates remote players, friends presence, room invites and matchmaking
// with timers, and computes rewards via gamification.ts.
import { applyMatchResult, dailyRewardFor } from "./gamification";
import {
CreateRoomOptions,
MatchmakingOptions,
OnlineService,
Unsubscribe,
} from "./service";
import {
AVATARS,
AuthSession,
DailyRewardState,
Friend,
FriendRequest,
LeaderboardEntry,
MatchSummary,
MatchmakingState,
PresenceStatus,
RewardResult,
Room,
RoomSeat,
ShopItem,
UserProfile,
} from "./types";
const PERSIAN_NAMES = [
"آرش", "کیان", "نیلوفر", "سارا", "رضا", "مهسا", "امیر", "پارسا",
"الناز", "بابک", "شیما", "حسام", "تینا", "کاوه", "رویا", "مازیار",
"نگار", "سهراب", "بهار", "فرهاد", "یاسمن", "آرمان", "دنیا", "سینا",
];
function rid(prefix = "id"): string {
return `${prefix}_${Math.random().toString(36).slice(2, 9)}`;
}
function pick<T>(arr: T[]): T {
return arr[Math.floor(Math.random() * arr.length)];
}
function randInt(min: number, max: number): number {
return Math.floor(Math.random() * (max - min + 1)) + min;
}
function todayStr(): string {
return new Date().toISOString().slice(0, 10);
}
function isBrowser(): boolean {
return typeof window !== "undefined";
}
const LS = {
session: "hokm.session",
profile: "hokm.profile",
daily: "hokm.daily",
};
function load<T>(key: string): T | null {
if (!isBrowser()) return null;
try {
const raw = localStorage.getItem(key);
return raw ? (JSON.parse(raw) as T) : null;
} catch {
return null;
}
}
function save(key: string, value: unknown): void {
if (!isBrowser()) return;
try {
localStorage.setItem(key, JSON.stringify(value));
} catch {
/* ignore */
}
}
function defaultProfile(session: AuthSession): UserProfile {
return {
id: session.userId,
username: "player_" + session.userId.slice(-4),
displayName: "بازیکن",
avatar: AVATARS[0].id,
phone: session.method === "phone" ? undefined : undefined,
level: 1,
xp: 0,
coins: 1000,
rating: 1000,
stats: {
games: 0,
wins: 0,
losses: 0,
kotsFor: 0,
kotsAgainst: 0,
tricks: 0,
bestWinStreak: 0,
currentWinStreak: 0,
},
ownedAvatars: [AVATARS[0].id, AVATARS[1].id],
ownedThemes: ["royal"],
achievements: {},
unlocked: [],
createdAt: Date.now(),
};
}
function makeFriend(status?: PresenceStatus): Friend {
return {
id: rid("fr"),
username: "u" + randInt(1000, 9999),
displayName: pick(PERSIAN_NAMES),
avatar: pick(AVATARS).id,
level: randInt(1, 40),
rating: randInt(900, 1800),
status: status ?? pick<PresenceStatus>(["online", "offline", "in-game", "online"]),
};
}
export class MockOnlineService implements OnlineService {
private session: AuthSession | null = null;
private profile: UserProfile | null = null;
private friends: Friend[] = [];
private requests: FriendRequest[] = [];
private room: Room | null = null;
private matchmaking: MatchmakingState = {
phase: "idle",
players: [],
elapsedMs: 0,
ranked: true,
stake: 0,
};
private matchPlayers:
| { id: string; displayName: string; avatar: string; level: number }[]
| null = null;
private currentOppRating = 1000;
private lastOtp = "";
private roomCbs = new Set<(r: Room) => void>();
private mmCbs = new Set<(s: MatchmakingState) => void>();
private friendCbs = new Set<(f: Friend[]) => void>();
private timers: ReturnType<typeof setTimeout>[] = [];
constructor() {
this.session = load<AuthSession>(LS.session);
this.profile = load<UserProfile>(LS.profile);
this.seedFriends();
}
private seedFriends() {
this.friends = Array.from({ length: 8 }, () => makeFriend());
// one pending request
this.requests = [{ id: rid("req"), from: makeFriend("online"), createdAt: Date.now() }];
}
private emitRoom() {
if (this.room) for (const cb of this.roomCbs) cb(this.room);
}
private emitMM() {
for (const cb of this.mmCbs) cb({ ...this.matchmaking });
}
private emitFriends() {
for (const cb of this.friendCbs) cb([...this.friends]);
}
private after(ms: number, fn: () => void) {
const t = setTimeout(fn, ms);
this.timers.push(t);
return t;
}
private saveProfile() {
if (this.profile) save(LS.profile, this.profile);
}
/* ------------------------------ auth ------------------------------- */
getSession() {
return this.session;
}
async restore() {
if (this.session && this.profile) {
return { session: this.session, profile: this.profile };
}
return null;
}
private establish(session: AuthSession): AuthSession {
this.session = session;
save(LS.session, session);
if (!this.profile) {
this.profile = defaultProfile(session);
this.saveProfile();
}
return session;
}
async requestOtp(phone: string) {
this.lastOtp = String(randInt(1000, 9999));
void phone;
// In dev we surface the code so it can be entered without a real SMS.
return { devCode: this.lastOtp };
}
async verifyOtp(phone: string, code: string) {
if (code !== this.lastOtp && code !== "1234") {
throw new Error("INVALID_CODE");
}
const session: AuthSession = {
userId: rid("user"),
token: rid("tok"),
method: "phone",
createdAt: Date.now(),
};
const s = this.establish(session);
if (this.profile && !this.profile.phone) {
this.profile.phone = phone;
this.saveProfile();
}
return s;
}
async signInEmail(email: string, password: string) {
void password;
const session: AuthSession = {
userId: rid("user"),
token: rid("tok"),
method: "email",
createdAt: Date.now(),
};
const s = this.establish(session);
if (this.profile && !this.profile.email) {
this.profile.email = email;
this.saveProfile();
}
return s;
}
async signUpEmail(email: string, password: string, displayName: string) {
const s = await this.signInEmail(email, password);
if (this.profile) {
this.profile.email = email;
if (displayName.trim()) this.profile.displayName = displayName.trim();
this.saveProfile();
}
return s;
}
async signInGoogle() {
const session: AuthSession = {
userId: rid("user"),
token: rid("tok"),
method: "google",
createdAt: Date.now(),
};
return this.establish(session);
}
async signOut() {
this.session = null;
if (isBrowser()) localStorage.removeItem(LS.session);
// keep profile so progress persists across sign-ins on the same device
}
/* ----------------------------- profile ----------------------------- */
async getProfile() {
if (!this.profile) {
// guest fallback profile (not persisted as session)
this.profile =
load<UserProfile>(LS.profile) ??
defaultProfile({
userId: rid("guest"),
token: "",
method: "guest",
createdAt: Date.now(),
});
this.saveProfile();
}
return this.profile;
}
async updateProfile(patch: Partial<Pick<UserProfile, "displayName" | "avatar">>) {
const p = await this.getProfile();
this.profile = { ...p, ...patch };
this.saveProfile();
return this.profile;
}
/* ----------------------------- friends ----------------------------- */
async listFriends() {
return [...this.friends];
}
async listRequests() {
return [...this.requests];
}
async addFriend(query: string) {
if (!query.trim()) {
return { ok: false, messageFa: "نام یا شماره را وارد کنید", messageEn: "Enter a name or number" };
}
const f = makeFriend("offline");
f.displayName = query.trim().startsWith("0") ? pick(PERSIAN_NAMES) : query.trim();
this.friends = [f, ...this.friends];
this.emitFriends();
return { ok: true, messageFa: "درخواست دوستی ارسال شد", messageEn: "Friend request sent" };
}
async acceptRequest(id: string) {
const req = this.requests.find((r) => r.id === id);
if (req) {
this.friends = [{ ...req.from, status: "online" }, ...this.friends];
this.requests = this.requests.filter((r) => r.id !== id);
this.emitFriends();
}
}
async declineRequest(id: string) {
this.requests = this.requests.filter((r) => r.id !== id);
}
async removeFriend(id: string) {
this.friends = this.friends.filter((f) => f.id !== id);
this.emitFriends();
}
onFriends(cb: (f: Friend[]) => void): Unsubscribe {
this.friendCbs.add(cb);
return () => this.friendCbs.delete(cb);
}
/* ------------------------------ rooms ------------------------------ */
private seatYou(): RoomSeat {
const p = this.profile!;
return {
seat: 0,
kind: "you",
player: { id: p.id, displayName: p.displayName, avatar: p.avatar, level: p.level },
};
}
async createRoom(opts: CreateRoomOptions) {
await this.getProfile();
this.room = {
id: rid("room"),
code: Math.random().toString(36).slice(2, 8).toUpperCase(),
hostId: this.profile!.id,
status: "open",
seats: [
this.seatYou(),
{ seat: 1, kind: "empty" },
{ seat: 2, kind: "empty" },
{ seat: 3, kind: "empty" },
],
targetScore: opts.targetScore,
stake: opts.stake,
ranked: opts.ranked,
};
return this.room;
}
private setSeat(seat: number, s: RoomSeat) {
if (!this.room) return;
this.room.seats = this.room.seats.map((x) => (x.seat === seat ? s : x));
}
private friendSeat(seat: 1 | 2 | 3, friendId: string, invited: boolean): RoomSeat {
const f = this.friends.find((x) => x.id === friendId);
return {
seat,
kind: invited ? "invited" : "friend",
player: f
? { id: f.id, displayName: f.displayName, avatar: f.avatar, level: f.level }
: { id: friendId, displayName: pick(PERSIAN_NAMES), avatar: pick(AVATARS).id, level: randInt(1, 30) },
};
}
async setPartner(roomId: string, friendId: string | null) {
void roomId;
if (!this.room) throw new Error("NO_ROOM");
if (friendId == null) {
this.setSeat(2, { seat: 2, kind: "empty" });
} else {
this.setSeat(2, this.friendSeat(2, friendId, true));
this.after(1100, () => {
this.setSeat(2, this.friendSeat(2, friendId, false));
this.emitRoom();
});
}
this.emitRoom();
return this.room;
}
async inviteToSeat(roomId: string, seat: 1 | 3, friendId: string) {
void roomId;
if (!this.room) throw new Error("NO_ROOM");
this.setSeat(seat, this.friendSeat(seat, friendId, true));
this.after(1100, () => {
this.setSeat(seat, this.friendSeat(seat, friendId, false));
this.emitRoom();
});
this.emitRoom();
return this.room;
}
async addBot(roomId: string, seat: 1 | 2 | 3) {
void roomId;
if (!this.room) throw new Error("NO_ROOM");
this.setSeat(seat, {
seat,
kind: "bot",
player: { id: rid("bot"), displayName: pick(PERSIAN_NAMES), avatar: pick(AVATARS).id, level: randInt(1, 50) },
});
this.emitRoom();
return this.room;
}
async clearSeat(roomId: string, seat: 1 | 2 | 3) {
void roomId;
if (!this.room) throw new Error("NO_ROOM");
this.setSeat(seat, { seat, kind: "empty" });
this.emitRoom();
return this.room;
}
async startRoom(roomId: string) {
void roomId;
if (!this.room) throw new Error("NO_ROOM");
// fill empty seats with bots
for (const s of this.room.seats) {
if (s.kind === "empty" || s.kind === "invited") {
await this.addBot(roomId, s.seat as 1 | 2 | 3);
}
}
this.room.status = "in-game";
this.matchPlayers = this.room.seats
.slice()
.sort((a, b) => a.seat - b.seat)
.map((s) => s.player!) as typeof this.matchPlayers;
this.currentOppRating = this.profile?.rating ?? 1000;
this.emitRoom();
return this.room;
}
async leaveRoom(roomId: string) {
void roomId;
this.room = null;
}
onRoom(cb: (r: Room) => void): Unsubscribe {
this.roomCbs.add(cb);
return () => this.roomCbs.delete(cb);
}
/* --------------------------- matchmaking --------------------------- */
async startMatchmaking(opts: MatchmakingOptions) {
await this.getProfile();
const me = this.profile!;
this.matchmaking = {
phase: "searching",
players: [{ id: me.id, displayName: me.displayName, avatar: me.avatar, level: me.level, rating: me.rating }],
elapsedMs: 0,
ranked: opts.ranked,
stake: opts.stake,
};
this.emitMM();
const reveal = (delay: number) =>
this.after(delay, () => {
if (this.matchmaking.phase !== "searching") return;
this.matchmaking.players.push({
id: rid("p"),
displayName: pick(PERSIAN_NAMES),
avatar: pick(AVATARS).id,
level: randInt(1, 50),
rating: me.rating + randInt(-150, 150),
});
this.emitMM();
});
reveal(900);
reveal(1900);
reveal(2900);
this.after(3500, () => {
if (this.matchmaking.phase !== "searching") return;
this.matchmaking.phase = "found";
this.emitMM();
this.after(1200, () => {
if (this.matchmaking.phase !== "found") return;
this.matchmaking.phase = "ready";
// seat order: you=0, then revealed players
const players = this.matchmaking.players;
this.matchPlayers = players.map((p) => ({
id: p.id,
displayName: p.displayName,
avatar: p.avatar,
level: p.level,
}));
const opps = players.slice(1);
this.currentOppRating =
opps.reduce((s, p) => s + p.rating, 0) / Math.max(1, opps.length);
this.emitMM();
});
});
}
async cancelMatchmaking() {
this.matchmaking = { phase: "cancelled", players: [], elapsedMs: 0, ranked: true, stake: 0 };
this.emitMM();
this.matchmaking.phase = "idle";
}
onMatchmaking(cb: (s: MatchmakingState) => void): Unsubscribe {
this.mmCbs.add(cb);
return () => this.mmCbs.delete(cb);
}
/* ----------------------------- match ------------------------------- */
getMatchPlayers() {
return this.matchPlayers;
}
async submitMatchResult(summary: MatchSummary): Promise<RewardResult> {
const p = await this.getProfile();
const { profile, reward } = applyMatchResult(p, summary, this.currentOppRating);
this.profile = profile;
this.saveProfile();
if (this.room) this.room = null;
this.matchmaking.phase = "idle";
return reward;
}
/* --------------------- leaderboard / shop / daily ------------------ */
async getLeaderboard(): Promise<LeaderboardEntry[]> {
const p = await this.getProfile();
const others = Array.from({ length: 24 }, () => ({
id: rid("lb"),
displayName: pick(PERSIAN_NAMES),
avatar: pick(AVATARS).id,
level: randInt(5, 60),
rating: randInt(1000, 2200),
isYou: false,
}));
const you = {
id: p.id,
displayName: p.displayName,
avatar: p.avatar,
level: p.level,
rating: p.rating,
isYou: true,
};
const all = [...others, you].sort((a, b) => b.rating - a.rating);
return all.map((e, i) => ({ rank: i + 1, ...e }));
}
async getShopItems(): Promise<ShopItem[]> {
const avatarItems: ShopItem[] = AVATARS.slice(2).map((a, i) => ({
id: a.id,
kind: "avatar",
nameFa: "آواتار",
nameEn: "Avatar",
price: 500 + i * 150,
preview: a.emoji,
}));
const themes: ShopItem[] = [
{ id: "midnight", kind: "theme", nameFa: "تم نیمه‌شب", nameEn: "Midnight", price: 1200, preview: "#0a142e" },
{ id: "emerald", kind: "theme", nameFa: "تم زمرد", nameEn: "Emerald", price: 1500, preview: "#0d6b6b" },
{ id: "crimson", kind: "theme", nameFa: "تم یاقوت", nameEn: "Crimson", price: 1800, preview: "#7f1d2e" },
];
return [...avatarItems, ...themes];
}
async buyItem(id: string) {
const p = await this.getProfile();
const items = await this.getShopItems();
const item = items.find((i) => i.id === id);
if (!item) return { ok: false, messageFa: "آیتم یافت نشد", messageEn: "Item not found" };
const owned =
item.kind === "avatar" ? p.ownedAvatars.includes(id) : p.ownedThemes.includes(id);
if (owned) return { ok: false, messageFa: "قبلاً خریداری شده", messageEn: "Already owned" };
if (p.coins < item.price)
return { ok: false, messageFa: "سکه کافی نیست", messageEn: "Not enough coins" };
this.profile = {
...p,
coins: p.coins - item.price,
ownedAvatars: item.kind === "avatar" ? [...p.ownedAvatars, id] : p.ownedAvatars,
ownedThemes: item.kind === "theme" ? [...p.ownedThemes, id] : p.ownedThemes,
};
this.saveProfile();
return { ok: true, profile: this.profile, messageFa: "خرید انجام شد", messageEn: "Purchased" };
}
async getDailyState(): Promise<DailyRewardState> {
const d = load<DailyRewardState>(LS.daily) ?? { day: 1, lastClaimed: null, available: true };
d.available = d.lastClaimed !== todayStr();
return d;
}
async claimDaily() {
const p = await this.getProfile();
const d = await this.getDailyState();
if (!d.available) return { reward: 0, profile: p, day: d.day };
const reward = dailyRewardFor(d.day);
this.profile = { ...p, coins: p.coins + reward };
this.saveProfile();
const nextDay = d.day >= 7 ? 1 : d.day + 1;
save(LS.daily, { day: nextDay, lastClaimed: todayStr(), available: false });
return { reward, profile: this.profile, day: d.day };
}
}
+93
View File
@@ -0,0 +1,93 @@
// The single seam between the UI and any backend.
// The mock implements this today; a SignalR/.NET client implements it later
// without any UI changes.
import {
AuthSession,
DailyRewardState,
Friend,
FriendRequest,
LeaderboardEntry,
MatchSummary,
MatchmakingState,
RewardResult,
Room,
ShopItem,
UserProfile,
} from "./types";
export interface CreateRoomOptions {
targetScore: number;
stake: number;
ranked: boolean;
}
export interface MatchmakingOptions {
stake: number;
ranked: boolean;
}
export type Unsubscribe = () => void;
export interface OnlineService {
/* ----- auth ----- */
getSession(): AuthSession | null;
restore(): Promise<{ session: AuthSession; profile: UserProfile } | null>;
requestOtp(phone: string): Promise<{ devCode?: string }>;
verifyOtp(phone: string, code: string): Promise<AuthSession>;
signInEmail(email: string, password: string): Promise<AuthSession>;
signUpEmail(email: string, password: string, displayName: string): Promise<AuthSession>;
signInGoogle(): Promise<AuthSession>;
signOut(): Promise<void>;
/* ----- profile ----- */
getProfile(): Promise<UserProfile>;
updateProfile(patch: Partial<Pick<UserProfile, "displayName" | "avatar">>): Promise<UserProfile>;
/* ----- friends ----- */
listFriends(): Promise<Friend[]>;
listRequests(): Promise<FriendRequest[]>;
addFriend(query: string): Promise<{ ok: boolean; messageFa: string; messageEn: string }>;
acceptRequest(id: string): Promise<void>;
declineRequest(id: string): Promise<void>;
removeFriend(id: string): Promise<void>;
onFriends(cb: (friends: Friend[]) => void): Unsubscribe;
/* ----- rooms ----- */
createRoom(opts: CreateRoomOptions): Promise<Room>;
setPartner(roomId: string, friendId: string | null): Promise<Room>;
inviteToSeat(roomId: string, seat: 1 | 3, friendId: string): Promise<Room>;
addBot(roomId: string, seat: 1 | 2 | 3): Promise<Room>;
clearSeat(roomId: string, seat: 1 | 2 | 3): Promise<Room>;
startRoom(roomId: string): Promise<Room>;
leaveRoom(roomId: string): Promise<void>;
onRoom(cb: (room: Room) => void): Unsubscribe;
/* ----- matchmaking ----- */
startMatchmaking(opts: MatchmakingOptions): Promise<void>;
cancelMatchmaking(): Promise<void>;
onMatchmaking(cb: (state: MatchmakingState) => void): Unsubscribe;
/* ----- match players (for the online game driver) ----- */
getMatchPlayers(): { id: string; displayName: string; avatar: string; level: number }[] | null;
submitMatchResult(summary: MatchSummary): Promise<RewardResult>;
/* ----- leaderboard / shop / daily ----- */
getLeaderboard(): Promise<LeaderboardEntry[]>;
getShopItems(): Promise<ShopItem[]>;
buyItem(id: string): Promise<{ ok: boolean; profile?: UserProfile; messageFa: string; messageEn: string }>;
getDailyState(): Promise<DailyRewardState>;
claimDaily(): Promise<{ reward: number; profile: UserProfile; day: number }>;
}
import { MockOnlineService } from "./mock-service";
let _service: OnlineService | null = null;
/** Lazily create the active service. Swap the implementation here later. */
export function getService(): OnlineService {
if (!_service) {
_service = new MockOnlineService();
}
return _service;
}
+265
View File
@@ -0,0 +1,265 @@
// Online / social / gamification domain types.
// These are transport-agnostic: the mock service and the future SignalR
// client both speak in these shapes.
import { Suit } from "../hokm/types";
/* ------------------------------- Auth -------------------------------- */
export type AuthMethod = "phone" | "email" | "google" | "guest";
export interface AuthSession {
userId: string;
token: string;
method: AuthMethod;
createdAt: number;
}
/* ------------------------------ Profile ------------------------------ */
export interface PlayerStats {
games: number;
wins: number;
losses: number;
kotsFor: number; // kots inflicted
kotsAgainst: number;
tricks: number;
bestWinStreak: number;
currentWinStreak: number;
}
export interface UserProfile {
id: string;
username: string;
displayName: string;
avatar: string; // avatar id (see AVATARS)
phone?: string;
email?: string;
level: number;
xp: number; // xp within the current level
coins: number;
rating: number; // competitive rating
stats: PlayerStats;
ownedAvatars: string[];
ownedThemes: string[];
achievements: Record<string, number>; // achievementId -> progress count
unlocked: string[]; // achievementId list already unlocked
createdAt: number;
}
/* ------------------------------- Ranks ------------------------------- */
export type RankTierId =
| "bronze"
| "silver"
| "gold"
| "platinum"
| "diamond"
| "master";
export interface RankTier {
id: RankTierId;
nameFa: string;
nameEn: string;
/** inclusive rating floor for this tier */
floor: number;
color: string; // hex for badge
}
export interface LeagueInfo {
tier: RankTier;
/** division 1 (highest) .. 3 (lowest); master has no divisions */
division: number | null;
rating: number;
/** rating at which the player promotes to the next division/tier */
nextThreshold: number | null;
/** progress 0..1 toward nextThreshold within the current band */
progress: number;
}
/* --------------------------- Achievements ---------------------------- */
export interface AchievementDef {
id: string;
nameFa: string;
nameEn: string;
descFa: string;
descEn: string;
icon: string; // emoji or lucide name
goal: number; // progress needed to unlock
coinReward: number;
}
export interface AchievementView extends AchievementDef {
progress: number;
unlocked: boolean;
}
/* ------------------------------ Friends ------------------------------ */
export type PresenceStatus = "online" | "offline" | "in-game";
export interface Friend {
id: string;
username: string;
displayName: string;
avatar: string;
level: number;
rating: number;
status: PresenceStatus;
}
export interface FriendRequest {
id: string;
from: Friend;
createdAt: number;
}
/* ------------------------------- Rooms ------------------------------- */
export type RoomStatus = "open" | "starting" | "in-game" | "closed";
export type SeatOccupantKind = "you" | "friend" | "bot" | "empty" | "invited";
export interface RoomSeat {
seat: 0 | 1 | 2 | 3;
kind: SeatOccupantKind;
/** present for you/friend/bot/invited */
player?: {
id: string;
displayName: string;
avatar: string;
level: number;
};
}
export interface Room {
id: string;
code: string; // shareable join code
hostId: string;
status: RoomStatus;
/** seats[0] is always the host (you). seat 2 is the partner. */
seats: RoomSeat[];
targetScore: number;
stake: number; // coins
ranked: boolean;
}
/* --------------------------- Matchmaking ----------------------------- */
export type MatchmakingPhase =
| "idle"
| "searching"
| "found"
| "ready"
| "cancelled";
export interface MatchmakingState {
phase: MatchmakingPhase;
/** players revealed so far (incl. you), index = seat */
players: {
id: string;
displayName: string;
avatar: string;
level: number;
rating: number;
}[];
elapsedMs: number;
ranked: boolean;
stake: number;
}
/* ------------------------- Match + Rewards --------------------------- */
export interface MatchSummary {
ranked: boolean;
stake: number;
won: boolean;
kotFor: boolean;
kotAgainst: boolean;
tricksWon: number; // your team's total tricks across the match
rounds: number;
trump: Suit | null;
}
export interface AchievementUnlock {
id: string;
nameFa: string;
nameEn: string;
icon: string;
coinReward: number;
}
export interface RewardResult {
ratingBefore: number;
ratingAfter: number;
ratingDelta: number;
coinsBefore: number;
coinsAfter: number;
coinsDelta: number;
xpGained: number;
levelBefore: number;
levelAfter: number;
leveledUp: boolean;
newAchievements: AchievementUnlock[];
promoted: boolean;
demoted: boolean;
}
/* ---------------------------- Leaderboard ---------------------------- */
export interface LeaderboardEntry {
rank: number;
id: string;
displayName: string;
avatar: string;
level: number;
rating: number;
isYou: boolean;
}
/* ------------------------------- Shop -------------------------------- */
export type ShopItemKind = "avatar" | "theme";
export interface ShopItem {
id: string;
kind: ShopItemKind;
nameFa: string;
nameEn: string;
price: number;
preview: string; // emoji/avatar id/color
}
/* --------------------------- Daily reward ---------------------------- */
export interface DailyRewardState {
/** day index 1..7 the player is currently on */
day: number;
/** ISO date (yyyy-mm-dd) the reward was last claimed */
lastClaimed: string | null;
/** whether today's reward is available to claim */
available: boolean;
}
/* ------------------------------ Avatars ------------------------------ */
export const AVATARS: { id: string; emoji: string }[] = [
{ id: "a-fox", emoji: "🦊" },
{ id: "a-lion", emoji: "🦁" },
{ id: "a-owl", emoji: "🦉" },
{ id: "a-tiger", emoji: "🐯" },
{ id: "a-panda", emoji: "🐼" },
{ id: "a-eagle", emoji: "🦅" },
{ id: "a-wolf", emoji: "🐺" },
{ id: "a-cat", emoji: "🐱" },
{ id: "a-dragon", emoji: "🐲" },
{ id: "a-unicorn", emoji: "🦄" },
];
export function avatarEmoji(id: string): string {
return AVATARS.find((a) => a.id === id)?.emoji ?? "🦊";
}
+87
View File
@@ -0,0 +1,87 @@
"use client";
import { create } from "zustand";
import { getService } from "./online/service";
import { AuthSession, UserProfile } from "./online/types";
interface SessionStore {
session: AuthSession | null;
profile: UserProfile | null;
loading: boolean;
isAuthed: boolean;
init: () => Promise<void>;
refreshProfile: () => Promise<void>;
setProfile: (p: UserProfile) => void;
requestOtp: (phone: string) => Promise<{ devCode?: string }>;
verifyOtp: (phone: string, code: string) => Promise<void>;
signInEmail: (email: string, password: string) => Promise<void>;
signUpEmail: (email: string, password: string, name: string) => Promise<void>;
signInGoogle: () => Promise<void>;
signOut: () => Promise<void>;
updateProfile: (patch: Partial<Pick<UserProfile, "displayName" | "avatar">>) => Promise<void>;
}
export const useSessionStore = create<SessionStore>((set, get) => ({
session: null,
profile: null,
loading: true,
isAuthed: false,
init: async () => {
const svc = getService();
const restored = await svc.restore();
if (restored) {
set({ session: restored.session, profile: restored.profile, isAuthed: true, loading: false });
} else {
// ensure a (guest) profile exists so the top bar can render
const profile = await svc.getProfile();
set({ profile, isAuthed: false, loading: false });
}
},
refreshProfile: async () => {
const profile = await getService().getProfile();
set({ profile });
},
setProfile: (p) => set({ profile: p }),
requestOtp: (phone) => getService().requestOtp(phone),
verifyOtp: async (phone, code) => {
const session = await getService().verifyOtp(phone, code);
const profile = await getService().getProfile();
set({ session, profile, isAuthed: true });
},
signInEmail: async (email, password) => {
const session = await getService().signInEmail(email, password);
const profile = await getService().getProfile();
set({ session, profile, isAuthed: true });
},
signUpEmail: async (email, password, name) => {
const session = await getService().signUpEmail(email, password, name);
const profile = await getService().getProfile();
set({ session, profile, isAuthed: true });
},
signInGoogle: async () => {
const session = await getService().signInGoogle();
const profile = await getService().getProfile();
set({ session, profile, isAuthed: true });
},
signOut: async () => {
await getService().signOut();
set({ session: null, isAuthed: false });
},
updateProfile: async (patch) => {
const profile = await getService().updateProfile(patch);
set({ profile });
},
}));
+36
View File
@@ -0,0 +1,36 @@
"use client";
import { create } from "zustand";
export type Screen =
| "home"
| "auth"
| "profile"
| "friends"
| "online" // online lobby (create room / play random)
| "room"
| "matchmaking"
| "leaderboard"
| "shop"
| "game"; // the table (used for both ai + online)
interface UIStore {
screen: Screen;
/** screen to return to from the game table */
returnTo: Screen;
dailyModalOpen: boolean;
go: (screen: Screen) => void;
goGame: (returnTo?: Screen) => void;
openDaily: () => void;
closeDaily: () => void;
}
export const useUIStore = create<UIStore>((set) => ({
screen: "home",
returnTo: "home",
dailyModalOpen: false,
go: (screen) => set({ screen }),
goGame: (returnTo = "home") => set({ screen: "game", returnTo }),
openDaily: () => set({ dailyModalOpen: true }),
closeDaily: () => set({ dailyModalOpen: false }),
}));