2026-04-20 15:16:03 +08:00
|
|
|
import { describe, it, expect } from "vitest";
|
|
|
|
|
import { createGameContext } from "@/core/game";
|
|
|
|
|
import { registry } from "@/samples/regicide/commands";
|
|
|
|
|
import { createInitialState } from "@/samples/regicide/state";
|
2026-04-10 13:43:12 +08:00
|
|
|
import {
|
2026-04-20 15:16:03 +08:00
|
|
|
buildEnemyDeck,
|
|
|
|
|
buildTavernDeck,
|
|
|
|
|
createAllCards,
|
|
|
|
|
createCard,
|
|
|
|
|
createEnemy,
|
|
|
|
|
getCardValue,
|
|
|
|
|
isEnemyDefeated,
|
|
|
|
|
} from "@/samples/regicide/utils";
|
|
|
|
|
import { Mulberry32RNG } from "@/utils/rng";
|
|
|
|
|
import {
|
|
|
|
|
CARD_VALUES,
|
|
|
|
|
ENEMY_COUNT,
|
|
|
|
|
FACE_CARDS,
|
|
|
|
|
INITIAL_HAND_SIZE,
|
|
|
|
|
} from "@/samples/regicide/constants";
|
|
|
|
|
import { PlayerType } from "@/samples/regicide/types";
|
|
|
|
|
|
|
|
|
|
describe("Regicide - Utils", () => {
|
|
|
|
|
describe("getCardValue", () => {
|
|
|
|
|
it("should return correct value for number cards", () => {
|
|
|
|
|
expect(getCardValue("A")).toBe(1);
|
|
|
|
|
expect(getCardValue("5")).toBe(5);
|
|
|
|
|
expect(getCardValue("10")).toBe(10);
|
2026-04-10 13:43:12 +08:00
|
|
|
});
|
|
|
|
|
|
2026-04-20 15:16:03 +08:00
|
|
|
it("should return correct value for face cards", () => {
|
|
|
|
|
expect(getCardValue("J")).toBe(10);
|
|
|
|
|
expect(getCardValue("Q")).toBe(15);
|
|
|
|
|
expect(getCardValue("K")).toBe(20);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe("createCard", () => {
|
|
|
|
|
it("should create a card with correct properties", () => {
|
|
|
|
|
const card = createCard("spades_A", "spades", "A");
|
|
|
|
|
expect(card.id).toBe("spades_A");
|
|
|
|
|
expect(card.suit).toBe("spades");
|
|
|
|
|
expect(card.rank).toBe("A");
|
|
|
|
|
expect(card.value).toBe(1);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe("createEnemy", () => {
|
|
|
|
|
it("should create an enemy with correct HP", () => {
|
|
|
|
|
const enemy = createEnemy("enemy_0", "J", "spades");
|
|
|
|
|
expect(enemy.rank).toBe("J");
|
|
|
|
|
expect(enemy.value).toBe(10);
|
|
|
|
|
expect(enemy.hp).toBe(20);
|
|
|
|
|
expect(enemy.maxHp).toBe(20);
|
2026-04-10 13:43:12 +08:00
|
|
|
});
|
|
|
|
|
|
2026-04-20 15:16:03 +08:00
|
|
|
it("should create enemy with different values for different ranks", () => {
|
|
|
|
|
const jEnemy = createEnemy("enemy_0", "J", "spades");
|
|
|
|
|
const qEnemy = createEnemy("enemy_1", "Q", "hearts");
|
|
|
|
|
const kEnemy = createEnemy("enemy_2", "K", "diamonds");
|
|
|
|
|
|
|
|
|
|
expect(jEnemy.value).toBe(10);
|
|
|
|
|
expect(qEnemy.value).toBe(15);
|
|
|
|
|
expect(kEnemy.value).toBe(20);
|
|
|
|
|
|
|
|
|
|
expect(jEnemy.hp).toBe(20);
|
|
|
|
|
expect(qEnemy.hp).toBe(30);
|
|
|
|
|
expect(kEnemy.hp).toBe(40);
|
2026-04-10 13:43:12 +08:00
|
|
|
});
|
2026-04-20 15:16:03 +08:00
|
|
|
});
|
2026-04-10 13:43:12 +08:00
|
|
|
|
2026-04-20 15:16:03 +08:00
|
|
|
describe("createAllCards", () => {
|
|
|
|
|
it("should create 52 cards", () => {
|
|
|
|
|
const cards = createAllCards();
|
|
|
|
|
expect(Object.keys(cards).length).toBe(52);
|
2026-04-10 13:43:12 +08:00
|
|
|
});
|
|
|
|
|
|
2026-04-20 15:16:03 +08:00
|
|
|
it("should have all suits and ranks", () => {
|
|
|
|
|
const cards = createAllCards();
|
|
|
|
|
const suits = ["spades", "hearts", "diamonds", "clubs"];
|
|
|
|
|
const ranks = [
|
|
|
|
|
"A",
|
|
|
|
|
"2",
|
|
|
|
|
"3",
|
|
|
|
|
"4",
|
|
|
|
|
"5",
|
|
|
|
|
"6",
|
|
|
|
|
"7",
|
|
|
|
|
"8",
|
|
|
|
|
"9",
|
|
|
|
|
"10",
|
|
|
|
|
"J",
|
|
|
|
|
"Q",
|
|
|
|
|
"K",
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
for (const suit of suits) {
|
|
|
|
|
for (const rank of ranks) {
|
|
|
|
|
const id = `${suit}_${rank}`;
|
|
|
|
|
expect(cards[id]).toBeDefined();
|
|
|
|
|
expect(cards[id].suit).toBe(suit);
|
|
|
|
|
expect(cards[id].rank).toBe(rank);
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-04-10 13:43:12 +08:00
|
|
|
});
|
2026-04-20 15:16:03 +08:00
|
|
|
});
|
2026-04-10 13:43:12 +08:00
|
|
|
|
2026-04-20 15:16:03 +08:00
|
|
|
describe("buildEnemyDeck", () => {
|
|
|
|
|
it("should create 12 enemies (J/Q/K)", () => {
|
|
|
|
|
const rng = new Mulberry32RNG(12345);
|
|
|
|
|
const deck = buildEnemyDeck(rng);
|
|
|
|
|
expect(deck.length).toBe(12);
|
2026-04-10 13:43:12 +08:00
|
|
|
});
|
|
|
|
|
|
2026-04-20 15:16:03 +08:00
|
|
|
it("should have J at top, Q in middle, K at bottom", () => {
|
|
|
|
|
const rng = new Mulberry32RNG(12345);
|
|
|
|
|
const deck = buildEnemyDeck(rng);
|
2026-04-10 13:43:12 +08:00
|
|
|
|
2026-04-20 15:16:03 +08:00
|
|
|
for (let i = 0; i < 4; i++) {
|
|
|
|
|
expect(deck[i].rank).toBe("J");
|
|
|
|
|
}
|
2026-04-10 13:43:12 +08:00
|
|
|
|
2026-04-20 15:16:03 +08:00
|
|
|
for (let i = 4; i < 8; i++) {
|
|
|
|
|
expect(deck[i].rank).toBe("Q");
|
|
|
|
|
}
|
2026-04-10 13:43:12 +08:00
|
|
|
|
2026-04-20 15:16:03 +08:00
|
|
|
for (let i = 8; i < 12; i++) {
|
|
|
|
|
expect(deck[i].rank).toBe("K");
|
|
|
|
|
}
|
2026-04-10 13:43:12 +08:00
|
|
|
});
|
2026-04-20 15:16:03 +08:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe("buildTavernDeck", () => {
|
|
|
|
|
it("should create 40 cards (A-10)", () => {
|
|
|
|
|
const rng = new Mulberry32RNG(12345);
|
|
|
|
|
const deck = buildTavernDeck(rng);
|
|
|
|
|
expect(deck.length).toBe(40);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("should not contain face cards", () => {
|
|
|
|
|
const rng = new Mulberry32RNG(12345);
|
|
|
|
|
const deck = buildTavernDeck(rng);
|
|
|
|
|
|
|
|
|
|
for (const card of deck) {
|
|
|
|
|
expect(FACE_CARDS.includes(card.rank)).toBe(false);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe("isEnemyDefeated", () => {
|
|
|
|
|
it("should return true when enemy HP <= 0", () => {
|
|
|
|
|
const enemy = createEnemy("enemy_0", "J", "spades");
|
|
|
|
|
expect(isEnemyDefeated(enemy)).toBe(false);
|
|
|
|
|
|
|
|
|
|
enemy.hp = 0;
|
|
|
|
|
expect(isEnemyDefeated(enemy)).toBe(true);
|
|
|
|
|
|
|
|
|
|
enemy.hp = -5;
|
|
|
|
|
expect(isEnemyDefeated(enemy)).toBe(true);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("should return false for null enemy", () => {
|
|
|
|
|
expect(isEnemyDefeated(null)).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
});
|
2026-04-10 13:43:12 +08:00
|
|
|
});
|
|
|
|
|
|
2026-04-20 15:16:03 +08:00
|
|
|
describe("Regicide - Commands", () => {
|
|
|
|
|
function createTestContext() {
|
|
|
|
|
const initialState = createInitialState();
|
|
|
|
|
return createGameContext(registry, initialState);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function setupTestGame(game: ReturnType<typeof createTestContext>) {
|
|
|
|
|
const cards = createAllCards();
|
|
|
|
|
const rng = new Mulberry32RNG(12345);
|
|
|
|
|
const enemyDeck = buildEnemyDeck(rng);
|
|
|
|
|
const tavernDeck = buildTavernDeck(rng);
|
|
|
|
|
|
|
|
|
|
game.produce((state) => {
|
|
|
|
|
state.cards = cards;
|
|
|
|
|
state.playerCount = 2;
|
|
|
|
|
state.currentPlayerIndex = 0;
|
|
|
|
|
state.enemyDeck = [...enemyDeck];
|
|
|
|
|
state.currentEnemy = { ...enemyDeck[0] };
|
|
|
|
|
|
|
|
|
|
for (const card of tavernDeck) {
|
|
|
|
|
state.regions.tavernDeck.childIds.push(card.id);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for (let i = 0; i < 6; i++) {
|
|
|
|
|
const card1 = tavernDeck[i];
|
|
|
|
|
const card2 = tavernDeck[i + 6];
|
|
|
|
|
card1.regionId = "hand_player1";
|
|
|
|
|
card2.regionId = "hand_player2";
|
|
|
|
|
state.playerHands.player1.push(card1.id);
|
|
|
|
|
state.playerHands.player2.push(card2.id);
|
|
|
|
|
state.regions.hand_player1.childIds.push(card1.id);
|
|
|
|
|
state.regions.hand_player2.childIds.push(card2.id);
|
|
|
|
|
}
|
2026-04-10 13:43:12 +08:00
|
|
|
});
|
2026-04-20 15:16:03 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
describe("play command", () => {
|
|
|
|
|
it("should deal damage to current enemy", async () => {
|
|
|
|
|
const game = createTestContext();
|
|
|
|
|
setupTestGame(game);
|
2026-04-10 13:43:12 +08:00
|
|
|
|
2026-04-20 15:16:03 +08:00
|
|
|
const enemyHpBefore = game.value.currentEnemy!.hp;
|
|
|
|
|
const cardId = game.value.playerHands.player1[0];
|
|
|
|
|
const card = game.value.cards[cardId];
|
2026-04-10 13:43:12 +08:00
|
|
|
|
2026-04-20 15:16:03 +08:00
|
|
|
const result = await game.run(`play player1 ${cardId}`);
|
2026-04-10 13:43:12 +08:00
|
|
|
|
2026-04-20 15:16:03 +08:00
|
|
|
expect(game.value.currentEnemy!.hp).toBe(enemyHpBefore - card.value);
|
2026-04-10 13:43:12 +08:00
|
|
|
});
|
|
|
|
|
|
2026-04-20 15:16:03 +08:00
|
|
|
it("should double damage for clubs suit", async () => {
|
|
|
|
|
const game = createTestContext();
|
|
|
|
|
setupTestGame(game);
|
2026-04-10 13:43:12 +08:00
|
|
|
|
2026-04-20 15:16:03 +08:00
|
|
|
game.produce((state) => {
|
|
|
|
|
state.cards["clubs_5"] = createCard("clubs_5", "clubs", "5");
|
|
|
|
|
state.playerHands.player1.push("clubs_5");
|
|
|
|
|
state.regions.hand_player1.childIds.push("clubs_5");
|
|
|
|
|
});
|
2026-04-10 13:43:12 +08:00
|
|
|
|
2026-04-20 15:16:03 +08:00
|
|
|
const clubsCardId = "clubs_5";
|
|
|
|
|
const enemyHpBefore = game.value.currentEnemy!.hp;
|
|
|
|
|
const card = game.value.cards[clubsCardId];
|
2026-04-10 13:43:12 +08:00
|
|
|
|
2026-04-20 15:16:03 +08:00
|
|
|
await game.run(`play player1 ${clubsCardId}`);
|
2026-04-10 13:43:12 +08:00
|
|
|
|
2026-04-20 15:16:03 +08:00
|
|
|
expect(game.value.currentEnemy!.hp).toBe(enemyHpBefore - card.value * 2);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe("pass command", () => {
|
|
|
|
|
it("should allow player to pass", async () => {
|
|
|
|
|
const game = createTestContext();
|
|
|
|
|
setupTestGame(game);
|
|
|
|
|
|
|
|
|
|
const result = await game.run("pass player1");
|
|
|
|
|
|
|
|
|
|
expect(result.success).toBe(true);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe("check-enemy command", () => {
|
|
|
|
|
it("should detect defeated enemy and reveal next", async () => {
|
|
|
|
|
const game = createTestContext();
|
|
|
|
|
setupTestGame(game);
|
|
|
|
|
|
|
|
|
|
const firstEnemy = game.value.currentEnemy!;
|
|
|
|
|
game.produce((state) => {
|
|
|
|
|
state.currentEnemy!.hp = 0;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await game.run("check-enemy");
|
2026-04-10 13:43:12 +08:00
|
|
|
|
2026-04-20 15:16:03 +08:00
|
|
|
expect(game.value.regions.discardPile.childIds).toContain(firstEnemy.id);
|
|
|
|
|
expect(game.value.currentEnemy).not.toBe(firstEnemy);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("should not defeat enemy if HP > 0", async () => {
|
|
|
|
|
const game = createTestContext();
|
|
|
|
|
setupTestGame(game);
|
2026-04-10 13:43:12 +08:00
|
|
|
|
2026-04-20 15:16:03 +08:00
|
|
|
const currentEnemyId = game.value.currentEnemy!.id;
|
2026-04-10 13:43:12 +08:00
|
|
|
|
2026-04-20 15:16:03 +08:00
|
|
|
await game.run("check-enemy");
|
|
|
|
|
|
|
|
|
|
expect(game.value.currentEnemy!.id).toBe(currentEnemyId);
|
2026-04-10 13:43:12 +08:00
|
|
|
});
|
2026-04-20 15:16:03 +08:00
|
|
|
});
|
2026-04-10 13:43:12 +08:00
|
|
|
|
2026-04-20 15:16:03 +08:00
|
|
|
describe("next-turn command", () => {
|
|
|
|
|
it("should switch to next player", async () => {
|
|
|
|
|
const game = createTestContext();
|
|
|
|
|
setupTestGame(game);
|
2026-04-10 13:43:12 +08:00
|
|
|
|
2026-04-20 15:16:03 +08:00
|
|
|
expect(game.value.currentPlayerIndex).toBe(0);
|
2026-04-10 13:43:12 +08:00
|
|
|
|
2026-04-20 15:16:03 +08:00
|
|
|
await game.run("next-turn");
|
2026-04-10 13:43:12 +08:00
|
|
|
|
2026-04-20 15:16:03 +08:00
|
|
|
expect(game.value.currentPlayerIndex).toBe(1);
|
|
|
|
|
});
|
2026-04-10 13:43:12 +08:00
|
|
|
|
2026-04-20 15:16:03 +08:00
|
|
|
it("should wrap around to first player", async () => {
|
|
|
|
|
const game = createTestContext();
|
|
|
|
|
setupTestGame(game);
|
2026-04-10 13:43:12 +08:00
|
|
|
|
2026-04-20 15:16:03 +08:00
|
|
|
game.produce((state) => {
|
|
|
|
|
state.currentPlayerIndex = 1;
|
|
|
|
|
});
|
2026-04-10 13:43:12 +08:00
|
|
|
|
2026-04-20 15:16:03 +08:00
|
|
|
await game.run("next-turn");
|
2026-04-10 13:43:12 +08:00
|
|
|
|
2026-04-20 15:16:03 +08:00
|
|
|
expect(game.value.currentPlayerIndex).toBe(0);
|
2026-04-10 13:43:12 +08:00
|
|
|
});
|
2026-04-20 15:16:03 +08:00
|
|
|
});
|
2026-04-10 13:43:12 +08:00
|
|
|
});
|
|
|
|
|
|
2026-04-20 15:16:03 +08:00
|
|
|
describe("Regicide - Game Flow", () => {
|
|
|
|
|
function createTestContext() {
|
|
|
|
|
const initialState = createInitialState();
|
|
|
|
|
return createGameContext(registry, initialState);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
it("should complete a full turn cycle", async () => {
|
|
|
|
|
const game = createTestContext();
|
|
|
|
|
|
|
|
|
|
const cards = createAllCards();
|
|
|
|
|
const rng = new Mulberry32RNG(12345);
|
|
|
|
|
const enemyDeck = buildEnemyDeck(rng);
|
|
|
|
|
const tavernDeck = buildTavernDeck(rng);
|
|
|
|
|
|
|
|
|
|
game.produce((state) => {
|
|
|
|
|
state.cards = cards;
|
|
|
|
|
state.playerCount = 1;
|
|
|
|
|
state.currentPlayerIndex = 0;
|
|
|
|
|
state.enemyDeck = [...enemyDeck.slice(1)];
|
|
|
|
|
state.currentEnemy = { ...enemyDeck[0] };
|
|
|
|
|
|
|
|
|
|
for (const card of tavernDeck) {
|
|
|
|
|
state.regions.tavernDeck.childIds.push(card.id);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for (let i = 0; i < 6; i++) {
|
|
|
|
|
const card = tavernDeck[i];
|
|
|
|
|
card.regionId = "hand_player1";
|
|
|
|
|
state.playerHands.player1.push(card.id);
|
|
|
|
|
state.regions.hand_player1.childIds.push(card.id);
|
|
|
|
|
}
|
2026-04-10 13:43:12 +08:00
|
|
|
});
|
|
|
|
|
|
2026-04-20 15:16:03 +08:00
|
|
|
const cardId = game.value.playerHands.player1[0];
|
|
|
|
|
const card = game.value.cards[cardId];
|
|
|
|
|
const enemyHpBefore = game.value.currentEnemy!.hp;
|
2026-04-10 13:43:12 +08:00
|
|
|
|
2026-04-20 15:16:03 +08:00
|
|
|
await game.run(`play player1 ${cardId}`);
|
2026-04-10 13:43:12 +08:00
|
|
|
|
2026-04-20 15:16:03 +08:00
|
|
|
expect(game.value.currentEnemy!.hp).toBeLessThan(enemyHpBefore);
|
|
|
|
|
});
|
2026-04-10 13:43:12 +08:00
|
|
|
|
2026-04-20 15:16:03 +08:00
|
|
|
it("should win game when all enemies defeated", async () => {
|
|
|
|
|
const game = createTestContext();
|
2026-04-10 13:43:12 +08:00
|
|
|
|
2026-04-20 15:16:03 +08:00
|
|
|
const cards = createAllCards();
|
|
|
|
|
const rng = new Mulberry32RNG(12345);
|
|
|
|
|
const tavernDeck = buildTavernDeck(rng);
|
2026-04-10 13:43:12 +08:00
|
|
|
|
2026-04-20 15:16:03 +08:00
|
|
|
game.produce((state) => {
|
|
|
|
|
state.cards = cards;
|
|
|
|
|
state.playerCount = 1;
|
|
|
|
|
state.currentPlayerIndex = 0;
|
|
|
|
|
state.enemyDeck = [];
|
|
|
|
|
state.currentEnemy = null;
|
|
|
|
|
|
|
|
|
|
for (const card of tavernDeck) {
|
|
|
|
|
state.regions.tavernDeck.childIds.push(card.id);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
game.produce((state) => {
|
|
|
|
|
state.phase = "victory";
|
|
|
|
|
state.winner = true;
|
2026-04-10 13:43:12 +08:00
|
|
|
});
|
2026-04-20 15:16:03 +08:00
|
|
|
|
|
|
|
|
expect(game.value.phase).toBe("victory");
|
|
|
|
|
expect(game.value.winner).toBe(true);
|
|
|
|
|
});
|
2026-04-10 13:43:12 +08:00
|
|
|
});
|