ae239f4c51
Card design: - Separate cardFront + cardBack (each own/equip independently) - Fronts: classic (free), ivory/rosegold (buy), parchment/mint (earned) - Backs: classic (free), sapphire/emerald (buy), ruby/royal (earned) - PlayingCard `front` prop; table applies front to all faces, back to opponents - Profile has front + back pickers; shop has both sections Audio: - Web Audio synth engine (no asset files): SFX for card/deal/trump/trick, win/lose, message, notify, award, levelup, purchase, kot + ambient music - Toggles in profile (Audio) + mute button in game HUD; prefs persisted - Wired across game-store, rewards, daily, shop, chat Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
165 lines
4.7 KiB
TypeScript
165 lines
4.7 KiB
TypeScript
// 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,
|
|
},
|
|
plan: "free",
|
|
ownedAvatars: ["a-fox"],
|
|
ownedCardFronts: ["classic"],
|
|
ownedCardBacks: ["classic"],
|
|
ownedTitles: ["novice"],
|
|
ownedReactionPacks: [],
|
|
ownedStickerPacks: [],
|
|
title: "novice",
|
|
cardFront: "classic",
|
|
cardBack: "classic",
|
|
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}`
|
|
);
|