@gamepark/rules-api
Version:
API to implement the rules of a board game
414 lines • 17.9 kB
JavaScript
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