Engine, felt, controls, sounds, card skins, and dev popups; SSE events in the realtime hook, nav and command palette entries, stats migration, and the six blackjack test suites. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
247 lines
7.3 KiB
TypeScript
247 lines
7.3 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import type { PublicDealerView, PublicPlayerView } from "@/lib/minigames/blackjack";
|
|
import {
|
|
actionLogText,
|
|
popupEventsForTransition,
|
|
type BlackjackPopupEvent,
|
|
} from "@/components/frontend/minigames/blackjack/blackjackPopups";
|
|
import {
|
|
isTopSeatPosition,
|
|
seatPosition,
|
|
} from "@/components/frontend/minigames/blackjack/seatPosition";
|
|
import type { BlackjackSoundSnapshot } from "@/components/frontend/minigames/blackjack/blackjackSoundEvents";
|
|
|
|
function hand(
|
|
overrides: Partial<PublicPlayerView["hands"][number]> = {},
|
|
): PublicPlayerView["hands"][number] {
|
|
return {
|
|
cards: [],
|
|
bet: 25,
|
|
status: "active",
|
|
doubled: false,
|
|
fromSplit: false,
|
|
splitAces: false,
|
|
total: 21,
|
|
outcome: null,
|
|
netChange: null,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function player(seatId: number, overrides: Partial<PublicPlayerView> = {}): PublicPlayerView {
|
|
return {
|
|
seatId,
|
|
name: `Player ${seatId + 1}`,
|
|
bankroll: 1000,
|
|
hands: [],
|
|
insuranceBet: 0,
|
|
insuranceAnswered: false,
|
|
hasBet: false,
|
|
sittingOut: false,
|
|
bankrupt: false,
|
|
left: false,
|
|
roundsPlayed: 0,
|
|
roundsWon: 0,
|
|
blackjacksHit: 0,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function dealerView(overrides: Partial<PublicDealerView> = {}): PublicDealerView {
|
|
return {
|
|
cards: [],
|
|
holeHidden: true,
|
|
total: null,
|
|
hasBlackjack: null,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function view(
|
|
overrides: Partial<BlackjackSoundSnapshot["you"]> = {},
|
|
): BlackjackSoundSnapshot["you"] {
|
|
return {
|
|
roundNumber: 1,
|
|
phase: "betting",
|
|
players: [player(0), player(1)],
|
|
dealer: dealerView(),
|
|
roundResult: null,
|
|
viewerSeat: 0,
|
|
yourTurn: false,
|
|
yourHands: [],
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function snapshot(
|
|
version: number,
|
|
nextView: BlackjackSoundSnapshot["you"] = view(),
|
|
): BlackjackSoundSnapshot {
|
|
return { version, you: nextView };
|
|
}
|
|
|
|
function bySeat(events: readonly BlackjackPopupEvent[], seatId: number): BlackjackPopupEvent | undefined {
|
|
return events.find((event) => event.seatId === seatId);
|
|
}
|
|
|
|
describe("blackjack popup transition events", () => {
|
|
it("labels a placed bet with its amount over the acting seat", () => {
|
|
const events = popupEventsForTransition(
|
|
snapshot(1, view()),
|
|
snapshot(
|
|
2,
|
|
view({ players: [player(0, { hasBet: true, hands: [hand({ bet: 25 })] }), player(1)] }),
|
|
),
|
|
);
|
|
const bettor = bySeat(events, 0);
|
|
expect(bettor?.tone).toBe("wager");
|
|
expect(bettor?.text).toBe("Bet +25");
|
|
});
|
|
|
|
it("labels insurance, splits, and doubles over the acting seat", () => {
|
|
const events = popupEventsForTransition(
|
|
snapshot(
|
|
1,
|
|
view({
|
|
players: [
|
|
player(0, { hasBet: true, hands: [hand()] }),
|
|
player(1, { hasBet: true, hands: [hand()] }),
|
|
],
|
|
}),
|
|
),
|
|
snapshot(
|
|
2,
|
|
view({
|
|
players: [
|
|
player(0, {
|
|
hasBet: true,
|
|
insuranceBet: 5,
|
|
hands: [hand({ doubled: true, bet: 50 })],
|
|
}),
|
|
player(1, { hasBet: true, hands: [hand(), hand({ fromSplit: true })] }),
|
|
],
|
|
}),
|
|
),
|
|
);
|
|
expect(events.some((event) => event.text === "Insurance +5")).toBe(true);
|
|
expect(events.some((event) => event.text.startsWith("Double +"))).toBe(true);
|
|
const splitter = events.find((event) => event.seatId === 1 && event.text === "Split");
|
|
expect(splitter?.tone).toBe("action");
|
|
});
|
|
|
|
it("labels busts and naturals as hand-status changes", () => {
|
|
const events = popupEventsForTransition(
|
|
snapshot(
|
|
1,
|
|
view({
|
|
phase: "player",
|
|
players: [player(0, { hasBet: true, hands: [hand()] }), player(1, { hasBet: true, hands: [hand()] })],
|
|
}),
|
|
),
|
|
snapshot(
|
|
2,
|
|
view({
|
|
phase: "player",
|
|
players: [
|
|
player(0, { hasBet: true, hands: [hand({ status: "bust" })] }),
|
|
player(1, { hasBet: true, hands: [hand({ status: "blackjack" })] }),
|
|
],
|
|
}),
|
|
),
|
|
);
|
|
expect(bySeat(events, 0)?.text).toBe("Bust");
|
|
expect(bySeat(events, 0)?.tone).toBe("fold");
|
|
expect(bySeat(events, 1)?.text).toBe("Blackjack!");
|
|
expect(bySeat(events, 1)?.tone).toBe("win");
|
|
});
|
|
|
|
it("fires one aggregated win popup per seat when a round settles", () => {
|
|
const settledView = view({
|
|
phase: "round-complete",
|
|
roundResult: {
|
|
roundNumber: 1,
|
|
dealerTotal: 22,
|
|
dealerBusted: true,
|
|
dealerHasBlackjack: false,
|
|
hands: [
|
|
{
|
|
seatId: 0,
|
|
handIndex: 0,
|
|
total: 20,
|
|
outcome: "win",
|
|
payout: 50,
|
|
netChange: 50,
|
|
},
|
|
{
|
|
seatId: 1,
|
|
handIndex: 0,
|
|
total: 18,
|
|
outcome: "win",
|
|
payout: 25,
|
|
netChange: 25,
|
|
},
|
|
],
|
|
insurance: [],
|
|
},
|
|
});
|
|
const events = popupEventsForTransition(snapshot(1, view()), snapshot(2, settledView));
|
|
expect(bySeat(events, 0)?.text).toBe("+50");
|
|
expect(bySeat(events, 0)?.tone).toBe("win");
|
|
expect(bySeat(events, 1)?.text).toBe("+25");
|
|
});
|
|
|
|
it("announces leavers and bankrupt players with leave tone", () => {
|
|
const events = popupEventsForTransition(
|
|
snapshot(1, view()),
|
|
snapshot(
|
|
2,
|
|
view({
|
|
players: [player(0, { left: true }), player(1, { bankrupt: true })],
|
|
}),
|
|
),
|
|
);
|
|
expect(bySeat(events, 0)?.tone).toBe("leave");
|
|
expect(bySeat(events, 0)?.text).toBe("Player 1 left the table");
|
|
expect(bySeat(events, 1)?.text).toBe("Player 2 is out of chips");
|
|
});
|
|
|
|
it("returns no events for the same version", () => {
|
|
const current = snapshot(3, view());
|
|
expect(popupEventsForTransition(current, current)).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe("actionLogText", () => {
|
|
it("prefixes the acting player's name", () => {
|
|
const players = [player(0, { name: "Harlan" })];
|
|
expect(
|
|
actionLogText({ key: "k", seatId: 0, text: "Bet +25", tone: "wager" }, players),
|
|
).toBe("Harlan: Bet +25");
|
|
expect(
|
|
actionLogText({ key: "k", seatId: 0, text: "+50", tone: "win" }, players),
|
|
).toBe("Harlan wins +50");
|
|
expect(
|
|
actionLogText({ key: "k", seatId: 0, text: "Gone", tone: "leave" }, players),
|
|
).toBe("Gone");
|
|
expect(
|
|
actionLogText({ key: "k", seatId: 4, text: "Bet +25", tone: "wager" }, players),
|
|
).toBe("Seat 5: Bet +25");
|
|
});
|
|
});
|
|
|
|
describe("seatPosition", () => {
|
|
it("spreads one through five opponents across the bottom two-thirds arc", () => {
|
|
expect(seatPosition(0, 1)).toBe("blackjack-seat-arc-corner-right");
|
|
expect(seatPosition(0, 2)).toBe("blackjack-seat-arc-corner-right");
|
|
expect(seatPosition(1, 2)).toBe("blackjack-seat-arc-corner-left");
|
|
expect(seatPosition(2, 5)).toBe("blackjack-seat-arc-low-right");
|
|
expect(seatPosition(4, 5)).toBe("blackjack-seat-arc-corner-left");
|
|
});
|
|
|
|
it("classifies the dealer-adjacent corner seats for popup placement", () => {
|
|
expect(isTopSeatPosition("blackjack-seat-arc-corner-right")).toBe(true);
|
|
expect(isTopSeatPosition("blackjack-seat-arc-corner-left")).toBe(true);
|
|
expect(isTopSeatPosition("blackjack-seat-arc-low-right")).toBe(false);
|
|
expect(isTopSeatPosition("blackjack-seat-arc-mid-left")).toBe(false);
|
|
});
|
|
});
|