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