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
5 changes: 5 additions & 0 deletions src/Rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -205,4 +205,9 @@ export type PlayMoveContext = {
* True if the move will not be kept in the game's history
*/
transient?: boolean

/**
* The player who initiated the action (used for interleaving during simultaneous phases)
*/
player?: number
}
45 changes: 41 additions & 4 deletions src/material/MaterialRules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { PlayMoveContext, Rules } from '../Rules'
import { hasTimeLimit, TimeLimit } from '../TimeLimit'
import { Undo } from '../Undo'
import { UnpredictableMoves } from '../UnpredictableMove'
import { Material, MaterialMutator } from './items'
import { Material, MaterialMutator, SimultaneousContext } from './items'
import { LocationStrategy } from './location'
import { MaterialGame } from './MaterialGame'
import { GameMemory, PlayerMemory } from './memory'
Expand Down Expand Up @@ -176,6 +176,36 @@ export abstract class MaterialRules<Player extends number = number, MaterialType
return new MaterialMutator(type, this.game.items[type]!, this.locationsStrategies[type], this.itemsCanMerge(type))
}

/**
* Returns a mutator with interleaving context for a specific player during simultaneous phases.
* Outside simultaneous phases or without a player, falls back to the standard mutator.
*/
mutatorForPlayer(type: MaterialType, player?: number): MaterialMutator<Player, MaterialType, LocationType> {
const interleaving = this.game.rule?.interleaving
if (interleaving && player !== undefined) {
const playerRank = interleaving.players.indexOf(player as Player)
if (playerRank !== -1) {
if (!this.game.items[type]) this.game.items[type] = []
if (!(type in interleaving.availableIndexes)) {
const items = this.game.items[type]!
const available: number[] = []
for (let i = 0; i < items.length; i++) {
if (items[i].quantity === 0) available.push(i)
}
available.push(items.length)
interleaving.availableIndexes[type] = available
}
const simultaneousContext: SimultaneousContext = {
availableIndexes: interleaving.availableIndexes[type],
playerRank,
numPlayers: interleaving.players.length
}
return new MaterialMutator(type, this.game.items[type]!, this.locationsStrategies[type], this.itemsCanMerge(type), this.constructor.name, simultaneousContext)
}
}
return this.mutator(type)
}

/**
* Items can sometime be stored with a quantity (for example, coins).
* By default, if you create or move similar items to the exact same location, they will merge into one item with a quantity.
Expand Down Expand Up @@ -284,7 +314,7 @@ export abstract class MaterialRules<Player extends number = number, MaterialType
consequences.push(...this.beforeItemMove(move, context))
}
if (!this.game.items[move.itemType]) this.game.items[move.itemType] = []
const mutator = this.mutator(move.itemType)
const mutator = this.mutatorForPlayer(move.itemType, context?.player)
mutator.applyMove(move)
if (this.game.droppedItems) {
this.game.droppedItems = this.game.droppedItems.filter((droppedItem) => {
Expand Down Expand Up @@ -353,9 +383,15 @@ export abstract class MaterialRules<Player extends number = number, MaterialType
case RuleMoveType.StartPlayerTurn:
this.game.rule = { id: move.id, player: move.player }
break
case RuleMoveType.StartSimultaneousRule:
this.game.rule = { id: move.id, players: move.players ?? this.game.players }
case RuleMoveType.StartSimultaneousRule: {
const players = move.players ?? this.game.players
this.game.rule = {
id: move.id,
players: [...players],
interleaving: { players: [...players].sort((a, b) => a - b), availableIndexes: {} }
}
break
}
case RuleMoveType.StartRule:
this.game.rule = { id: move.id, player: this.game.rule?.player }
break
Expand Down Expand Up @@ -468,6 +504,7 @@ export abstract class MaterialRules<Player extends number = number, MaterialType
* @returns true if the move outcome cannot be predicted on client side
*/
isUnpredictableMove(move: MaterialMove<Player, MaterialType, LocationType, RuleId>, _player: Player): boolean {
if (this.game.rule?.interleaving && move.kind === MoveKind.RulesMove && move.type !== RuleMoveType.EndPlayerTurn) return true
return isRoll(move)
}

