From 291b64f98ea490ec02ee03975d64dcb7ac7a6e76 Mon Sep 17 00:00:00 2001 From: Romain Fromi Date: Mon, 27 Apr 2026 10:18:05 +0200 Subject: [PATCH] Add option on MaterialMoney to consolidate after moving of gaining money --- src/material/items/MaterialMoney.ts | 153 ++++++++++++++++++++++------ src/tests/MaterialMoney.test.ts | 150 ++++++++++++++++++++++++++- 2 files changed, 271 insertions(+), 32 deletions(-) diff --git a/src/material/items/MaterialMoney.ts b/src/material/items/MaterialMoney.ts index 161c03d..ae4b490 100644 --- a/src/material/items/MaterialMoney.ts +++ b/src/material/items/MaterialMoney.ts @@ -6,6 +6,17 @@ import { ItemEntry, MaterialBase } from './MaterialBase' import { MaterialItem } from './MaterialItem' import { MaterialMutator } from './MaterialMutator' +/** + * Option that controls preventive consolidation when gaining money. + * - `true`: always consolidate the gain. + * - predicate: consolidate only when it returns true. The predicate is evaluated against the items that *would* be + * present at the location if the gain were performed without consolidation, and decides whether to use the + * consolidated path instead. + */ +export type ConsolidateOption

