UNPKG

@gamepark/rules-api

Version:

API to implement the rules of a board game

241 lines 11.2 kB
import { difference, isEqual, mapValues } from 'es-toolkit'; import { get, keys, set, unset } from 'es-toolkit/compat'; import { MaterialRules } from './MaterialRules'; import { isCreateItem, isCreateItemsAtOnce, isMoveItem, isMoveItemsAtOnce, isShuffle, ItemMoveType, MoveKind } from './moves'; /** * Implement HiddenMaterialRules when you want to use the {@link MaterialRules} approach with {@link HiddenInformation}. * Using some {@link HidingStrategy} allows to enforce the security of a game with hidden information easily. * If the game has secret information (some players have information not available to others, link cards in their hand), then you * must implement {@link SecretMaterialRules} instead. */ export class HiddenMaterialRules extends MaterialRules { client; constructor(game, client) { super(game); this.client = client; } randomize(move, player) { if (player !== undefined && this.isRevealingItemMove(move, player)) { // We need to know if a MoveItem has revealed something to the player to prevent the undo in that case. // To know that, we need the position of the item before the move. // To prevent having to recalculate the game state before the move, we flag the move in the database with "reveal: {}". // This flag indicate that something was revealed to someone. // We use the "randomize" function because is the where we can "preprocess" the move and transform it after checking it is legal and before it is saved. return { ...move, reveal: {} }; } return super.randomize(move); } isRevealingItemMove(move, player) { return (isMoveItem(move) && this.moveItemWillRevealSomething(move, player)) || (isMoveItemsAtOnce(move) && this.moveAtOnceWillRevealSomething(move, player)); } /** * Items that can be hidden cannot merge by default, to prevent hidden items to merge only because they have no id. */ itemsCanMerge(type) { return !this.hidingStrategies[type]; } /** * Moves that reveal some information (like drawing a card) cannot be predicted by the player. */ isUnpredictableMove(move, player) { if (isMoveItem(move)) { return this.moveItemWillRevealSomething(move, player); } else if (isMoveItemsAtOnce(move)) { return this.moveAtOnceWillRevealSomething(move, player); } else if (isCreateItem(move)) { return this.itemHasHiddenInformation(move.itemType, move.item, player); } else if (isCreateItemsAtOnce(move)) { return move.items.some(item => this.itemHasHiddenInformation(move.itemType, item, player)); } else if (isShuffle(move)) { return true; } else { return super.isUnpredictableMove(move, player); } } /** * Moves than reveals an information to someone cannot be undone by default */ moveBlocksUndo(move, player) { return super.moveBlocksUndo(move, player) || this.moveRevealedSomething(move); } /** * @param move A move to test * @returns true if the move revealed something to some player */ moveRevealedSomething(move) { return (isMoveItem(move) || isMoveItemsAtOnce(move)) && !!move.reveal; } /** * With the material approach, we can offer a default working implementation for {@link HiddenInformation.getView} */ getView(player) { return { ...this.game, items: mapValues(this.game.items, (items, itemsType) => { const hidingStrategies = this.hidingStrategies[itemsType]; if (!hidingStrategies || !items) return items; return items.map(item => this.hideItem(itemsType, item, player)); }) }; } hideItem(type, item, player) { const paths = this.getItemHiddenPaths(type, item, player); if (!paths.length) return item; const hiddenItem = JSON.parse(JSON.stringify(item)); for (const path of paths) { unset(hiddenItem, path); } return hiddenItem; } getItemHiddenPaths(type, item, player) { const hidingStrategy = this.hidingStrategies[type]?.[item.location.type]; const hiddenPaths = hidingStrategy ? hidingStrategy(item, player) : []; return hiddenPaths.flatMap((path) => { if (!path) console.error('Empty paths are not allowed in hiding strategies'); const itemAtPath = get(item, path); if (typeof itemAtPath === 'object') { return keys(itemAtPath).map((key) => path + '.' + key); } else { return [path]; } }); } itemHasHiddenInformation(type, item, player) { return this.getItemHiddenPaths(type, item, player).length > 0; } /** * To be able to know if a MoveItem cannot be undone, the server flags the moves with a "reveal" property. * This difference must be integrated without error during the callback. */ canIgnoreServerDifference(clientMove, serverMove) { if (isMoveItem(clientMove) && isMoveItem(serverMove)) { const { reveal, ...serverMoveWithoutReveal } = serverMove; return isEqual(clientMove, serverMoveWithoutReveal); } return false; } /** * With the material approach, we can offer a default working implementation for {@link HiddenInformation.getMoveView} */ getMoveView(move, player) { if (move.kind === MoveKind.ItemMove && move.itemType in this.hidingStrategies) { switch (move.type) { case ItemMoveType.Move: return this.getMoveItemView(move, player); case ItemMoveType.MoveAtOnce: return this.getMoveAtOnceView(move, player); case ItemMoveType.Create: return { ...move, item: this.hideItem(move.itemType, move.item, player) }; case ItemMoveType.CreateAtOnce: return { ...move, items: move.items.map(item => this.hideItem(move.itemType, item, player)) }; case ItemMoveType.Shuffle: return this.getShuffleItemsView(move, player); } } return move; } getMoveItemView(move, player) { const revealedPaths = this.getMoveItemRevealedPath(move, player); if (!revealedPaths.length) return move; const item = this.material(move.itemType).getItem(move.itemIndex); const moveView = { ...move, reveal: {} }; for (const path of revealedPaths) { set(moveView.reveal, path, get(item, path)); } return moveView; } getMoveAtOnceView(move, player) { const moveView = { ...move }; for (const index of move.indexes) { const revealedPaths = this.getMoveAtOnceRevealedPath(move, index, player); if (!revealedPaths.length) continue; if (!moveView.reveal) moveView.reveal = {}; moveView.reveal[index] = {}; const item = this.material(move.itemType).getItem(index); for (const path of revealedPaths) { set(moveView.reveal[index], path, get(item, path)); } } return moveView; } getMoveItemRevealedPath(move, player) { const item = this.material(move.itemType).getItem(move.itemIndex); const hiddenPathsBefore = this.getItemHiddenPaths(move.itemType, item, player); const hiddenPathsAfter = this.getItemHiddenPaths(move.itemType, this.mutator(move.itemType).getItemAfterMove(move), player); return difference(hiddenPathsBefore, hiddenPathsAfter); } getMoveAtOnceRevealedPath(move, itemIndex, player) { const item = this.material(move.itemType).getItem(itemIndex); const hiddenPathsBefore = this.getItemHiddenPaths(move.itemType, item, player); const hiddenPathsAfter = this.getItemHiddenPaths(move.itemType, this.mutator(move.itemType).getItemAfterMoveAtOnce(move, itemIndex), player); return difference(hiddenPathsBefore, hiddenPathsAfter); } moveItemWillRevealSomething(move, player) { return this.getMoveItemRevealedPath(move, player).length > 0; } moveAtOnceWillRevealSomething(move, player) { return move.indexes.some((index) => this.getMoveAtOnceRevealedPath(move, index, player).length); } getShuffleItemsView(move, player) { if (this.canSeeShuffleResult(move, player)) return move; const { newIndexes, ...moveView } = move; return moveView; } canSeeShuffleResult(move, player) { if (!this.hidingStrategies[move.itemType]) return true; const material = this.material(move.itemType); const hiddenPaths = this.getItemHiddenPaths(move.itemType, material.getItem(move.indexes[0]), player); if (process.env.NODE_ENV === 'development' && move.indexes.some(index => !isEqual(hiddenPaths, this.getItemHiddenPaths(move.itemType, material.getItem(index), player)))) { throw new RangeError(`You cannot shuffle items with different hiding strategies: ${JSON.stringify(move.indexes.map(index => this.getItemHiddenPaths(move.itemType, material.getItem(index), player)))}`); } // TODO: if we shuffle a hand of items partially hidden, we should send the partially visible information to the client. // Example: It's a Wonderful World with the Extension: the back face of the player's hand are different // => when the hand is shuffled we should see where the expansion cards land. return !hiddenPaths.length; } /** * Override of {@link MaterialRules.play} that also removes the hidden information from items, for example when a card is flipped face down */ play(move, context) { const result = super.play(move, context); if (this.client && isMoveItem(move) && this.hidingStrategies[move.itemType]) { const item = this.material(move.itemType).getItem(move.itemIndex); if (item) { this.game.items[move.itemType][move.itemIndex] = this.hideItem(move.itemType, item, this.client.player); } } if (this.client && isMoveItemsAtOnce(move) && this.hidingStrategies[move.itemType]) { for (const index of move.indexes) { const item = this.material(move.itemType).getItem(index); if (item) { this.game.items[move.itemType][index] = this.hideItem(move.itemType, item, this.client.player); } } } return result; } } /** * Hiding strategy that removes the item id */ export const hideItemId = () => ['id']; /** * Hiding strategy that removes "id.front" from the item (when we have cards with composite ids, back & front) */ export const hideFront = () => ['id.front']; //# sourceMappingURL=HiddenMaterialRules.js.map