diff --git a/src/samples/boop/parts.csv.d.ts b/src/samples/boop/parts.csv.d.ts new file mode 100644 index 0000000..e126296 --- /dev/null +++ b/src/samples/boop/parts.csv.d.ts @@ -0,0 +1,8 @@ +type PartsTable = readonly { + readonly type: string; + readonly player: string; + readonly count: number; +}[]; + +declare const data: PartsTable; +export default data; diff --git a/src/samples/regicide/commands/index.ts b/src/samples/regicide/commands/index.ts index 9f614b9..dc15f7b 100644 --- a/src/samples/regicide/commands/index.ts +++ b/src/samples/regicide/commands/index.ts @@ -1,14 +1,20 @@ -import {createGameCommandRegistry} from "@/core/game"; +import {IGameContext} from "@/core/game"; import {RegicideState} from "@/samples/regicide/state"; +import {createGameCommandRegistry} from "@/core/game"; +import {PlayerType, RegicideCard} from "@/samples/regicide/types"; +import {CARD_VALUES, FACE_CARDS} from "@/samples/regicide/constants"; +import {isEnemyDefeated} from "@/samples/regicide/utils"; + +export type RegicideGame = IGameContext; export const registry = createGameCommandRegistry(); /** - * 打出一张牌 + * 打出一张牌(对当前敌人造成伤害) */ const playCmd = registry.register({ schema: 'play ', - run: async (game, player, cardId) => { + run: async (game: RegicideGame, player: string, cardId: string) => { const state = game.value; const card = state.cards[cardId]; @@ -17,7 +23,8 @@ const playCmd = registry.register({ } // 检查卡牌是否在玩家手牌中 - const playerHand = state.playerHands[player as keyof typeof state.playerHands]; + const playerKey = player as PlayerType; + const playerHand = state.playerHands[playerKey]; if (!playerHand || !playerHand.includes(cardId)) { return {success: false, error: `卡牌 ${cardId} 不在玩家 ${player} 的手牌中`}; } @@ -29,20 +36,31 @@ const playCmd = registry.register({ // 计算伤害(基础伤害为卡牌面值) let damage = card.value; + let attackReduction = 0; - // TODO: 花色能力 - 梅花双倍伤害 - // if (card.suit === 'clubs') { - // damage *= 2; - // } + // 梅花双倍伤害 + if (card.suit === 'clubs') { + damage *= 2; + } - // TODO: A牌配合机制 - 如果card.rank === 'A',可以额外打出一张牌 + // 黑桃降低敌人攻击力 + if (card.suit === 'spades') { + attackReduction = card.value; + } - // 对敌人造成伤害 - game.produce(state => { + const enemyHpBefore = state.currentEnemy.hp; + + await game.produce(state => { + // 对敌人造成伤害 state.currentEnemy!.hp -= damage; + // 记录黑桃的攻击力降低 + if (attackReduction > 0) { + state.currentEnemy!.value = Math.max(0, state.currentEnemy!.value - attackReduction); + } + // 从手牌移除卡牌 - const hand = state.playerHands[player as keyof typeof state.playerHands]; + const hand = state.playerHands[playerKey]; const cardIndex = hand.indexOf(cardId); if (cardIndex !== -1) { hand.splice(cardIndex, 1); @@ -51,11 +69,126 @@ const playCmd = registry.register({ // 将卡牌移到弃牌堆 state.cards[cardId].regionId = 'discardPile'; - // TODO: 触发花色能力 - // TODO: 检查敌人是否被击败 + // 红心能力:将弃牌堆洗回酒馆牌堆 + if (card.suit === 'hearts') { + const discardIds = state.regions.discardPile.childIds.filter(id => id !== state.currentEnemy!.id); + if (discardIds.length > 0) { + // 将弃牌堆(除当前敌人外)移回酒馆牌堆 + for (const discardId of discardIds) { + state.cards[discardId].regionId = 'tavernDeck'; + } + state.regions.tavernDeck.childIds.push(...discardIds); + state.regions.discardPile.childIds = [state.currentEnemy!.id]; + } + } + + // 方块能力:从酒馆牌堆抓牌 + if (card.suit === 'diamonds') { + const tavernDeckCount = state.regions.tavernDeck.childIds.length; + if (tavernDeckCount > 0) { + const drawCardId = state.regions.tavernDeck.childIds.shift()!; + state.cards[drawCardId].regionId = `hand_${player}`; + hand.push(drawCardId); + } + } }); - return {success: true, result: {damage, enemyHp: state.currentEnemy.hp}}; + // 检查敌人是否被击败 + const enemyDefeated = isEnemyDefeated(game.value.currentEnemy); + + return { + success: true, + result: { + damage, + attackReduction, + enemyHpBefore, + enemyHpAfter: game.value.currentEnemy!.hp, + enemyDefeated, + suitAbility: card.suit + } + }; + } +}); + +/** + * 打出A配合另一张牌 + */ +const playWithACmd = registry.register({ + schema: 'play-with-a ', + run: async (game: RegicideGame, player: string, aceCardId: string, otherCardId: string) => { + const state = game.value; + const aceCard = state.cards[aceCardId]; + const otherCard = state.cards[otherCardId]; + + if (!aceCard || !otherCard) { + return {success: false, error: '卡牌不存在'}; + } + + // 检查是否是A牌 + if (aceCard.rank !== 'A') { + return {success: false, error: `第一张牌必须是A`}; + } + + const playerKey = player as PlayerType; + const playerHand = state.playerHands[playerKey]; + + // 检查两张牌都在手牌中 + if (!playerHand.includes(aceCardId) || !playerHand.includes(otherCardId)) { + return {success: false, error: '卡牌不在手牌中'}; + } + + if (!state.currentEnemy) { + return {success: false, error: '没有活跃的敌人'}; + } + + // 计算两张牌的总伤害 + let totalDamage = aceCard.value + otherCard.value; + + // 如果另一张牌是梅花,双倍伤害 + if (otherCard.suit === 'clubs') { + totalDamage *= 2; + } + + let attackReduction = 0; + if (aceCard.suit === 'spades') { + attackReduction += aceCard.value; + } + if (otherCard.suit === 'spades') { + attackReduction += otherCard.value; + } + + await game.produce(state => { + // 对敌人造成伤害 + state.currentEnemy!.hp -= totalDamage; + + // 记录黑桃的攻击力降低 + if (attackReduction > 0) { + state.currentEnemy!.value = Math.max(0, state.currentEnemy!.value - attackReduction); + } + + // 从手牌移除两张牌 + const hand = state.playerHands[playerKey]; + const aceIndex = hand.indexOf(aceCardId); + const otherIndex = hand.indexOf(otherCardId); + if (aceIndex !== -1) hand.splice(aceIndex, 1); + if (otherIndex !== -1) hand.splice(otherIndex, 1); + + // 将卡牌移到弃牌堆 + state.cards[aceCardId].regionId = 'discardPile'; + state.cards[otherCardId].regionId = 'discardPile'; + }); + + const enemyDefeated = isEnemyDefeated(state.currentEnemy); + + return { + success: true, + result: { + damage: totalDamage, + attackReduction, + enemyHp: state.currentEnemy!.hp, + enemyDefeated + } + }; } }); @@ -64,44 +197,225 @@ const playCmd = registry.register({ */ const passCmd = registry.register({ schema: 'pass ', - run: async (game, player) => { - const state = game.value; - - // 即使让过,也会受到敌人反击 - // TODO: 实现反击逻辑 - + run: async (game: RegicideGame, player: string) => { + // 即使让过,也会受到敌人反击(在回合结束时处理) return {success: true, result: {message: `${player} 让过`}}; } }); /** - * 检查敌人是否被击败 + * 敌人反击 - 玩家必须弃掉点数和 >= 敌人攻击力的牌 */ -const checkEnemyDefeatedCmd = registry.register({ - schema: 'check-enemy-defeated', - run: async (game) => { +const enemyCounterattackCmd = registry.register({ + schema: 'counterattack ', + run: async (game: RegicideGame, player: string, discardCards: string[]) => { const state = game.value; if (!state.currentEnemy) { return {success: false, error: '没有活跃的敌人'}; } - const isDefeated = state.currentEnemy.hp <= 0; + const playerKey = player as PlayerType; + const playerHand = state.playerHands[playerKey]; - if (isDefeated) { - // 敌人被击败,移到弃牌堆 - game.produce(state => { - // TODO: 将当前敌人移到弃牌堆 - // TODO: 翻开下一个敌人 - }); + // 检查要弃的牌都在手牌中 + for (const cardId of discardCards) { + if (!playerHand.includes(cardId)) { + return {success: false, error: `卡牌 ${cardId} 不在手牌中`}; + } } - return {success: true, result: {isDefeated, enemy: state.currentEnemy}}; + // 计算弃牌的点数和 + let totalValue = 0; + for (const cardId of discardCards) { + const card = state.cards[cardId]; + if (card) { + totalValue += card.value; + } + } + + const enemyAttack = state.currentEnemy.value; + + // 检查点数和是否 >= 敌人攻击力 + if (totalValue < enemyAttack) { + return { + success: false, + error: `弃牌点数和 (${totalValue}) 小于敌人攻击力 (${enemyAttack}),游戏失败` + }; + } + + // 执行弃牌 + await game.produce(state => { + const hand = state.playerHands[playerKey]; + for (const cardId of discardCards) { + const index = hand.indexOf(cardId); + if (index !== -1) { + hand.splice(index, 1); + } + state.cards[cardId].regionId = 'discardPile'; + } + }); + + return { + success: true, + result: { + discardedCards: discardCards, + totalValue, + enemyAttack + } + }; + } +}); + +/** + * 检查敌人是否被击败,如果击败则翻开下一个敌人 + */ +const checkEnemyDefeatedCmd = registry.register({ + schema: 'check-enemy', + run: async (game: RegicideGame) => { + const state = game.value; + + if (!state.currentEnemy) { + return {success: false, error: '没有活跃的敌人'}; + } + + const defeated = state.currentEnemy.hp <= 0; + + if (defeated) { + const defeatedEnemy = {...state.currentEnemy}; + + await game.produce(state => { + // 将当前敌人移到弃牌堆 + state.regions.discardPile.childIds.push(state.currentEnemy!.id); + + // 翻开下一个敌人 + if (state.enemyDeck.length > 0) { + const nextEnemy = state.enemyDeck.shift()!; + state.currentEnemy = nextEnemy; + } else { + // 没有更多敌人了 + state.currentEnemy = null; + } + }); + + // 检查是否胜利(没有更多敌人) + if (!game.value.currentEnemy) { + await game.produce(state => { + state.phase = 'victory'; + state.winner = true; + }); + } + + return { + success: true, + result: { + defeated: true, + defeatedEnemy, + nextEnemy: game.value.currentEnemy, + enemiesRemaining: game.value.enemyDeck.length + } + }; + } + + return { + success: true, + result: { + defeated: false, + currentEnemy: {...state.currentEnemy}, + enemiesRemaining: state.enemyDeck.length + } + }; + } +}); + +/** + * 检查玩家是否有可出的牌 + */ +const checkCanPlayCmd = registry.register({ + schema: 'check-can-play ', + run: async (game: RegicideGame, player: string) => { + const state = game.value; + const playerKey = player as PlayerType; + const playerHand = state.playerHands[playerKey]; + + const canPlay = playerHand.length > 0; + const canPlayWithA = playerHand.some(cardId => { + const card = state.cards[cardId]; + return card && card.rank === 'A' && playerHand.length > 1; + }); + + return { + success: true, + result: { + canPlay, + canPlayWithA, + handSize: playerHand.length + } + }; + } +}); + +/** + * 检查酒馆牌堆是否为空 + */ +const checkTavernDeckCmd = registry.register({ + schema: 'check-tavern-deck', + run: async (game: RegicideGame) => { + const state = game.value; + const isEmpty = state.regions.tavernDeck.childIds.length === 0; + + // 如果酒馆牌堆为空且所有玩家手牌也为空,则游戏失败 + if (isEmpty) { + const allHandsEmpty = Object.values(state.playerHands).every(hand => hand.length === 0); + if (allHandsEmpty) { + await game.produce(state => { + state.phase = 'defeat'; + state.winner = false; + }); + } + } + + return { + success: true, + result: { + isEmpty, + cardsRemaining: state.regions.tavernDeck.childIds.length + } + }; + } +}); + +/** + * 下一个玩家回合 + */ +const nextTurnCmd = registry.register({ + schema: 'next-turn', + run: async (game: RegicideGame) => { + const state = game.value; + await game.produce(state => { + state.currentPlayerIndex = (state.currentPlayerIndex + 1) % state.playerCount; + }); + + const players: PlayerType[] = ['player1', 'player2', 'player3', 'player4']; + const currentPlayer = players[game.value.currentPlayerIndex]; + + return { + success: true, + result: { + currentPlayer, + currentPlayerIndex: game.value.currentPlayerIndex + } + }; } }); export { playCmd as play, + playWithACmd as playWithA, passCmd as pass, - checkEnemyDefeatedCmd as checkEnemyDefeated, + enemyCounterattackCmd as enemyCounterattack, + checkEnemyDefeatedCmd as checkEnemy, + checkCanPlayCmd as checkCanPlay, + checkTavernDeckCmd as checkTavernDeck, + nextTurnCmd as nextTurn, }; diff --git a/src/samples/regicide/game.ts b/src/samples/regicide/game.ts new file mode 100644 index 0000000..e2b7c89 --- /dev/null +++ b/src/samples/regicide/game.ts @@ -0,0 +1,239 @@ +import {IGameContext} from "@/core/game"; +import {RegicideState} from "@/samples/regicide/state"; +import {buildEnemyDeck, buildTavernDeck, createAllCards, getPlayerHandRegionId} from "@/samples/regicide/utils"; +import {INITIAL_HAND_SIZE} from "@/samples/regicide/constants"; +import {Enemy, PlayerType, RegicideCard} from "@/samples/regicide/types"; + +export type RegicideGame = IGameContext; + +/** + * 初始化游戏设置 + * @param game 游戏上下文 + * @param playerCount 玩家数量(1-4) + * @param seed 随机种子(可选) + */ +export async function setupGame(game: RegicideGame, playerCount: number, seed?: number) { + if (playerCount < 1 || playerCount > 4) { + throw new Error('玩家数量必须为 1-4 人'); + } + + if (seed) { + // RNG seeding handled by game context + } + + // 创建所有卡牌 + const allCards = createAllCards(); + + // 构建敌人牌堆(J/Q/K) + const enemyDeck = buildEnemyDeck(game._rng); + + // 构建酒馆牌堆(A-10) + const tavernDeck = buildTavernDeck(game._rng); + + // 初始化游戏状态 + await game.produceAsync(state => { + state.cards = allCards; + state.playerCount = playerCount; + state.currentPlayerIndex = 0; + state.enemyDeck = enemyDeck; + + // 设置酒馆牌堆区域 + for (const card of tavernDeck) { + card.regionId = 'tavernDeck'; + state.regions.tavernDeck.childIds.push(card.id); + } + + // 设置敌人牌堆区域(只存储ID,敌人是独立对象) + state.regions.enemyDeck.childIds = enemyDeck.map(e => e.id); + + // 给每个玩家发牌 + const players: PlayerType[] = ['player1', 'player2', 'player3', 'player4']; + for (let i = 0; i < playerCount; i++) { + const player = players[i]; + const regionId = getPlayerHandRegionId(player); + + for (let j = 0; j < INITIAL_HAND_SIZE; j++) { + if (tavernDeck.length === 0) break; + const card = tavernDeck.shift()!; + card.regionId = regionId; + state.playerHands[player].push(card.id); + const region = state.regions[regionId as keyof typeof state.regions]; + region.childIds.push(card.id); + } + } + + // 翻开第一个敌人 + if (enemyDeck.length > 0) { + const firstEnemy = enemyDeck.shift()!; + state.currentEnemy = firstEnemy; + } + }); +} + +/** + * 启动游戏主循环 + */ +export async function start(game: RegicideGame) { + const state = game.value; + + // 检查游戏是否已设置 + if (!state.currentEnemy) { + throw new Error('请先调用 setupGame 初始化游戏'); + } + + const players: PlayerType[] = ['player1', 'player2', 'player3', 'player4']; + + // 主游戏循环 + while (state.phase === 'playing') { + const currentPlayerIndex = state.currentPlayerIndex; + const currentPlayer = players[currentPlayerIndex]; + + // 检查当前玩家是否有手牌 + const currentHand = state.playerHands[currentPlayer]; + if (currentHand.length === 0) { + // 玩家没有手牌,跳过回合 + await game.produceAsync(state => { + state.currentPlayerIndex = (state.currentPlayerIndex + 1) % state.playerCount; + }); + continue; + } + + // 等待玩家输入(出牌或让过) + // 这里需要外部通过 prompt 系统获取输入 + // 实际使用时由 UI 或测试代码提供输入 + + // 循环会在外部调用 play/pass 命令后继续 + // 当 phase 变为 'victory' 或 'defeat' 时退出 + break; + } + + return game.value; +} + +/** + * 处理完整的玩家回合 + */ +export async function playTurn(game: RegicideGame, player: PlayerType, action: 'play' | 'pass', cardId?: string, secondCardId?: string) { + const state = game.value; + + if (state.phase !== 'playing') { + return {success: false, error: '游戏已结束'}; + } + + if (!state.currentEnemy) { + return {success: false, error: '没有活跃的敌人'}; + } + + let playResult: any; + + // 执行玩家动作 + if (action === 'play' && cardId) { + // 检查是否是A配合另一张牌 + const card = state.cards[cardId]; + if (card.rank === 'A' && secondCardId) { + playResult = await game.run(`play-with-a ${player} ${cardId} ${secondCardId}`); + } else { + playResult = await game.run(`play ${player} ${cardId}`); + } + } else { + // 让过 + playResult = await game.run(`pass ${player}`); + } + + if (!playResult.success) { + return playResult; + } + + // 检查敌人是否被击败 + const checkResult = await game.run<{defeated: boolean; currentEnemy?: any; nextEnemy?: any; defeatedEnemy?: any; enemiesRemaining?: number}>('check-enemy'); + if (!checkResult.success) { + return checkResult; + } + + // 如果敌人未被击败,处理反击 + if (!checkResult.result.defeated) { + // 反击逻辑需要玩家选择弃牌,这里返回状态让外部处理 + return { + success: true, + result: { + playResult: playResult.result, + enemyDefeated: false, + needsDiscard: true, + enemyAttack: state.currentEnemy.value, + playerHand: state.playerHands[player] + } + }; + } + + // 敌人被击败,检查是否还有更多敌人 + if (state.enemyDeck.length === 0 && state.currentEnemy && state.currentEnemy.hp <= 0) { + await game.produceAsync(state => { + state.phase = 'victory'; + state.winner = true; + }); + return { + success: true, + result: { + playResult: playResult.result, + enemyDefeated: true, + gameWon: true + } + }; + } + + // 切换到下一个玩家 + await game.run('next-turn'); + + return { + success: true, + result: { + playResult: playResult.result, + enemyDefeated: true, + nextEnemy: state.currentEnemy + } + }; +} + +/** + * 处理反击阶段的弃牌 + */ +export async function handleCounterattack(game: RegicideGame, player: PlayerType, discardCardIds: string[]) { + const result = await game.run(`counterattack ${player} ${JSON.stringify(discardCardIds)}`); + + if (!result.success) { + // 弃牌失败(点数和不足),游戏失败 + await game.produceAsync(state => { + state.phase = 'defeat'; + state.winner = false; + }); + return result; + } + + // 弃牌成功,切换到下一个玩家 + await game.run('next-turn'); + + return result; +} + +/** + * 获取当前游戏状态摘要 + */ +export function getGameStatus(game: RegicideGame) { + const state = game.value; + + return { + phase: state.phase, + currentPlayer: ['player1', 'player2', 'player3', 'player4'][state.currentPlayerIndex], + currentEnemy: state.currentEnemy ? { + ...state.currentEnemy, + hpPercent: Math.round((state.currentEnemy.hp / state.currentEnemy.maxHp) * 100) + } : null, + enemiesRemaining: state.enemyDeck.length, + tavernDeckCount: state.regions.tavernDeck.childIds.length, + discardPileCount: state.regions.discardPile.childIds.length, + playerHands: Object.fromEntries( + Object.entries(state.playerHands).map(([player, hand]) => [player, hand.length]) + ), + winner: state.winner + }; +} diff --git a/src/samples/regicide/index.ts b/src/samples/regicide/index.ts index a150c29..83a123b 100644 --- a/src/samples/regicide/index.ts +++ b/src/samples/regicide/index.ts @@ -34,10 +34,25 @@ export {prompts} from './prompts'; export { registry, play as playCmd, + playWithA as playWithACmd, pass as passCmd, - checkEnemyDefeated as checkEnemyDefeatedCmd + enemyCounterattack as enemyCounterattackCmd, + checkEnemy as checkEnemyCmd, + checkCanPlay as checkCanPlayCmd, + checkTavernDeck as checkTavernDeckCmd, + nextTurn as nextTurnCmd, } from './commands'; +// Game +export { + setupGame, + start, + playTurn, + handleCounterattack, + getGameStatus, + type RegicideGame +} from './game'; + // Utils export { getCardValue, diff --git a/src/samples/regicide/prompts.ts b/src/samples/regicide/prompts.ts index 56ad565..f8bfd8e 100644 --- a/src/samples/regicide/prompts.ts +++ b/src/samples/regicide/prompts.ts @@ -1,17 +1,20 @@ import {createPromptDef} from "@/core/game"; -import {PlayerType} from "@/samples/regicide/types"; export const prompts = { - playCard: createPromptDef<[PlayerType, string]>( + playCard: createPromptDef<[string, string]>( 'play ', '选择要打出的卡牌' ), - pass: createPromptDef<[PlayerType]>( + playWithA: createPromptDef<[string, string, string]>( + 'play-with-a ', + '打出A配合另一张牌' + ), + pass: createPromptDef<[string]>( 'pass ', '让过不出牌' ), - discard: createPromptDef<[PlayerType, string[]]>( - 'discard ', - '选择要弃掉的卡牌' + discard: createPromptDef<[string, string[]]>( + 'counterattack ', + '选择要弃掉的卡牌以应对敌人反击' ), }; diff --git a/tests/samples/regicide.test.ts b/tests/samples/regicide.test.ts new file mode 100644 index 0000000..b8addad --- /dev/null +++ b/tests/samples/regicide.test.ts @@ -0,0 +1,353 @@ +import {createGameContext} from '@/core/game'; +import {registry} from '@/samples/regicide/commands'; +import {createInitialState} from '@/samples/regicide/state'; +import { + 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); + }); + + 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); + }); + + 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); + }); + }); + + describe('createAllCards', () => { + it('should create 52 cards', () => { + const cards = createAllCards(); + expect(Object.keys(cards).length).toBe(52); + }); + + 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); + } + } + }); + }); + + describe('buildEnemyDeck', () => { + it('should create 12 enemies (J/Q/K)', () => { + const rng = new Mulberry32RNG(12345); + const deck = buildEnemyDeck(rng); + expect(deck.length).toBe(12); + }); + + it('should have J at top, Q in middle, K at bottom', () => { + const rng = new Mulberry32RNG(12345); + const deck = buildEnemyDeck(rng); + + for (let i = 0; i < 4; i++) { + expect(deck[i].rank).toBe('J'); + } + + for (let i = 4; i < 8; i++) { + expect(deck[i].rank).toBe('Q'); + } + + for (let i = 8; i < 12; i++) { + expect(deck[i].rank).toBe('K'); + } + }); + }); + + 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); + }); + }); +}); + +describe('Regicide - Commands', () => { + function createTestContext() { + const initialState = createInitialState(); + return createGameContext(registry, initialState); + } + + function setupTestGame(game: ReturnType) { + 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); + } + }); + } + + describe('play command', () => { + it('should deal damage to current enemy', async () => { + const game = createTestContext(); + setupTestGame(game); + + const enemyHpBefore = game.value.currentEnemy!.hp; + const cardId = game.value.playerHands.player1[0]; + const card = game.value.cards[cardId]; + + const result = await game.run(`play player1 ${cardId}`); + + expect(game.value.currentEnemy!.hp).toBe(enemyHpBefore - card.value); + }); + + it('should double damage for clubs suit', async () => { + const game = createTestContext(); + setupTestGame(game); + + 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'); + }); + + const clubsCardId = 'clubs_5'; + const enemyHpBefore = game.value.currentEnemy!.hp; + const card = game.value.cards[clubsCardId]; + + await game.run(`play player1 ${clubsCardId}`); + + 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'); + + 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); + + const currentEnemyId = game.value.currentEnemy!.id; + + await game.run('check-enemy'); + + expect(game.value.currentEnemy!.id).toBe(currentEnemyId); + }); + }); + + describe('next-turn command', () => { + it('should switch to next player', async () => { + const game = createTestContext(); + setupTestGame(game); + + expect(game.value.currentPlayerIndex).toBe(0); + + await game.run('next-turn'); + + expect(game.value.currentPlayerIndex).toBe(1); + }); + + it('should wrap around to first player', async () => { + const game = createTestContext(); + setupTestGame(game); + + game.produce(state => { + state.currentPlayerIndex = 1; + }); + + await game.run('next-turn'); + + expect(game.value.currentPlayerIndex).toBe(0); + }); + }); +}); + +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); + } + }); + + const cardId = game.value.playerHands.player1[0]; + const card = game.value.cards[cardId]; + const enemyHpBefore = game.value.currentEnemy!.hp; + + await game.run(`play player1 ${cardId}`); + + expect(game.value.currentEnemy!.hp).toBeLessThan(enemyHpBefore); + }); + + it('should win game when all enemies defeated', async () => { + const game = createTestContext(); + + const cards = createAllCards(); + const rng = new Mulberry32RNG(12345); + const tavernDeck = buildTavernDeck(rng); + + 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; + }); + + expect(game.value.phase).toBe('victory'); + expect(game.value.winner).toBe(true); + }); +});