@gamepark/rules-api
Version:
API to implement the rules of a board game
230 lines • 10.1 kB
JavaScript
import { cloneDeep, isEqual, mapValues, sumBy } from 'es-toolkit';
import { keyBy } from 'es-toolkit/compat';
import { Material } from './index';
import { MaterialMutator } from './MaterialMutator';
/**
* This subclass of {@link Material} is design to handle counting and moving Money with different units: coins of 5 and 1 for instance.
* It also keeps track of how much money is left after spending moves are create so that it is easy to spend money multiple time at once,
* without risking spending the same coins twice because the moves are no immediately executed.
*/
export class MaterialMoney extends Material {
type;
units;
items;
processMove;
entries;
pendingMoves = [];
/**
* Construct a new Material Money helper instance
* @param {number} type Type of items this instance will work on
* @param {MaterialItem[]} items The complete list of items of this type in current game state.
* @param {number[]} units The different units that exists in stock to count this money
* @param {ItemEntry[]} entries The list of items to work on. Each entry consists of an array with the index of the item, and the item
* @param {function} processMove if provided, this function will be executed on every move created with this instance
*/
constructor(type, units, items = [], processMove, entries = Array.from(items.entries()).filter(entry => entry[1].quantity !== 0)) {
super(type, items, processMove, entries);
this.type = type;
this.units = units;
this.items = items;
this.processMove = processMove;
this.entries = entries;
if (this.units[0] === 1) {
this.units = [...units];
this.units.sort((a, b) => b - a); // Sort units from highest to 1
}
if (this.units[this.units.length - 1] !== 1)
console.warn('Money without 1 in the possible values will produce unexpected outcomes');
}
/**
* Helper function to return a new instance of the same class (works also for children class)
* @param {ItemEntry[]} entries Filtered entries for the new class
* @returns {this} the new Material instance
* @protected
*/
new(entries) {
const Class = this.constructor;
return new Class(this.type, this.units, this.items, this.processMove, entries);
}
/**
* We need to apply the pending moves before any filtering is done to get the right count for instance.
*/
filter(predicate) {
this.applyPendingMoves();
return super.filter(predicate);
}
/**
* Count the total value of a material instance
* @returns the sum of each item id multiplied by its quantity
*/
get count() {
this.applyPendingMoves();
return sumBy(this.getItems(), item => (item.id ?? 1) * (item.quantity ?? 1));
}
/**
* Create an amount of Money and put it in given location
* @param amount Amount to gain
* @param location The location to filter material onto, and to create new items in
* @returns the moves that need to be played to perform the operation
*/
addMoney(amount, location) {
if (amount === 0)
return [];
if (amount < 0)
return this.removeMoney(-amount, location);
const moves = [];
const gainMap = this.getGainMap(amount);
for (const unit of this.units) {
if (gainMap[unit] > 0) {
moves.push(this.createItem({ id: unit, location, quantity: gainMap[unit] }));
}
}
this.pendingMoves.push(...moves);
return moves;
}
/**
* Remove an amount of Money from given location
* @param amount Amount to spend
* @param location The location to filter material onto, and to create new items in
* @returns the moves that need to be played to perform the operation
*/
removeMoney(amount, location) {
if (amount === 0)
return [];
if (amount < 0)
return this.addMoney(-amount, location);
this.applyPendingMoves();
if (this.entries.some(([, item]) => !isEqual(item.location, location))) {
return this.location(l => isEqual(l, location)).removeMoney(amount, location);
}
const moves = [];
const spendMap = this.getSpendMap(amount);
for (const unit of this.units) {
if (spendMap[unit] < 0) {
moves.push(this.id(unit).deleteItem(-spendMap[unit]));
}
else if (spendMap[unit] > 0) {
moves.push(this.createItem({ id: unit, location, quantity: spendMap[unit] }));
}
}
this.pendingMoves.push(...moves);
return moves;
}
/**
* Move an amount of money from a place to another place. It searches after the easiest way to do it, making money with the bank only if necessary.
* @param origin Location to remove money from
* @param target Location to move money to
* @param amount Amount of money to transfer
* @returns the moves that need to be played to perform the operation
*/
moveMoney(origin, target, amount) {
if (amount === 0)
return [];
if (amount < 0)
return this.moveMoney(target, origin, -amount);
this.applyPendingMoves();
const moves = [];
const originMoney = this.location(l => isEqual(l, origin));
const targetMoney = this.location(l => isEqual(l, target));
const originDelta = originMoney.getSpendMap(amount);
const targetDelta = targetMoney.getGainMap(amount);
for (const unit of this.units) {
if (originDelta[unit] < 0) {
while (targetDelta[unit] < -originDelta[unit]) { // try to make money for 1 unit with lower units
const lowerUnits = this.units.slice(this.units.indexOf(unit) + 1);
const targetResultDelta = targetMoney.getSpendMap(unit);
const valueSpent = sumBy(lowerUnits, unit => -targetResultDelta[unit] * unit);
if (valueSpent === unit && lowerUnits.every(lowerUnit => targetResultDelta[lowerUnit] < 0)) {
targetDelta[unit]++;
for (const lowerUnit of lowerUnits) {
targetDelta[lowerUnit] += targetResultDelta[lowerUnit];
}
}
else
break;
}
const moveAmount = Math.min(-originDelta[unit], targetDelta[unit]);
targetDelta[unit] -= moveAmount;
const originMaterialUnit = originMoney.id(unit);
if (moveAmount > 0) {
moves.push(originMaterialUnit.moveItem(target, moveAmount));
}
if (moveAmount < -originDelta[unit]) {
moves.push(originMaterialUnit.deleteItem(-originDelta[unit] - moveAmount));
}
}
else if (originDelta[unit] > 0) {
if (targetDelta[unit] < 0) {
moves.push(targetMoney.id(unit).moveItem(origin, -targetDelta[unit]));
}
else {
moves.push(originMoney.createItem({ id: unit, location: origin, quantity: originDelta[unit] }));
}
}
if (targetDelta[unit] > 0) {
moves.push(targetMoney.createItem({ id: unit, location: target, quantity: targetDelta[unit] }));
}
}
this.pendingMoves.push(...moves);
return moves;
}
/**
* Return the best way to gain an amount, prioritizing the highest unit values
* @param amount Amount to gain, default 1
* @returns the record of coins to earn (only positive values)
*/
getGainMap(amount) {
const map = mapValues(keyBy(this.units), _ => 0);
for (const unit of this.units) {
map[unit] = Math.floor(amount / unit);
amount %= unit;
}
return map;
}
/**
* Return the best way to spend an amount of owned units, prioritizing the smallest unit values
* @param amount Amount to gain, default 1
* @returns the record of coins to give away and eventually take (positive and negative values)
*/
getSpendMap(amount) {
const owned = mapValues(keyBy(this.units), unit => this.id(unit).getQuantity());
const map = mapValues(keyBy(this.units), _ => 0);
for (let _ = 0; _ < amount; _++) {
for (let i = this.units.length - 1; i >= 0; i--) {
const unit = this.units[i];
if (owned[unit] + map[unit] > 0) {
map[unit]--;
if (unit > 1) {
let rest = unit - 1;
for (const lowerUnit of this.units.slice(i + 1)) {
if (lowerUnit <= rest) {
map[lowerUnit] += Math.floor(rest / lowerUnit);
rest -= (rest % lowerUnit) * lowerUnit;
}
}
}
break;
}
}
}
return map;
}
/**
* Mutate the entries to get the state after
* @private
*/
applyPendingMoves() {
if (!this.pendingMoves.length)
return;
if (this.items.some((item, index) => item.quantity !== 0 && !this.entries.some(entry => entry[0] === index))) {
console.warn('MaterialMoney cannot track the state of the items on filtered instances, the filter will be cancelled');
}
this.items = cloneDeep(this.items);
const mutator = new MaterialMutator(this.type, this.items);
while (this.pendingMoves.length > 0) {
mutator.applyMove(this.pendingMoves.shift());
}
this.entries = Array.from(this.items.entries()).filter(entry => entry[1].quantity !== 0);
}
}
//# sourceMappingURL=MaterialMoney.js.map