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)
+ })
+ })
})