Expand Down
55 changes: 50 additions & 5 deletions src/material/items/MaterialMutator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,19 @@ import {
} from '../moves'
import { Material, MaterialItem } from './index'

/**
* Context for interleaving item creation during simultaneous phases.
* Each player gets non-overlapping slots to ensure commutativity.
* availableIndexes lists all free indexes (tombstones with quantity===0) followed by items.length.
* The list is conceptually infinite: beyond stored entries, it continues sequentially.
* Player with rank r gets positions r, r+n, r+2n, ... in this list.
*/
export type SimultaneousContext = {
availableIndexes: number[]
playerRank: number
numPlayers: number
}

/**
* Helper class to change the state of any {@link MaterialItem} in a game implemented with {@link MaterialRules}.
*
Expand All @@ -30,13 +43,15 @@ export class MaterialMutator<P extends number = number, M extends number = numbe
* @param locationsStrategies The strategies that these items must follow
* @param canMerge Whether to items at the exact same location can merge into one item with a quantity
* @param rulesClassName Constructor name of the main rules class for logging
* @param simultaneousContext Optional context for interleaving during simultaneous phases
*/
constructor(
private readonly type: M,
private readonly items: MaterialItem<P, L>[],
private readonly locationsStrategies: Partial<Record<L, LocationStrategy<P, M, L>>> = {},
private readonly canMerge: boolean = true,
private readonly rulesClassName: string = ''
private readonly rulesClassName: string = '',
private readonly simultaneousContext?: SimultaneousContext
) {
}

Expand Down Expand Up @@ -94,11 +109,25 @@ export class MaterialMutator<P extends number = number, M extends number = numbe

private addItem(item: MaterialItem<P, L>): void {
this.applyAddItemStrategy(item)
const availableIndex = this.items.findIndex(item => item.quantity === 0)
if (availableIndex !== -1) {
this.items[availableIndex] = item
if (this.simultaneousContext) {
const { playerRank, numPlayers } = this.simultaneousContext
let k = 0
let targetIndex: number
do {
targetIndex = this.getAvailableIndex(playerRank + k * numPlayers)
k++
} while (targetIndex < this.items.length && this.items[targetIndex].quantity !== 0)
while (this.items.length <= targetIndex) {
this.items.push({ quantity: 0, location: {} as any })
}
this.items[targetIndex] = item
} else {
this.items.push(item)
const availableIndex = this.items.findIndex(item => item.quantity === 0)
if (availableIndex !== -1) {
this.items[availableIndex] = item
} else {
this.items.push(item)
}
}
}

Expand All @@ -110,11 +139,27 @@ export class MaterialMutator<P extends number = number, M extends number = numbe
getItemCreationIndex(newItem: MaterialItem<P, L>): number {
const mergeIndex = this.findMergeIndex(newItem)
if (mergeIndex !== -1) return mergeIndex
if (this.simultaneousContext) {
const { playerRank, numPlayers } = this.simultaneousContext
let k = 0
let targetIndex: number
do {
targetIndex = this.getAvailableIndex(playerRank + k * numPlayers)
k++
} while (targetIndex < this.items.length && this.items[targetIndex].quantity !== 0)
return targetIndex
}
const availableIndex = this.items.findIndex(item => item.quantity === 0)
if (availableIndex !== -1) return availableIndex
return this.items.length
}

private getAvailableIndex(position: number): number {
const { availableIndexes } = this.simultaneousContext!
if (position < availableIndexes.length) return availableIndexes[position]
return availableIndexes[availableIndexes.length - 1] + (position - availableIndexes.length + 1)
}

private applyAddItemStrategy(item: MaterialItem<P, L>): void {
if (item.location.type in this.locationsStrategies) {
const strategy = this.locationsStrategies[item.location.type]!
Expand Down
4 changes: 4 additions & 0 deletions src/material/rules/RuleStep.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,8 @@ export type RuleStep<Player extends number = number, RuleId extends number = num
id: RuleId
player?: Player
players?: Player[]
interleaving?: {
players: Player[]
availableIndexes: Record<number, number[]>
}
}
Loading