Files
soroush.asadi b739b503eb
CI/CD / CI - API (dotnet build + engine sim) (push) Successful in 21s
CI/CD / CI - Web (tsc + next build) (push) Successful in 1m0s
CI/CD / Deploy - local stack (db + server + web) (push) Failing after 0s
Forfeit = 2x coin loss + 0 XP (no kot); end-of-game roster + add friend
Forfeit penalty reworked (client + server gamification, in sync):
- Surrendering team loses DOUBLE the entry coins; winner takes the stake.
- Forfeiter earns NO XP. No kot is applied or mentioned anymore.
- MatchSummary/Dto carry a `forfeit` flag; GameRoom.FinalizeForfeit →
  ApplyRewardsAsync(team) with Forfeit=true (dropped the kot path).
- Forfeit confirm dialogs now alert the real penalty (double coins, no XP).

End-of-game roster: SeatPlayerDto/ServerSeatPlayer + game-store SeatPlayer gain
userId/isBot. New <MatchPlayersList> lists everyone at the table on the final
screen (PostMatchRewardsModal + AI MatchOverlay) with a tactile "Add" button to
send a friend request to real (non-bot, non-self) players ("Sent" after).

Verified: tsc + sim + dotnet + next build clean; stack rebuilt :1500/:1505.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 10:40:14 +03:30

170 lines
5.0 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, shutoutWins: 0,
hakemRounds: 0, roundsWon: 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",
shutout: won && i % 8 === 0,
hakemRounds: i % 3,
roundsWon: won ? 7 : i % 7,
forfeit: false,
};
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 the first-win achievement (wins_1)
if (won && !firstWinSeen) {
firstWinSeen = true;
assert(after.unlocked.includes("wins_1"), "wins_1 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, shutout: false, hakemRounds: 0, roundsWon: 7, forfeit: false,
}, 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}`
);