196 lines
5.8 KiB
TypeScript
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;
|
|
}
|
|
}
|