Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
153 changes: 122 additions & 31 deletions src/material/items/MaterialMoney.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<P extends number = number, L extends number = number, Unit extends number = number> =
| boolean
| ((items: MaterialItem<P, L, Unit>[]) => 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,
Expand Down Expand Up @@ -67,14 +78,27 @@ export class MaterialMoney<P extends number = number, M extends number = number,
}

/**
* Create an amount of Money and put it in given location
* Create an amount of Money and put it in given location.
*
* When `consolidate` is set, the gain is *preventively* consolidated: instead of creating coins for the
* gained amount and then breaking lower-value coins back into a higher one, the moves are computed directly
* as deltas from the current state to the optimal distribution of (current + amount). Fewer moves, same final state.
* @param amount Amount to gain
* @param location The location to filter material onto, and to create new items in
* @param options Per-call options. `consolidate` triggers a preventive consolidation of the location during the gain.
* When a predicate is given, it is evaluated against the items that *would* result from a non-consolidated gain.
* @returns the moves that need to be played to perform the operation
*/
addMoney(amount: number, location: Location<P, L>): ItemMove<P, M, L>[] {
addMoney(amount: number, location: Location<P, L>, options: { consolidate?: ConsolidateOption<P, L, Unit> } = {}): ItemMove<P, M, L>[] {
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<P, L>): ItemMove<P, M, L>[] {
const moves: ItemMove<P, M, L>[] = []
const gainMap = this.getGainMap(amount)
for (const unit of this.units) {
Expand All @@ -86,6 +110,24 @@ export class MaterialMoney<P extends number = number, M extends number = number,
return moves
}

private gainConsolidated(amount: number, location: Location<P, L>): ItemMove<P, M, L>[] {
this.applyPendingMoves()
const localMoney = this.location(l => isEqual(l, location))
const newDist = this.getGainMap(localMoney.count + amount)
const moves: ItemMove<P, M, L>[] = []
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
Expand Down Expand Up @@ -113,7 +155,12 @@ export class MaterialMoney<P extends number = number, M extends number = number,
}

/**
* 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.
* Move an amount of money from a place to another place.
*
* The moves are always optimised: both origin and target end up in their canonical (highest-denomination-first)
* distribution, and any opportunity for a direct exchange between origin and target is taken before falling back
* on the bank. For example, if origin holds a single 5 and owes 2 to a target holding 3×1, origin gives the 5
* directly and the target returns 3×1, instead of breaking the 5 against the bank first.
* @param origin Location to remove money from
* @param target Location to move money to
* @param amount Amount of money to transfer
Expand All @@ -126,45 +173,89 @@ export class MaterialMoney<P extends number = number, M extends number = number,
const moves: ItemMove<P, M, L>[] = []
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<P, L>): ItemMove<P, M, L>[] {
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<P, M, L>[] = []
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<P, L>, option: ConsolidateOption<P, L, Unit> | undefined): boolean {
if (!option) return false
if (option === true) return true
this.applyPendingMoves()
const items: MaterialItem<P, L, Unit>[] = this.location(l => isEqual(l, location)).getItems<Unit>().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
Expand Down
150 changes: 149 additions & 1 deletion src/tests/MaterialMoney.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand All @@ -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)
})
})
})