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
+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;
}