Files
scopone/src/game/card-tracker.ts
2026-04-09 22:30:27 +02:00

196 lines
5.8 KiB
TypeScript

import { Card, Suit, SUITS } from './types';
export interface CardTrackerSnapshot {
playedCardIds: string[];
}
export interface CardTrackerValueRankResidue {
value: number;
knownCount: number;
unseenCount: number;
hasSingletonUnseenRankResidue: boolean;
hasPairedUnseenRankResidue: boolean;
}
interface VisibleValueResidueKnowledge {
unseenCards: Card[];
unseenCountBySuit: Record<Suit, number>;
unseenCountByValue: number[];
valueRankResidues: CardTrackerValueRankResidue[];
}
function normalizeSnapshot(snapshot: CardTrackerSnapshot): CardTrackerSnapshot {
return {
playedCardIds: Array.from(new Set(snapshot.playedCardIds)),
};
}
function createEmptySuitCounts(): Record<Suit, number> {
const counts = {} as Record<Suit, number>;
for (const suit of SUITS) {
counts[suit] = 0;
}
return counts;
}
/**
* Tracks which cards have been played/captured during a round.
* Used by AI to infer opponent hands WITHOUT cheating.
*/
export class CardTracker {
private played: Set<string> = new Set(); // card IDs that have been seen
constructor(snapshot?: CardTrackerSnapshot) {
if (snapshot) {
this.restoreSnapshot(snapshot);
}
}
static fromSnapshot(snapshot: CardTrackerSnapshot): CardTracker {
return new CardTracker(snapshot);
}
toSnapshot(): CardTrackerSnapshot {
return {
playedCardIds: [...this.played],
};
}
restoreSnapshot(snapshot: CardTrackerSnapshot): void {
const normalized = normalizeSnapshot(snapshot);
this.played = new Set(normalized.playedCardIds);
}
/** Record a card being played to the table */
trackPlay(card: Card): void {
this.played.add(card.id);
}
/** Record cards captured from the table */
trackCapture(cards: Card[]): void {
for (const c of cards) {
this.played.add(c.id);
}
}
/** Reset for a new round */
reset(): void {
this.played.clear();
}
/** Has a specific card been seen played? */
hasBeenPlayed(cardId: string): boolean {
return this.played.has(cardId);
}
/** Is the settebello (7 of denara) still unseen (not played/captured)? */
isSettebelloUnseen(): boolean {
return !this.played.has('denara_7');
}
private buildVisibleValueResidueKnowledge(myHand: Card[], table: Card[]): VisibleValueResidueKnowledge {
const knownCardIds = new Set<string>(this.played);
for (const card of myHand) {
knownCardIds.add(card.id);
}
for (const card of table) {
knownCardIds.add(card.id);
}
const knownCountByValue = Array.from({ length: 11 }, () => 0);
const unseenCountByValue = Array.from({ length: 11 }, () => 0);
const unseenCountBySuit = createEmptySuitCounts();
const unseenCards: Card[] = [];
for (const suit of SUITS) {
for (let value = 1; value <= 10; value++) {
const id = `${suit}_${value}`;
if (knownCardIds.has(id)) {
knownCountByValue[value] += 1;
continue;
}
unseenCountByValue[value] += 1;
unseenCountBySuit[suit] += 1;
unseenCards.push({ suit, value, id });
}
}
const valueRankResidues: CardTrackerValueRankResidue[] = [];
for (let value = 1; value <= 10; value++) {
const unseenCount = unseenCountByValue[value];
valueRankResidues.push({
value,
knownCount: knownCountByValue[value],
unseenCount,
hasSingletonUnseenRankResidue: unseenCount % 2 === 1,
hasPairedUnseenRankResidue: unseenCount >= 2 && unseenCount % 2 === 0,
});
}
return {
unseenCards,
unseenCountBySuit,
unseenCountByValue,
valueRankResidues,
};
}
/**
* Get cards that could be in opponent hands.
* = full 40-card deck minus: already played, my hand, currently on table
*/
getUnseenCards(myHand: Card[], table: Card[]): Card[] {
return this.buildVisibleValueResidueKnowledge(myHand, table).unseenCards;
}
/** Count how many cards of a suit are still unseen */
countRemainingSuit(suit: Suit, myHand: Card[], table: Card[]): number {
return this.buildVisibleValueResidueKnowledge(myHand, table).unseenCountBySuit[suit];
}
/** Count how many unseen cards share a value */
countRemainingValue(value: number, myHand: Card[], table: Card[]): number {
return this.getValueRankResidue(value, myHand, table).unseenCount;
}
/** Get visible known-count, unseen-count, and same-rank residue for a single value */
getValueRankResidue(value: number, myHand: Card[], table: Card[]): CardTrackerValueRankResidue {
const valueRankResidues = this.buildVisibleValueResidueKnowledge(myHand, table).valueRankResidues;
return valueRankResidues[value - 1] ?? {
value,
knownCount: 0,
unseenCount: 0,
hasSingletonUnseenRankResidue: false,
hasPairedUnseenRankResidue: false,
};
}
/** Get visible known-count, unseen-count, and same-rank residue for all card values */
getValueRankResidueSummary(myHand: Card[], table: Card[]): CardTrackerValueRankResidue[] {
return this.buildVisibleValueResidueKnowledge(myHand, table).valueRankResidues;
}
/** Probability that a hidden hand contains at least one card with the requested value */
probabilityHandHasValue(value: number, handSize: number, myHand: Card[], table: Card[]): number {
if (handSize <= 0) return 0;
const visibleValueResidueKnowledge = this.buildVisibleValueResidueKnowledge(myHand, table);
const unseen = visibleValueResidueKnowledge.unseenCards;
const matching = visibleValueResidueKnowledge.unseenCountByValue[value] ?? 0;
if (matching === 0) return 0;
if (handSize >= unseen.length) return 1;
let probNone = 1;
for (let i = 0; i < handSize; i++) {
probNone *= Math.max(0, unseen.length - matching - i) / (unseen.length - i);
}
return 1 - probNone;
}
/** Get count of all played/seen cards */
get playedCount(): number {
return this.played.size;
}
}