UNPKG

@gamepark/rules-api

Version:

API to implement the rules of a board game

414 lines 17.9 kB
import { difference, randomInt, shuffle, union } from 'es-toolkit'; import { Rules } from '../Rules'; import { hasTimeLimit } from '../TimeLimit'; import { Material, MaterialMutator } from './items'; import { GameMemory, PlayerMemory } from './memory'; import { isEndGame, isRoll, isSelectItem, isShuffle, isStartPlayerTurn, isStartSimultaneousRule, ItemMoveType, LocalMoveType, MaterialMoveBuilder, MoveKind, RuleMoveType } from './moves'; import { isSimultaneousRule } from './rules'; /** * The MaterialRules class is the main class to implement the rules of a board game with the "Material oriented" approach. * With this approach, the game state and the game moves is structured around the game material items and their movements. * The rules are also automatically split into small parts. * Finally, a "memory" util is available to store temporary information which is not part of the material or the rules state. * If you need to implement game with hidden information, see {@link HiddenMaterialRules} or {@link SecretMaterialRules}. * * @typeparam Player - identifier of a player. Either a number or a numeric enum (eg: PlayerColor) * @typeparam MaterialType - Numeric enum of the types of material manipulated in the game * @typeparam LocationType - Numeric enum of the types of location in the game where the material can be located */ export class MaterialRules extends Rules { /** * The "location strategies" are global rules that always apply in a game when we want to maintain a consistency in the position of the material. * For example, we usually want the cards in a player hand to always go from x=0 to x=n without a gap, so we use a {@link PositiveSequenceStrategy} to enforce * the rule once and for all. If we want to create a "river" of card we can use a {@link FillGapStrategy}. * Games with more complex use cases can implement their own {@link LocationStrategy}. */ locationsStrategies = {}; /** * Helper function to manipulate the material items of the game. See {@link Material}. * * @param type The type of Material we want to work on * @returns a Material instance to manipulate all the material of that type in current game state. */ material(type) { return new Material(type, this.game.items[type]); } /** * Shortcut for this.game.players * @returns array of the players identifiers */ get players() { return this.game.players; } /** * @return the active player if exactly one player is active */ get activePlayer() { return this.getActivePlayer(); } /** * @returns all the active players */ get activePlayers() { return this.game.rule?.player !== undefined ? [this.game.rule.player] : this.game.rule?.players ?? []; } /** * Utility function to access the memory tool for the game or on player. * this.game.memory can be used to store any data that is not available through the state of the material, or current rule. * * @param player Optional, identifier of the player if we want to manipulate a specific player's memory * @returns {@link GameMemory} or {@link PlayerMemory} utility * @protected */ getMemory(player) { return player === undefined ? new GameMemory(this.game) : new PlayerMemory(this.game, player); } /** * Save a new value inside the memory. * @param key The key to index the memorized value. * @param value Any JSON serializable value to store, or a function that takes previous stored value and returns the new value to store. * @param player optional, if we need to memorize a different value for each player. */ memorize(key, value, player) { return this.getMemory(player).memorize(key, value); } /** * Retrieve the value memorized under a given key. * Shortcut for this.game.memory[key] or this.game.memory[key][player] * * @param key Key under which the memory is store. Usually a value of a numeric enum named "Memory". * @param player optional, if we need to memorize a different value for each player. */ remind(key, player) { return this.getMemory(player).remind(key); } /** * Delete a value from the memory * @param key Key of the value to delete * @param player optional, if we need to memorize a different value for each player. */ forget(key, player) { this.getMemory(player).forget(key); } startPlayerTurn = MaterialMoveBuilder.startPlayerTurn; startSimultaneousRule = MaterialMoveBuilder.startSimultaneousRule; startRule = MaterialMoveBuilder.startRule; customMove = MaterialMoveBuilder.customMove; endGame = MaterialMoveBuilder.endGame; /** * Instantiates the class that handled the rules of the game corresponding to the current rule id. * This function reads the current value in this.game.rule.id and instantiate the corresponding class in the {@link rules} property. * * @returns the class that handled the rules of the game, at current specific game state. */ get rulesStep() { if (!this.game.rule) return; const RulesStep = this.rules[this.game.rule.id]; if (!RulesStep) { console.error(`The rules class for rules id ${this.game.rule.id} is missing`); return; } return new RulesStep(this.game); } /** * Returns a utility class to change the state of the items. * Used by the framework to apply the Material moves on the items (should not be manipulated directly in the games). * * @param type MaterialType of the item we want to modify * @returns a MaterialMutator to change the state of the items */ mutator(type) { if (!this.game.items[type]) this.game.items[type] = []; return new MaterialMutator(type, this.game.items[type], this.locationsStrategies[type], this.itemsCanMerge(type)); } /** * Items can sometime be stored with a quantity (for example, coins). * By default, if you create or move similar items to the exact same location, they will merge into one item with a quantity. * However, if you have 2 cards that can be at the same spot for a short time (swapping or replacing), you can override this function to prevent them to merge * * @param _type type of items * @returns true if items can merge into one item with a quantity (default behavior) */ itemsCanMerge(_type) { return true; } /** * In the material approach, the rules behavior is delegated to the current {@link rulesStep}. See {@link Rules.delegate} */ delegate() { return this.rulesStep; } /** * Randomize Shuffle of Roll moves (see {@link RandomMove.randomize}) * @param move The Material Move to randomize * @returns the randomized move */ randomize(move) { if (isShuffle(move)) { return { ...move, newIndexes: shuffle(move.indexes) }; } else if (isRoll(move)) { return { ...move, location: { ...move.location, rotation: this.roll(move) } }; } return move; } /** * When a RollItem move is create, it has to be randomized on the server side before it is saved and shared. * This function provides the random value. By default, it returns a value between 0 and 5 assuming a 6 sided dice is rolled. * If you need to flip a coin or roll non-cubic dice, you need to override this function. * * @param _move The RollItem move to randomize * @returns a random rolled value, by default a value between 0 and 5 (cubic dice result) */ roll(_move) { return randomInt(5); } /** * Execution of the Material Moves. See {@link Rules.play}. * * @param move Material move to play on the game state * @param context Context in which the move was played * @returns Consequences of the move */ play(move, context) { const consequences = []; switch (move.kind) { case MoveKind.ItemMove: consequences.push(...this.onPlayItemMove(move, context)); break; case MoveKind.RulesMove: consequences.push(...this.onPlayRulesMove(move, context)); break; case MoveKind.CustomMove: consequences.push(...this.onCustomMove(move, context)); break; case MoveKind.LocalMove: switch (move.type) { case LocalMoveType.DisplayHelp: this.game.helpDisplay = move.helpDisplay; break; case LocalMoveType.DropItem: if (!this.game.droppedItems) { this.game.droppedItems = []; } this.game.droppedItems.push(move.item); break; case LocalMoveType.SetTutorialStep: this.game.tutorial.step = move.step; this.game.tutorial.stepComplete = false; this.game.tutorial.popupClosed = false; break; case LocalMoveType.CloseTutorialPopup: this.game.tutorial.popupClosed = true; } } const endGameIndex = consequences.findIndex(isEndGame); if (endGameIndex !== -1) { return consequences.slice(0, endGameIndex + 1); } return consequences; } onPlayItemMove(move, context) { const consequences = []; if (!context?.transient) { consequences.push(...this.beforeItemMove(move, context)); } if (!this.game.items[move.itemType]) this.game.items[move.itemType] = []; const mutator = this.mutator(move.itemType); mutator.applyMove(move); if (this.game.droppedItems) { this.game.droppedItems = this.game.droppedItems.filter((droppedItem) => { if (move.itemType !== droppedItem.type) { return true; } switch (move.type) { case ItemMoveType.Move: case ItemMoveType.Delete: return move.itemIndex !== droppedItem.index; case ItemMoveType.MoveAtOnce: return !move.indexes.includes(droppedItem.index); } }); } const indexes = getItemMoveIndexes(move); if (context?.transient) { if (!this.game.transientItems) this.game.transientItems = {}; this.game.transientItems[move.itemType] = union(this.game.transientItems[move.itemType] ?? [], indexes); } else if (this.game.transientItems) { this.game.transientItems[move.itemType] = difference(this.game.transientItems[move.itemType] ?? [], indexes); } if (!context?.transient) { consequences.push(...this.afterItemMove(move, context)); } return consequences; } beforeItemMove(move, context) { return this.rulesStep?.beforeItemMove(move, context) ?? []; } afterItemMove(move, context) { return this.rulesStep?.afterItemMove(move, context) ?? []; } onCustomMove(move, context) { return this.rulesStep?.onCustomMove(move, context) ?? []; } onPlayRulesMove(move, context) { const consequences = []; const rulesStep = this.rulesStep; if (move.type === RuleMoveType.EndPlayerTurn) { if (this.game.rule?.players?.includes(move.player)) { this.game.rule.players = this.game.rule.players.filter(player => player !== move.player); if (isSimultaneousRule(rulesStep)) { consequences.push(...rulesStep.onPlayerTurnEnd(move, context)); if (this.game.rule.players.length === 0) { consequences.push(...rulesStep.getMovesAfterPlayersDone()); } } } else { console.warn(`${this.constructor.name}: endPlayerTurn was triggered for player ${move.player} which is already inactive: ${JSON.parse(JSON.stringify(this.game.rule))}`); } } else { consequences.push(...this.changeRule(move, context)); } return consequences; } changeRule(move, context) { const moves = this.rulesStep?.onRuleEnd(move, context) ?? []; const rule = this.game.rule; switch (move.type) { case RuleMoveType.StartPlayerTurn: this.game.rule = { id: move.id, player: move.player }; break; case RuleMoveType.StartSimultaneousRule: this.game.rule = { id: move.id, players: move.players ?? this.game.players }; break; case RuleMoveType.StartRule: this.game.rule = { id: move.id, player: this.game.rule?.player }; break; case RuleMoveType.EndGame: delete this.game.rule; break; } return moves.concat(this.rulesStep?.onRuleStart(move, rule, context) ?? []); } /** * By default, a Material Move can be undone if no player became active and no dice was rolled. * See {@link Undo.canUndo} and {@link HiddenMaterialRules.canUndo} * * @param action Action to consider * @param consecutiveActions Action played in between * @returns true if the action can be undone by the player that played it */ canUndo(action, consecutiveActions) { for (let i = consecutiveActions.length - 1; i >= 0; i--) { if (this.consecutiveActionBlocksUndo(action, consecutiveActions[i])) { return false; } } return !this.actionBlocksUndo(action); } consecutiveActionBlocksUndo(action, consecutiveAction) { if (this.actionActivatesPlayer(consecutiveAction)) { return true; } if (consecutiveAction.playerId === action.playerId) { if (!isSelectItem(consecutiveAction.move) || !isSelectItem(action.move)) { return true; } } return false; } actionBlocksUndo(action) { for (let i = action.consequences.length - 1; i >= 0; i--) { if (this.moveBlocksUndo(action.consequences[i], action.playerId)) { return true; } } return this.moveBlocksUndo(action.move, action.playerId); } actionActivatesPlayer(action) { for (let i = action.consequences.length - 1; i >= 0; i--) { if (this.moveActivatesPlayer(action.consequences[i])) { return true; } } return this.moveActivatesPlayer(action.move); } /** * @protected * If a moves blocks the undo, any action with this move cannot be undone. * By default, a move block the undo if it activates a player or exposes new information (roll result, hidden information revealed...) * * @param move The move to consider * @param player The player that triggered the move * @returns true if the move blocks the undo */ moveBlocksUndo(move, player) { return this.moveActivatesPlayer(move, player) || isRoll(move); } moveActivatesPlayer(move, player) { return (isStartPlayerTurn(move) && move.player !== player) || (isStartSimultaneousRule(move) && (move.players ?? this.game.players).some((p) => p !== player)); } /** * Restore help display & local item moves */ restoreTransientState(previousState) { this.game.helpDisplay = previousState.helpDisplay; this.game.transientItems = previousState.transientItems; for (const type in previousState.transientItems) { previousState.transientItems[type].forEach(index => this.game.items[type][index] = previousState.items[type][index]); } } /** * Random moves, or moves that reveals something to me, are unpredictable. * Unpredictable moves cannot be precomputed on client side, the server side's response is necessary. * See {@link Rules.isUnpredictableMove} * * @param move Material move to consider * @param _player The player playing the move * @returns true if the move outcome cannot be predicted on client side */ isUnpredictableMove(move, _player) { return isRoll(move); } /** * A Material Game is over when there is no rule left to execute. This state results of a {@link EndGame} move. * * @returns true if game is over */ isOver() { return !this.game.rule; } /** * Amount of time given to a player everytime it is their turn to play. * @param playerId Id of the player, if you want to give different time depending on the id for asymmetric games. * @return number of seconds to add to the player's clock */ giveTime(playerId) { const rule = this.rulesStep; return rule && hasTimeLimit(rule) ? rule.giveTime(playerId) : 60; } } function getItemMoveIndexes(move) { switch (move.type) { case ItemMoveType.Move: case ItemMoveType.Delete: case ItemMoveType.Roll: case ItemMoveType.Select: return [move.itemIndex]; case ItemMoveType.MoveAtOnce: case ItemMoveType.DeleteAtOnce: case ItemMoveType.Shuffle: return move.indexes; default: return []; } } //# sourceMappingURL=MaterialRules.js.map