= + | boolean + | ((items: MaterialItem[]) => boolean) + /** * This subclass of {@link MaterialBase} 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, @@ -67,14 +78,27 @@ export class MaterialMoney

): ItemMove[] { + addMoney(amount: number, location: Location, options: { consolidate?: ConsolidateOption } = {}): ItemMove[] { if (amount === 0) return [] if (amount < 0) return this.removeMoney(-amount, location) + if (this.shouldConsolidateOnGain(amount, location, options.consolidate)) { + return this.gainConsolidated(amount, location) + } + return this.gainNaive(amount, location) + } + + private gainNaive(amount: number, location: Location): ItemMove[] { const moves: ItemMove[] = [] const gainMap = this.getGainMap(amount) for (const unit of this.units) { @@ -86,6 +110,24 @@ export class MaterialMoney

): ItemMove[] { + this.applyPendingMoves() + const localMoney = this.location(l => isEqual(l, location)) + const newDist = this.getGainMap(localMoney.count + amount) + const moves: ItemMove[] = [] + for (const unit of this.units) { + const current = localMoney.id(unit).getQuantity() + const delta = newDist[unit] - current + if (delta < 0) { + moves.push(localMoney.id(unit).deleteItem(-delta)) + } else if (delta > 0) { + moves.push(localMoney.createItem({ id: unit, location, quantity: delta })) + } + } + this.pendingMoves.push(...moves) + return moves + } + /** * Remove an amount of Money from given location * @param amount Amount to spend @@ -113,7 +155,12 @@ export class MaterialMoney

[] = [] 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) + const newTargetDist = this.getGainMap(targetMoney.count + amount) + const targetDelta = mapValues(keyBy(this.units), (unit: Unit) => newTargetDist[unit] - targetMoney.id(unit).getQuantity()) + + // Direct exchanges between origin and target before resorting to the bank. 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 (originDelta[unit] < 0 && targetDelta[unit] > 0) { + const transfer = Math.min(-originDelta[unit], targetDelta[unit]) + moves.push(originMoney.id(unit).moveItem(target, transfer)) + originDelta[unit] += transfer + targetDelta[unit] -= transfer + } else if (originDelta[unit] > 0 && targetDelta[unit] < 0) { + const transfer = Math.min(originDelta[unit], -targetDelta[unit]) + moves.push(targetMoney.id(unit).moveItem(origin, transfer)) + originDelta[unit] -= transfer + targetDelta[unit] += transfer + } + } + + // Residuals balanced through the bank. + for (const unit of this.units) { + if (originDelta[unit] > 0) { + moves.push(originMoney.createItem({ id: unit, location: origin, quantity: originDelta[unit] })) + } else if (originDelta[unit] < 0) { + moves.push(originMoney.id(unit).deleteItem(-originDelta[unit])) } if (targetDelta[unit] > 0) { moves.push(targetMoney.createItem({ id: unit, location: target, quantity: targetDelta[unit] })) + } else if (targetDelta[unit] < 0) { + moves.push(targetMoney.id(unit).deleteItem(-targetDelta[unit])) + } + } + + this.pendingMoves.push(...moves) + return moves + } + + /** + * Consolidate the money at a location: exchange lower-value coins for higher-value ones so that the location holds + * the minimum number of items for its total value. Useful to clean up a location after several gains have piled up. + * @param location The location to consolidate + * @returns the moves needed to consolidate (deletes for the lower units, creates for the higher units) + */ + consolidate(location: Location): ItemMove[] { + this.applyPendingMoves() + const localMoney = this.location(l => isEqual(l, location)) + const total = localMoney.count + if (total === 0) return [] + const target = this.getGainMap(total) + const moves: ItemMove[] = [] + for (const unit of this.units) { + const current = localMoney.id(unit).getQuantity() + const delta = target[unit] - current + if (delta < 0) { + moves.push(localMoney.id(unit).deleteItem(-delta)) + } else if (delta > 0) { + moves.push(localMoney.createItem({ id: unit, location, quantity: delta })) } } this.pendingMoves.push(...moves) return moves } + private shouldConsolidateOnGain(amount: number, location: Location, option: ConsolidateOption | undefined): boolean { + if (!option) return false + if (option === true) return true + this.applyPendingMoves() + const items: MaterialItem[] = this.location(l => isEqual(l, location)).getItems().map(item => ({ ...item })) + const gainMap = this.getGainMap(amount) + for (const unit of this.units) { + const qty = gainMap[unit] + if (qty <= 0) continue + const existing = items.find(item => item.id === unit) + if (existing) { + existing.quantity = (existing.quantity ?? 1) + qty + } else { + items.push({ id: unit, location, quantity: qty }) + } + } + return option(items) + } + /** * Return the best way to gain an amount, prioritizing the highest unit values * @param amount Amount to gain, default 1 diff --git a/src/tests/MaterialMoney.test.ts b/src/tests/MaterialMoney.test.ts index 58d706a..3fa6541 100644 --- a/src/tests/MaterialMoney.test.ts +++ b/src/tests/MaterialMoney.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { MaterialItem, MaterialMoney } from '../material' +import { ConsolidateOption, MaterialItem, MaterialMoney } from '../material' describe('MaterialMoney', () => { describe('getSpendMap', () => { @@ -24,4 +24,152 @@ describe('MaterialMoney', () => { }) }) }) + + describe('consolidate', () => { + it('ne produit aucun mouvement quand la location est vide', () => { + const materialMoney = new MaterialMoney(1, [5, 1], []) + expect(materialMoney.consolidate({ type: 1 })).toEqual([]) + }) + + it('ne produit aucun mouvement quand la location est déjà minimale', () => { + const items: MaterialItem[] = [ + { id: 5, location: { type: 1 }, quantity: 1 }, + { id: 1, location: { type: 1 }, quantity: 2 } + ] + const materialMoney = new MaterialMoney(1, [5, 1], items) + expect(materialMoney.consolidate({ type: 1 })).toEqual([]) + }) + + it('échange 5 pièces de 1 contre 1 pièce de 5', () => { + const items: MaterialItem[] = [ + { id: 1, location: { type: 1 }, quantity: 5 } + ] + const materialMoney = new MaterialMoney(1, [5, 1], items) + materialMoney.consolidate({ type: 1 }) + expect(materialMoney.count).toBe(5) + expect(materialMoney.id(5).getQuantity()).toBe(1) + expect(materialMoney.id(1).getQuantity()).toBe(0) + }) + + it('ne consolide que la location ciblée', () => { + const items: MaterialItem[] = [ + { id: 1, location: { type: 1, player: 1 }, quantity: 5 }, + { id: 1, location: { type: 1, player: 2 }, quantity: 5 } + ] + const materialMoney = new MaterialMoney(1, [5, 1], items) + materialMoney.consolidate({ type: 1, player: 1 }) + expect(materialMoney.location(l => l.player === 1).id(5).getQuantity()).toBe(1) + expect(materialMoney.location(l => l.player === 1).id(1).getQuantity()).toBe(0) + expect(materialMoney.location(l => l.player === 2).id(1).getQuantity()).toBe(5) + }) + }) + + describe('moveMoney', () => { + it('échange direct : origine donne 5, cible rend 3', () => { + const items: MaterialItem[] = [ + { id: 5, location: { type: 1, player: 1 }, quantity: 1 }, + { id: 1, location: { type: 1, player: 2 }, quantity: 3 } + ] + const materialMoney = new MaterialMoney(1, [5, 1], items) + const moves = materialMoney.moveMoney( + { type: 1, player: 1 }, + { type: 1, player: 2 }, + 2 + ) + expect(moves).toHaveLength(2) + expect(materialMoney.location(l => l.player === 1).count).toBe(3) + expect(materialMoney.location(l => l.player === 1).id(5).getQuantity()).toBe(0) + expect(materialMoney.location(l => l.player === 1).id(1).getQuantity()).toBe(3) + expect(materialMoney.location(l => l.player === 2).count).toBe(5) + expect(materialMoney.location(l => l.player === 2).id(5).getQuantity()).toBe(1) + expect(materialMoney.location(l => l.player === 2).id(1).getQuantity()).toBe(0) + }) + + it('finalise la cible en distribution optimale via échange direct', () => { + const items: MaterialItem[] = [ + { id: 5, location: { type: 1, player: 1 }, quantity: 1 }, + { id: 1, location: { type: 1, player: 2 }, quantity: 4 } + ] + const materialMoney = new MaterialMoney(1, [5, 1], items) + const moves = materialMoney.moveMoney( + { type: 1, player: 1 }, + { type: 1, player: 2 }, + 1 + ) + expect(moves).toHaveLength(2) + expect(materialMoney.location(l => l.player === 1).count).toBe(4) + expect(materialMoney.location(l => l.player === 1).id(1).getQuantity()).toBe(4) + expect(materialMoney.location(l => l.player === 2).count).toBe(5) + expect(materialMoney.location(l => l.player === 2).id(5).getQuantity()).toBe(1) + expect(materialMoney.location(l => l.player === 2).id(1).getQuantity()).toBe(0) + }) + + it('fait appel à la banque quand l\'échange direct n\'est pas possible', () => { + const items: MaterialItem[] = [ + { id: 5, location: { type: 1, player: 1 }, quantity: 1 } + ] + const materialMoney = new MaterialMoney(1, [5, 1], items) + materialMoney.moveMoney( + { type: 1, player: 1 }, + { type: 1, player: 2 }, + 2 + ) + expect(materialMoney.location(l => l.player === 1).count).toBe(3) + expect(materialMoney.location(l => l.player === 2).count).toBe(2) + }) + + it('transfert simple par dénomination identique', () => { + const items: MaterialItem[] = [ + { id: 5, location: { type: 1, player: 1 }, quantity: 2 } + ] + const materialMoney = new MaterialMoney(1, [5, 1], items) + const moves = materialMoney.moveMoney( + { type: 1, player: 1 }, + { type: 1, player: 2 }, + 5 + ) + expect(moves).toHaveLength(1) + expect(materialMoney.location(l => l.player === 1).id(5).getQuantity()).toBe(1) + expect(materialMoney.location(l => l.player === 2).id(5).getQuantity()).toBe(1) + }) + }) + + describe('addMoney consolidation', () => { + it('ne consolide pas par défaut', () => { + const items: MaterialItem[] = [ + { id: 1, location: { type: 1 }, quantity: 1 } + ] + const materialMoney = new MaterialMoney(1, [5, 1], items) + materialMoney.addMoney(4, { type: 1 }) + expect(materialMoney.id(5).getQuantity()).toBe(0) + expect(materialMoney.id(1).getQuantity()).toBe(5) + }) + + it('consolide quand consolidate=true', () => { + const items: MaterialItem[] = [ + { id: 1, location: { type: 1 }, quantity: 1 } + ] + const materialMoney = new MaterialMoney(1, [5, 1], items) + materialMoney.addMoney(4, { type: 1 }, { consolidate: true }) + expect(materialMoney.count).toBe(5) + expect(materialMoney.id(5).getQuantity()).toBe(1) + expect(materialMoney.id(1).getQuantity()).toBe(0) + }) + + it('le prédicat ne consolide qu\'au franchissement du seuil', () => { + const consolidate: ConsolidateOption = items => + items.reduce((sum, i) => sum + (i.quantity ?? 1), 0) > 4 + const materialMoney = new MaterialMoney(1, [5, 1], []) + + materialMoney.addMoney(3, { type: 1 }, { consolidate }) + expect(materialMoney.count).toBe(3) + expect(materialMoney.id(5).getQuantity()).toBe(0) + expect(materialMoney.id(1).getQuantity()).toBe(3) + + materialMoney.addMoney(3, { type: 1 }, { consolidate }) + expect(materialMoney.count).toBe(6) + expect(materialMoney.id(5).getQuantity()).toBe(1) + expect(materialMoney.id(1).getQuantity()).toBe(1) + }) + }) })