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:
+156
@@ -0,0 +1,156 @@
|
||||
// Quick engine sanity sim: play full all-AI matches, assert no illegal states.
|
||||
import { chooseCardAI, chooseTrumpAI } from "../src/lib/hokm/ai";
|
||||
import {
|
||||
advanceAfterTrick,
|
||||
chooseTrump,
|
||||
createInitialState,
|
||||
dealForTrump,
|
||||
playCard,
|
||||
selectHakem,
|
||||
startNextRound,
|
||||
} from "../src/lib/hokm/engine";
|
||||
import { GameState } from "../src/lib/hokm/types";
|
||||
import { applyMatchResult, getLeagueInfo } from "../src/lib/online/gamification";
|
||||
import { MatchSummary, UserProfile } from "../src/lib/online/types";
|
||||
|
||||
function playMatch(seed: number): { rounds: number; tricks: number } {
|
||||
let s = createInitialState({
|
||||
names: ["P0", "P1", "P2", "P3"],
|
||||
targetScore: 7,
|
||||
});
|
||||
s = selectHakem(s);
|
||||
s = dealForTrump(s);
|
||||
let rounds = 0;
|
||||
let tricks = 0;
|
||||
let guard = 0;
|
||||
|
||||
while (s.phase !== "match-over") {
|
||||
guard++;
|
||||
if (guard > 100000) throw new Error("loop guard tripped");
|
||||
|
||||
if (s.phase === "choosing-trump") {
|
||||
const suit = chooseTrumpAI(s.players[s.hakem!].hand);
|
||||
s = chooseTrump(s, suit);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (s.phase === "playing") {
|
||||
const seat = s.turn!;
|
||||
const card = chooseCardAI(s, seat);
|
||||
s = playCard(s, seat, card);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (s.phase === "trick-complete") {
|
||||
tricks++;
|
||||
// sanity: 4 cards in trick
|
||||
if (s.currentTrick.length !== 4) throw new Error("trick not full");
|
||||
s = advanceAfterTrick(s, 2);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (s.phase === "round-over") {
|
||||
rounds++;
|
||||
// sanity: total tricks this round <= 13
|
||||
const total = s.roundTricks[0] + s.roundTricks[1];
|
||||
if (total > 13) throw new Error("too many tricks: " + total);
|
||||
s = startNextRound(s);
|
||||
continue;
|
||||
}
|
||||
|
||||
throw new Error("unexpected phase: " + s.phase);
|
||||
}
|
||||
return { rounds, tricks };
|
||||
}
|
||||
|
||||
let totalRounds = 0;
|
||||
const N = 200;
|
||||
for (let i = 0; i < N; i++) {
|
||||
const r = playMatch(i);
|
||||
totalRounds += r.rounds;
|
||||
}
|
||||
console.log(`OK: ${N} matches completed. avg rounds/match = ${(totalRounds / N).toFixed(1)}`);
|
||||
|
||||
/* ----------------------- gamification checks ----------------------- */
|
||||
|
||||
function baseProfile(): UserProfile {
|
||||
return {
|
||||
id: "u",
|
||||
username: "u",
|
||||
displayName: "Tester",
|
||||
avatar: "a-fox",
|
||||
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: ["a-fox"],
|
||||
ownedThemes: ["royal"],
|
||||
achievements: {},
|
||||
unlocked: [],
|
||||
createdAt: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function assert(cond: boolean, msg: string) {
|
||||
if (!cond) throw new Error("ASSERT FAILED: " + msg);
|
||||
}
|
||||
|
||||
let profile = baseProfile();
|
||||
let firstWinSeen = false;
|
||||
const M = 500;
|
||||
for (let i = 0; i < M; i++) {
|
||||
const won = (i * 7 + 3) % 5 < 3; // deterministic-ish mix
|
||||
const kot = i % 6 === 0;
|
||||
const summary: MatchSummary = {
|
||||
ranked: true,
|
||||
stake: 100,
|
||||
won,
|
||||
kotFor: won && kot,
|
||||
kotAgainst: !won && kot,
|
||||
tricksWon: won ? 7 + (i % 6) : i % 7,
|
||||
rounds: 7,
|
||||
trump: "spades",
|
||||
};
|
||||
const before = profile;
|
||||
const { profile: after, reward } = applyMatchResult(before, summary, 1000);
|
||||
|
||||
// rating moves the right way for ranked
|
||||
if (won) assert(reward.ratingDelta > 0, "ranked win should raise rating");
|
||||
else assert(reward.ratingDelta < 0, "ranked loss should lower rating");
|
||||
// coins never negative
|
||||
assert(after.coins >= 0, "coins never negative");
|
||||
// xp gained, level monotonic
|
||||
assert(reward.xpGained > 0, "xp gained");
|
||||
assert(after.level >= before.level, "level monotonic");
|
||||
// unlocked list only grows
|
||||
assert(after.unlocked.length >= before.unlocked.length, "achievements monotonic");
|
||||
// first win unlocks first_win
|
||||
if (won && !firstWinSeen) {
|
||||
firstWinSeen = true;
|
||||
assert(after.unlocked.includes("first_win"), "first_win unlocks on first win");
|
||||
}
|
||||
profile = after;
|
||||
}
|
||||
|
||||
// casual match must not change rating
|
||||
{
|
||||
const r = applyMatchResult(baseProfile(), {
|
||||
ranked: false, stake: 0, won: true, kotFor: false, kotAgainst: false,
|
||||
tricksWon: 7, rounds: 7, trump: null,
|
||||
}, 1000);
|
||||
assert(r.reward.ratingDelta === 0, "casual match must not change rating");
|
||||
}
|
||||
|
||||
// league boundaries sane
|
||||
assert(getLeagueInfo(1000).tier.id === "bronze", "1000 = bronze");
|
||||
assert(getLeagueInfo(1350).tier.id === "gold", "1350 = gold");
|
||||
assert(getLeagueInfo(2000).tier.id === "master", "2000 = master");
|
||||
|
||||
console.log(
|
||||
`OK: gamification ${M} results. final level=${profile.level} rating=${Math.round(profile.rating)} ` +
|
||||
`coins=${profile.coins} achievements=${profile.unlocked.length} league=${getLeagueInfo(profile.rating).tier.id}`
|
||||
);
|
||||
Reference in New Issue
Block a user