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:
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/** Fisher–Yates 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;
|
||||
});
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user