diff --git a/src/Rules.ts b/src/Rules.ts index 56ca1ad..13de0f4 100644 --- a/src/Rules.ts +++ b/src/Rules.ts @@ -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 } diff --git a/src/material/MaterialRules.ts b/src/material/MaterialRules.ts index 570cd6c..2110e81 100644 --- a/src/material/MaterialRules.ts +++ b/src/material/MaterialRules.ts @@ -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' @@ -176,6 +176,36 @@ export abstract class MaterialRules { + 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. @@ -284,7 +314,7 @@ export abstract class MaterialRules { @@ -353,9 +383,15 @@ export abstract class MaterialRules a - b), availableIndexes: {} } + } break + } case RuleMoveType.StartRule: this.game.rule = { id: move.id, player: this.game.rule?.player } break @@ -468,6 +504,7 @@ export abstract class MaterialRules, _player: Player): boolean { + if (this.game.rule?.interleaving && move.kind === MoveKind.RulesMove && move.type !== RuleMoveType.EndPlayerTurn) return true return isRoll(move) } diff --git a/src/material/items/MaterialMutator.ts b/src/material/items/MaterialMutator.ts index ced0054..bcc64dd 100644 --- a/src/material/items/MaterialMutator.ts +++ b/src/material/items/MaterialMutator.ts @@ -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}. * @@ -30,13 +43,15 @@ export class MaterialMutator

[], private readonly locationsStrategies: Partial>> = {}, private readonly canMerge: boolean = true, - private readonly rulesClassName: string = '' + private readonly rulesClassName: string = '', + private readonly simultaneousContext?: SimultaneousContext ) { } @@ -94,11 +109,25 @@ export class MaterialMutator

): 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) + } } } @@ -110,11 +139,27 @@ export class MaterialMutator

): 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): void { if (item.location.type in this.locationsStrategies) { const strategy = this.locationsStrategies[item.location.type]! diff --git a/src/material/rules/RuleStep.ts b/src/material/rules/RuleStep.ts index 5330692..98ab7ac 100644 --- a/src/material/rules/RuleStep.ts +++ b/src/material/rules/RuleStep.ts @@ -5,4 +5,8 @@ export type RuleStep + } } \ No newline at end of file diff --git a/src/tests/SimultaneousInterleaving.test.ts b/src/tests/SimultaneousInterleaving.test.ts new file mode 100644 index 0000000..155e83e --- /dev/null +++ b/src/tests/SimultaneousInterleaving.test.ts @@ -0,0 +1,707 @@ +import { describe, expect, it } from 'vitest' +import { + CreateItem, + ItemMoveType, + MaterialGame, + MaterialItem, + MaterialMove, + MaterialMutator, + MaterialRules, + MaterialRulesPartCreator, + MoveKind, + RuleMoveType, + SimultaneousContext, + SimultaneousRule +} from '../material' + +// Test enums +enum TestMaterial { Card = 1, Token = 2 } + +enum TestLocation { Hand = 1, Board = 2 } + +enum TestRule { Simultaneous = 1, PlayerTurn = 2 } + +type TestGame = MaterialGame + +// Helper to create a CreateItem move +function createItemMove(itemType: M, item: MaterialItem): CreateItem { + return { kind: MoveKind.ItemMove, type: ItemMoveType.Create, itemType, item } +} + +// Helper to create a StartSimultaneousRule move +function startSimultaneous(players?: number[]) { + return { kind: MoveKind.RulesMove as const, type: RuleMoveType.StartSimultaneousRule as const, id: TestRule.Simultaneous, players } +} + +// Helper to create an EndPlayerTurn move +function endPlayerTurn(player: number) { + return { kind: MoveKind.RulesMove as const, type: RuleMoveType.EndPlayerTurn as const, player } +} + +describe('MaterialMutator with SimultaneousContext', () => { + + describe('addItem with interleaving', () => { + it('should place items at interleaved slots for player rank 0', () => { + const items: MaterialItem[] = [] + const ctx: SimultaneousContext = { availableIndexes: [0], playerRank: 0, numPlayers: 2 } + const mutator = new MaterialMutator(TestMaterial.Card, items, {}, true, '', ctx) + + const move1 = createItemMove(TestMaterial.Card, { location: { type: TestLocation.Hand, player: 1 } }) + mutator.applyMove(move1) + expect(items.length).toBe(1) + expect(items[0].location.player).toBe(1) + + const move2 = createItemMove(TestMaterial.Card, { location: { type: TestLocation.Hand, player: 1 }, id: 2 }) + mutator.applyMove(move2) + // availableIndexes=[0], rank 0, numPlayers 2: positions 0, 2, 4... => indexes 0, 2, 4... + expect(items.length).toBe(3) // slot 0 used, slot 1 placeholder, slot 2 used + expect(items[0].location.player).toBe(1) + expect(items[1].quantity).toBe(0) // placeholder + expect(items[2].id).toBe(2) + }) + + it('should place items at interleaved slots for player rank 1', () => { + const items: MaterialItem[] = [] + const ctx: SimultaneousContext = { availableIndexes: [0], playerRank: 1, numPlayers: 2 } + const mutator = new MaterialMutator(TestMaterial.Card, items, {}, true, '', ctx) + + const move1 = createItemMove(TestMaterial.Card, { location: { type: TestLocation.Hand, player: 2 } }) + mutator.applyMove(move1) + // availableIndexes=[0], rank 1: position 1 => index 0+1+1=2... wait + // position 1 in [0]: beyond list, so 0 + (1-1+1) = 1 + expect(items.length).toBe(2) + expect(items[0].quantity).toBe(0) // placeholder + expect(items[1].location.player).toBe(2) + }) + + it('should append after existing items when no tombstones', () => { + const items: MaterialItem[] = [ + { location: { type: TestLocation.Board } }, + { location: { type: TestLocation.Board } } + ] + // availableIndexes=[2] means no tombstones, next free slot is at index 2 + const ctx: SimultaneousContext = { availableIndexes: [2], playerRank: 0, numPlayers: 2 } + const mutator = new MaterialMutator(TestMaterial.Card, items, {}, true, '', ctx) + + const move = createItemMove(TestMaterial.Card, { location: { type: TestLocation.Hand, player: 1 } }) + mutator.applyMove(move) + expect(items.length).toBe(3) + expect(items[2].location.player).toBe(1) + }) + + it('should reuse tombstones listed in availableIndexes', () => { + const items: MaterialItem[] = [ + { location: { type: TestLocation.Board }, quantity: 0 }, // tombstone at index 0 + { location: { type: TestLocation.Board } } + ] + // availableIndexes=[0, 2] means tombstone at 0 and next free at 2 + const ctx: SimultaneousContext = { availableIndexes: [0, 2], playerRank: 0, numPlayers: 2 } + const mutator = new MaterialMutator(TestMaterial.Card, items, {}, true, '', ctx) + + const move = createItemMove(TestMaterial.Card, { location: { type: TestLocation.Hand, player: 1 } }) + mutator.applyMove(move) + // Tombstone at index 0 should be reused + expect(items.length).toBe(2) // no growth! + expect(items[0].location.player).toBe(1) + }) + + it('should distribute tombstones among players', () => { + const items: MaterialItem[] = [ + { location: { type: TestLocation.Board }, quantity: 0 }, // tombstone 0 + { location: { type: TestLocation.Board } }, // live item 1 + { location: { type: TestLocation.Board }, quantity: 0 }, // tombstone 2 + { location: { type: TestLocation.Board } } // live item 3 + ] + // 2 tombstones (0, 2) + array end (4) + const available = [0, 2, 4] + + // Player 0 gets positions 0, 2 => indexes 0, 4 + const ctx0: SimultaneousContext = { availableIndexes: available, playerRank: 0, numPlayers: 2 } + const mut0 = new MaterialMutator(TestMaterial.Card, items, {}, false, '', ctx0) + mut0.applyMove(createItemMove(TestMaterial.Card, { location: { type: TestLocation.Hand, player: 1 }, id: 10 })) + + // Player 1 gets positions 1, 3 => indexes 2, 5 + const ctx1: SimultaneousContext = { availableIndexes: available, playerRank: 1, numPlayers: 2 } + const mut1 = new MaterialMutator(TestMaterial.Card, items, {}, false, '', ctx1) + mut1.applyMove(createItemMove(TestMaterial.Card, { location: { type: TestLocation.Hand, player: 2 }, id: 20 })) + + expect(items[0].id).toBe(10) // player 0 reused tombstone at 0 + expect(items[2].id).toBe(20) // player 1 reused tombstone at 2 + expect(items.length).toBe(4) // no growth! + }) + }) + + describe('getItemCreationIndex with interleaving', () => { + it('should predict the correct interleaved index', () => { + const items: MaterialItem[] = [] + const ctx: SimultaneousContext = { availableIndexes: [0], playerRank: 1, numPlayers: 3 } + const mutator = new MaterialMutator(TestMaterial.Card, items, {}, true, '', ctx) + + const item: MaterialItem = { location: { type: TestLocation.Hand, player: 2 } } + const index = mutator.getItemCreationIndex(item) + expect(index).toBe(1) // rank 1: position 1 in [0] => 0 + (1-1+1) = 1 + + // After creating, next index should be 4 (position 4 => 0 + (4-1+1) = 4) + mutator.applyMove(createItemMove(TestMaterial.Card, item)) + const nextIndex = mutator.getItemCreationIndex({ location: { type: TestLocation.Hand, player: 2 }, id: 2 }) + expect(nextIndex).toBe(4) + }) + + it('should still merge when possible', () => { + const items: MaterialItem[] = [ + { location: { type: TestLocation.Board }, quantity: 3 } + ] + const ctx: SimultaneousContext = { availableIndexes: [1], playerRank: 0, numPlayers: 2 } + const mutator = new MaterialMutator(TestMaterial.Card, items, {}, true, '', ctx) + + // Same item data should merge regardless of interleaving + const item: MaterialItem = { location: { type: TestLocation.Board }, quantity: 2 } + const index = mutator.getItemCreationIndex(item) + expect(index).toBe(0) // merge with existing item + }) + }) + + describe('commutativity', () => { + it('should produce same result regardless of player order (2 players)', () => { + const player1Item: MaterialItem = { location: { type: TestLocation.Hand, player: 1 } } + const player2Item: MaterialItem = { location: { type: TestLocation.Hand, player: 2 } } + const available = [0] + + // Order A: player 1 first, then player 2 + const itemsA: MaterialItem[] = [] + const mutatorA1 = new MaterialMutator(TestMaterial.Card, itemsA, {}, true, '', { availableIndexes: available, playerRank: 0, numPlayers: 2 }) + mutatorA1.applyMove(createItemMove(TestMaterial.Card, player1Item)) + const mutatorA2 = new MaterialMutator(TestMaterial.Card, itemsA, {}, true, '', { availableIndexes: available, playerRank: 1, numPlayers: 2 }) + mutatorA2.applyMove(createItemMove(TestMaterial.Card, player2Item)) + + // Order B: player 2 first, then player 1 + const itemsB: MaterialItem[] = [] + const mutatorB2 = new MaterialMutator(TestMaterial.Card, itemsB, {}, true, '', { availableIndexes: available, playerRank: 1, numPlayers: 2 }) + mutatorB2.applyMove(createItemMove(TestMaterial.Card, player2Item)) + const mutatorB1 = new MaterialMutator(TestMaterial.Card, itemsB, {}, true, '', { availableIndexes: available, playerRank: 0, numPlayers: 2 }) + mutatorB1.applyMove(createItemMove(TestMaterial.Card, player1Item)) + + // Both should produce same state + expect(itemsA).toEqual(itemsB) + expect(itemsA[0].location.player).toBe(1) // rank 0 -> index 0 + expect(itemsA[1].location.player).toBe(2) // rank 1 -> index 1 + }) + + it('should produce same result with multiple items per player', () => { + const p1Items = [ + { location: { type: TestLocation.Hand, player: 1 }, id: 1 } as MaterialItem, + { location: { type: TestLocation.Hand, player: 1 }, id: 2 } as MaterialItem + ] + const p2Items = [ + { location: { type: TestLocation.Hand, player: 2 }, id: 3 } as MaterialItem, + { location: { type: TestLocation.Hand, player: 2 }, id: 4 } as MaterialItem + ] + const available = [0] + + // Order A: P1 creates 2, then P2 creates 2 + const itemsA: MaterialItem[] = [] + const ctxA1: SimultaneousContext = { availableIndexes: available, playerRank: 0, numPlayers: 2 } + const mutA1 = new MaterialMutator(TestMaterial.Card, itemsA, {}, false, '', ctxA1) + mutA1.applyMove(createItemMove(TestMaterial.Card, p1Items[0])) + mutA1.applyMove(createItemMove(TestMaterial.Card, p1Items[1])) + const ctxA2: SimultaneousContext = { availableIndexes: available, playerRank: 1, numPlayers: 2 } + const mutA2 = new MaterialMutator(TestMaterial.Card, itemsA, {}, false, '', ctxA2) + mutA2.applyMove(createItemMove(TestMaterial.Card, p2Items[0])) + mutA2.applyMove(createItemMove(TestMaterial.Card, p2Items[1])) + + // Order B: P2 creates 2, then P1 creates 2 + const itemsB: MaterialItem[] = [] + const ctxB2: SimultaneousContext = { availableIndexes: available, playerRank: 1, numPlayers: 2 } + const mutB2 = new MaterialMutator(TestMaterial.Card, itemsB, {}, false, '', ctxB2) + mutB2.applyMove(createItemMove(TestMaterial.Card, p2Items[0])) + mutB2.applyMove(createItemMove(TestMaterial.Card, p2Items[1])) + const ctxB1: SimultaneousContext = { availableIndexes: available, playerRank: 0, numPlayers: 2 } + const mutB1 = new MaterialMutator(TestMaterial.Card, itemsB, {}, false, '', ctxB1) + mutB1.applyMove(createItemMove(TestMaterial.Card, p1Items[0])) + mutB1.applyMove(createItemMove(TestMaterial.Card, p1Items[1])) + + expect(itemsA).toEqual(itemsB) + // P1 (rank 0): indices 0, 2 + // P2 (rank 1): indices 1, 3 + expect(itemsA[0].id).toBe(1) + expect(itemsA[1].id).toBe(3) + expect(itemsA[2].id).toBe(2) + expect(itemsA[3].id).toBe(4) + }) + + it('should produce same result with 3 players', () => { + const makeItem = (player: number, id: number): MaterialItem => + ({ location: { type: TestLocation.Hand, player }, id }) + const available = [0] + + const applyForPlayer = (items: MaterialItem[], rank: number, player: number, id: number) => { + const ctx: SimultaneousContext = { availableIndexes: available, playerRank: rank, numPlayers: 3 } + const mutator = new MaterialMutator(TestMaterial.Card, items, {}, false, '', ctx) + mutator.applyMove(createItemMove(TestMaterial.Card, makeItem(player, id))) + } + + // Order A: P1, P2, P3 + const itemsA: MaterialItem[] = [] + applyForPlayer(itemsA, 0, 1, 10) + applyForPlayer(itemsA, 1, 2, 20) + applyForPlayer(itemsA, 2, 3, 30) + + // Order B: P3, P1, P2 + const itemsB: MaterialItem[] = [] + applyForPlayer(itemsB, 2, 3, 30) + applyForPlayer(itemsB, 0, 1, 10) + applyForPlayer(itemsB, 1, 2, 20) + + // Order C: P2, P3, P1 + const itemsC: MaterialItem[] = [] + applyForPlayer(itemsC, 1, 2, 20) + applyForPlayer(itemsC, 2, 3, 30) + applyForPlayer(itemsC, 0, 1, 10) + + expect(itemsA).toEqual(itemsB) + expect(itemsA).toEqual(itemsC) + }) + + it('should be commutative with merge', () => { + // Both players create items that merge with existing ones + const existingItems: () => MaterialItem[] = () => [ + { location: { type: TestLocation.Board, player: 1 }, quantity: 3 }, + { location: { type: TestLocation.Board, player: 2 }, quantity: 5 } + ] + const available = [2] + + // Order A: P1 adds 2 to their stack, then P2 adds 3 to their stack + const itemsA = existingItems() + const ctxA1: SimultaneousContext = { availableIndexes: available, playerRank: 0, numPlayers: 2 } + const mutA1 = new MaterialMutator(TestMaterial.Token, itemsA, {}, true, '', ctxA1) + mutA1.applyMove(createItemMove(TestMaterial.Token, { location: { type: TestLocation.Board, player: 1 }, quantity: 2 })) + const ctxA2: SimultaneousContext = { availableIndexes: available, playerRank: 1, numPlayers: 2 } + const mutA2 = new MaterialMutator(TestMaterial.Token, itemsA, {}, true, '', ctxA2) + mutA2.applyMove(createItemMove(TestMaterial.Token, { location: { type: TestLocation.Board, player: 2 }, quantity: 3 })) + + // Order B: P2 first, then P1 + const itemsB = existingItems() + const ctxB2: SimultaneousContext = { availableIndexes: available, playerRank: 1, numPlayers: 2 } + const mutB2 = new MaterialMutator(TestMaterial.Token, itemsB, {}, true, '', ctxB2) + mutB2.applyMove(createItemMove(TestMaterial.Token, { location: { type: TestLocation.Board, player: 2 }, quantity: 3 })) + const ctxB1: SimultaneousContext = { availableIndexes: available, playerRank: 0, numPlayers: 2 } + const mutB1 = new MaterialMutator(TestMaterial.Token, itemsB, {}, true, '', ctxB1) + mutB1.applyMove(createItemMove(TestMaterial.Token, { location: { type: TestLocation.Board, player: 1 }, quantity: 2 })) + + expect(itemsA).toEqual(itemsB) + expect(itemsA[0].quantity).toBe(5) // 3 + 2 + expect(itemsA[1].quantity).toBe(8) // 5 + 3 + }) + }) + + describe('without interleaving (no simultaneous context)', () => { + it('should use normal addItem behavior', () => { + const items: MaterialItem[] = [ + { location: { type: TestLocation.Board }, quantity: 0 } // tombstone + ] + const mutator = new MaterialMutator(TestMaterial.Card, items, {}, true, '') + + const move = createItemMove(TestMaterial.Card, { location: { type: TestLocation.Hand, player: 1 } }) + mutator.applyMove(move) + + // Without interleaving, tombstone at index 0 should be reused + expect(items.length).toBe(1) + expect(items[0].location.player).toBe(1) + }) + + it('getItemCreationIndex should use normal behavior', () => { + const items: MaterialItem[] = [ + { location: { type: TestLocation.Board }, quantity: 0 } // tombstone + ] + const mutator = new MaterialMutator(TestMaterial.Card, items, {}, true, '') + + const item: MaterialItem = { location: { type: TestLocation.Hand, player: 1 } } + expect(mutator.getItemCreationIndex(item)).toBe(0) // reuse tombstone + }) + }) +}) + +// Integration test with MaterialRules +class TestSimultaneousRule extends SimultaneousRule { + getActivePlayerLegalMoves(_player: number): MaterialMove[] { + return [] + } + + getMovesAfterPlayersDone(): MaterialMove[] { + return [] + } +} + +class TestRules extends MaterialRules { + rules: Record> = { + [TestRule.Simultaneous]: TestSimultaneousRule, + [TestRule.PlayerTurn]: TestSimultaneousRule // placeholder + } +} + +function createTestGame(players: number[]): TestGame { + return { + players, + items: {}, + memory: {} + } +} + +describe('MaterialRules interleaving integration', () => { + + it('should initialize interleaving on StartSimultaneousRule', () => { + const game = createTestGame([1, 2, 3]) + const rules = new TestRules(game) + rules.play(startSimultaneous()) + + expect(game.rule).toBeDefined() + expect(game.rule!.interleaving).toBeDefined() + expect(game.rule!.interleaving!.players).toEqual([1, 2, 3]) + expect(game.rule!.interleaving!.availableIndexes).toEqual({}) + }) + + it('should initialize interleaving with subset of players', () => { + const game = createTestGame([1, 2, 3]) + const rules = new TestRules(game) + rules.play(startSimultaneous([3, 1])) + + expect(game.rule!.interleaving!.players).toEqual([1, 3]) // sorted + expect(game.rule!.players).toEqual([3, 1]) // original order preserved + }) + + it('should lazily initialize availableIndexes on first item creation', () => { + const game = createTestGame([1, 2]) + game.items = { [TestMaterial.Card]: [{ location: { type: TestLocation.Board } }] } + const rules = new TestRules(game) + rules.play(startSimultaneous()) + + // availableIndexes should be empty initially + expect(game.rule!.interleaving!.availableIndexes).toEqual({}) + + // Create an item for player 1 + const createMove = createItemMove(TestMaterial.Card, { location: { type: TestLocation.Hand, player: 1 } }) + rules.play(createMove, { player: 1 }) + + // availableIndexes should list tombstones + array length (no tombstones here, so just [1]) + expect(game.rule!.interleaving!.availableIndexes[TestMaterial.Card]).toEqual([1]) + }) + + it('should collect existing tombstones in availableIndexes', () => { + const game = createTestGame([1, 2]) + game.items = { + [TestMaterial.Card]: [ + { location: { type: TestLocation.Board }, quantity: 0 }, // tombstone at 0 + { location: { type: TestLocation.Board } }, // live at 1 + { location: { type: TestLocation.Board }, quantity: 0 }, // tombstone at 2 + ] + } + const rules = new TestRules(game) + rules.play(startSimultaneous()) + + // Create an item to trigger lazy init + const createMove = createItemMove(TestMaterial.Card, { location: { type: TestLocation.Hand, player: 1 } }) + rules.play(createMove, { player: 1 }) + + // Should have collected tombstones at 0 and 2, plus array length 3 + expect(game.rule!.interleaving!.availableIndexes[TestMaterial.Card]).toEqual([0, 2, 3]) + // Player 1 (rank 0) should have used index 0 (first available for rank 0) + expect(game.items[TestMaterial.Card]![0].location.player).toBe(1) + expect(game.items[TestMaterial.Card]!.length).toBe(3) // no growth! + }) + + it('should produce commutative results through MaterialRules.play', () => { + const player1Card = createItemMove(TestMaterial.Card, { location: { type: TestLocation.Hand, player: 1 }, id: 1 }) + const player2Card = createItemMove(TestMaterial.Card, { location: { type: TestLocation.Hand, player: 2 }, id: 2 }) + + // Order A: player 1 then player 2 + const gameA = createTestGame([1, 2]) + const rulesA = new TestRules(gameA) + rulesA.play(startSimultaneous()) + rulesA.play(player1Card, { player: 1 }) + rulesA.play(player2Card, { player: 2 }) + + // Order B: player 2 then player 1 + const gameB = createTestGame([1, 2]) + const rulesB = new TestRules(gameB) + rulesB.play(startSimultaneous()) + rulesB.play(player2Card, { player: 2 }) + rulesB.play(player1Card, { player: 1 }) + + expect(gameA.items[TestMaterial.Card]).toEqual(gameB.items[TestMaterial.Card]) + }) + + it('should not use interleaving without player context', () => { + const game = createTestGame([1, 2]) + const rules = new TestRules(game) + rules.play(startSimultaneous()) + + // Create without player context (e.g., from onRuleStart consequences) + const createMove = createItemMove(TestMaterial.Card, { location: { type: TestLocation.Board } }) + rules.play(createMove) + + // Should use normal behavior (no interleaving) + expect(game.items[TestMaterial.Card]!.length).toBe(1) + expect(game.items[TestMaterial.Card]![0].location.type).toBe(TestLocation.Board) + }) + + it('should clear interleaving on rule change', () => { + const game = createTestGame([1, 2]) + const rules = new TestRules(game) + rules.play(startSimultaneous()) + expect(game.rule!.interleaving).toBeDefined() + + // Change to player turn + rules.play({ + kind: MoveKind.RulesMove, + type: RuleMoveType.StartPlayerTurn, + id: TestRule.PlayerTurn, + player: 1 + }) + expect(game.rule!.interleaving).toBeUndefined() + }) + + it('should keep interleaving players stable after EndPlayerTurn', () => { + const game = createTestGame([1, 2, 3]) + const rules = new TestRules(game) + rules.play(startSimultaneous()) + + const originalInterleavingPlayers = [...game.rule!.interleaving!.players] + + // Player 1 ends their turn + rules.play(endPlayerTurn(1)) + + // The interleaving.players should NOT change (ranks must remain stable) + expect(game.rule!.interleaving!.players).toEqual(originalInterleavingPlayers) + // But rule.players should change + expect(game.rule!.players).toEqual([2, 3]) + }) + + it('should handle split MoveItem (quantity) during simultaneous phase', () => { + const game = createTestGame([1, 2]) + game.items = { + [TestMaterial.Token]: [ + { location: { type: TestLocation.Board, player: 1 }, quantity: 5 }, + { location: { type: TestLocation.Board, player: 2 }, quantity: 5 } + ] + } + const rules = new TestRules(game) + rules.play(startSimultaneous()) + + // Player 1 moves 2 tokens from their stack to hand (splits the item) + const moveP1 = { + kind: MoveKind.ItemMove as const, + type: ItemMoveType.Move as const, + itemType: TestMaterial.Token, + itemIndex: 0, + location: { type: TestLocation.Hand, player: 1 }, + quantity: 2 + } + + // Player 2 moves 3 tokens from their stack to hand (splits the item) + const moveP2 = { + kind: MoveKind.ItemMove as const, + type: ItemMoveType.Move as const, + itemType: TestMaterial.Token, + itemIndex: 1, + location: { type: TestLocation.Hand, player: 2 }, + quantity: 3 + } + + // Order A + const gameA = createTestGame([1, 2]) + gameA.items = { + [TestMaterial.Token]: [ + { location: { type: TestLocation.Board, player: 1 }, quantity: 5 }, + { location: { type: TestLocation.Board, player: 2 }, quantity: 5 } + ] + } + const rulesA = new TestRules(gameA) + rulesA.play(startSimultaneous()) + rulesA.play(moveP1, { player: 1 }) + rulesA.play(moveP2, { player: 2 }) + + // Order B + const gameB = createTestGame([1, 2]) + gameB.items = { + [TestMaterial.Token]: [ + { location: { type: TestLocation.Board, player: 1 }, quantity: 5 }, + { location: { type: TestLocation.Board, player: 2 }, quantity: 5 } + ] + } + const rulesB = new TestRules(gameB) + rulesB.play(startSimultaneous()) + rulesB.play(moveP2, { player: 2 }) + rulesB.play(moveP1, { player: 1 }) + + expect(gameA.items[TestMaterial.Token]).toEqual(gameB.items[TestMaterial.Token]) + }) +}) + +describe('Index efficiency with tombstone reuse', () => { + it('should reuse tombstones across simultaneous phases, keeping array compact', () => { + const game = createTestGame([1, 2, 3, 4]) + const rules = new TestRules(game) + + // Phase 1: each player creates 3 tokens + rules.play(startSimultaneous()) + for (const player of [1, 2, 3, 4]) { + for (let i = 0; i < 3; i++) { + rules.play( + createItemMove(TestMaterial.Token, { location: { type: TestLocation.Hand, player }, id: player * 100 + i }), + { player } + ) + } + } + expect(game.items[TestMaterial.Token]!.length).toBe(12) // 4 players * 3 items + + // Simulate end of phase: delete half the tokens (6 items become tombstones) + rules.play({ kind: MoveKind.RulesMove, type: RuleMoveType.StartPlayerTurn, id: TestRule.PlayerTurn, player: 1 }) + for (let i = 0; i < 6; i++) { + rules.play({ + kind: MoveKind.ItemMove, type: ItemMoveType.Delete, itemType: TestMaterial.Token, itemIndex: i + }) + } + + // Phase 2: new simultaneous phase, each player creates 3 more tokens + rules.play(startSimultaneous()) + for (const player of [1, 2, 3, 4]) { + for (let i = 0; i < 3; i++) { + rules.play( + createItemMove(TestMaterial.Token, { location: { type: TestLocation.Hand, player }, id: player * 1000 + i }), + { player } + ) + } + } + + // With the old system (baseIndex), array would grow to 12 + 12 = 24 + // With tombstone reuse, 6 tombstones are reused, so array grows by only 6 + expect(game.items[TestMaterial.Token]!.length).toBe(18) // 12 + 6 new (6 reused tombstones) + + // Verify all new items exist + const liveItems = game.items[TestMaterial.Token]!.filter(item => item.quantity !== 0) + expect(liveItems.length).toBe(18) // 6 survived from phase 1 + 12 new from phase 2 + }) + + it('should be commutative with tombstone reuse across phases', () => { + // Phase 1: create items, then delete some, then phase 2 creates more + const setupGame = () => { + const game = createTestGame([1, 2]) + const rules = new TestRules(game) + rules.play(startSimultaneous()) + // Each player creates 2 items + for (const player of [1, 2]) { + for (let i = 0; i < 2; i++) { + rules.play( + createItemMove(TestMaterial.Token, { location: { type: TestLocation.Hand, player }, id: player * 10 + i }), + { player } + ) + } + } + // End phase, delete items at index 0 and 2 + rules.play({ kind: MoveKind.RulesMove, type: RuleMoveType.StartPlayerTurn, id: TestRule.PlayerTurn, player: 1 }) + rules.play({ kind: MoveKind.ItemMove, type: ItemMoveType.Delete, itemType: TestMaterial.Token, itemIndex: 0 }) + rules.play({ kind: MoveKind.ItemMove, type: ItemMoveType.Delete, itemType: TestMaterial.Token, itemIndex: 2 }) + // Start new simultaneous phase + rules.play(startSimultaneous()) + return { game, rules } + } + + // Order A: player 1 first + const { game: gameA, rules: rulesA } = setupGame() + rulesA.play(createItemMove(TestMaterial.Token, { location: { type: TestLocation.Hand, player: 1 }, id: 100 }), { player: 1 }) + rulesA.play(createItemMove(TestMaterial.Token, { location: { type: TestLocation.Hand, player: 2 }, id: 200 }), { player: 2 }) + + // Order B: player 2 first + const { game: gameB, rules: rulesB } = setupGame() + rulesB.play(createItemMove(TestMaterial.Token, { location: { type: TestLocation.Hand, player: 2 }, id: 200 }), { player: 2 }) + rulesB.play(createItemMove(TestMaterial.Token, { location: { type: TestLocation.Hand, player: 1 }, id: 100 }), { player: 1 }) + + expect(gameA.items[TestMaterial.Token]).toEqual(gameB.items[TestMaterial.Token]) + // Tombstones should have been reused, no array growth + expect(gameA.items[TestMaterial.Token]!.length).toBe(4) + }) +}) + +describe('isUnpredictableMove during simultaneous phase', () => { + it('should mark rule-changing moves as unpredictable during simultaneous phase', () => { + const game = createTestGame([1, 2]) + const rules = new TestRules(game) + rules.play(startSimultaneous()) + + // StartPlayerTurn should be unpredictable during simultaneous phase + const startPlayerTurnMove = { + kind: MoveKind.RulesMove as const, + type: RuleMoveType.StartPlayerTurn as const, + id: TestRule.PlayerTurn, + player: 1 + } + expect(rules.isUnpredictableMove(startPlayerTurnMove, 1)).toBe(true) + + // StartRule should be unpredictable + const startRuleMove = { + kind: MoveKind.RulesMove as const, + type: RuleMoveType.StartRule as const, + id: TestRule.PlayerTurn + } + expect(rules.isUnpredictableMove(startRuleMove, 1)).toBe(true) + + // EndGame should be unpredictable + const endGameMove = { + kind: MoveKind.RulesMove as const, + type: RuleMoveType.EndGame as const + } + expect(rules.isUnpredictableMove(endGameMove, 1)).toBe(true) + + // EndPlayerTurn should NOT be unpredictable (it's the player's own move) + expect(rules.isUnpredictableMove(endPlayerTurn(1), 1)).toBe(false) + + // Item moves should NOT be unpredictable (unless roll) + const createMove = createItemMove(TestMaterial.Card, { location: { type: TestLocation.Hand, player: 1 } }) + expect(rules.isUnpredictableMove(createMove, 1)).toBe(false) + }) + + it('should NOT mark rule-changing moves as unpredictable outside simultaneous phase', () => { + const game = createTestGame([1, 2]) + const rules = new TestRules(game) + + // Start a player turn (not simultaneous) + rules.play({ + kind: MoveKind.RulesMove, + type: RuleMoveType.StartPlayerTurn, + id: TestRule.PlayerTurn, + player: 1 + }) + + // StartPlayerTurn consequence should NOT be unpredictable + const startPlayerTurnMove = { + kind: MoveKind.RulesMove as const, + type: RuleMoveType.StartPlayerTurn as const, + id: TestRule.PlayerTurn, + player: 2 + } + expect(rules.isUnpredictableMove(startPlayerTurnMove, 1)).toBe(false) + }) + + it('should still mark rule moves as unpredictable after all players ended turn (interleaving persists)', () => { + const game = createTestGame([1, 2]) + const rules = new TestRules(game) + rules.play(startSimultaneous()) + + // Both players end their turn + rules.play(endPlayerTurn(1)) + rules.play(endPlayerTurn(2)) + + // interleaving is still present even though players is empty + expect(game.rule!.interleaving).toBeDefined() + expect(game.rule!.players).toEqual([]) + + // Rule change should still be unpredictable + const startPlayerTurnMove = { + kind: MoveKind.RulesMove as const, + type: RuleMoveType.StartPlayerTurn as const, + id: TestRule.PlayerTurn, + player: 1 + } + expect(rules.isUnpredictableMove(startPlayerTurnMove, 1)).toBe(true) + }) +}) diff --git a/src/utils/action-view.util.ts b/src/utils/action-view.util.ts index d62e193..5d51da9 100644 --- a/src/utils/action-view.util.ts +++ b/src/utils/action-view.util.ts @@ -35,14 +35,15 @@ export function playActionWithViews( // Prepare action view for spectators actionWithView.views.push({ action: { id, playerId, move: getMoveView(rules, move), consequences: [] } }) - const consequences = rules.play(JSON.parse(JSON.stringify(move))) + const context = { player: playerId as number } + const consequences = rules.play(JSON.parse(JSON.stringify(move)), context) applyAutomaticMoves(rules, consequences, move => { actionWithView.action.consequences.push(move) for (const view of actionWithView.views) { view.action.consequences.push(getMoveView(rules, move, view.recipient)) } - }) + }, context) return actionWithView } diff --git a/src/utils/action.util.ts b/src/utils/action.util.ts index 0fec49a..d89dabe 100644 --- a/src/utils/action.util.ts +++ b/src/utils/action.util.ts @@ -10,16 +10,18 @@ export function playAction(rules: Rules = { playerId, move, consequences: [] } - const consequences = rules.play(JSON.parse(JSON.stringify(move))) + const context = { player: playerId as number } + const consequences = rules.play(JSON.parse(JSON.stringify(move)), context) - applyAutomaticMoves(rules, consequences, move => action.consequences.push(move)) + applyAutomaticMoves(rules, consequences, move => action.consequences.push(move), context) return action } export function replayAction(rules: Rules, action: Action) { - rules.play(JSON.parse(JSON.stringify(action.move))) - action.consequences.forEach(move => rules.play(JSON.parse(JSON.stringify(move)))) + const context = { player: action.playerId as number } + rules.play(JSON.parse(JSON.stringify(action.move)), context) + action.consequences.forEach(move => rules.play(JSON.parse(JSON.stringify(move)), context)) } export function replayActions(rules: Rules, actions: Action[]) { diff --git a/src/utils/automatic-moves.util.ts b/src/utils/automatic-moves.util.ts index a47952a..563d0b0 100644 --- a/src/utils/automatic-moves.util.ts +++ b/src/utils/automatic-moves.util.ts @@ -1,8 +1,8 @@ -import { Rules } from '../Rules' +import { PlayMoveContext, Rules } from '../Rules' import { loopWithFuse } from './loops.util' import { hasRandomMove } from './random.util' -export function applyAutomaticMoves(rules: Rules, moves: Move[] = [], preprocessMove?: (move: Move) => void) { +export function applyAutomaticMoves(rules: Rules, moves: Move[] = [], preprocessMove?: (move: Move) => void, context?: PlayMoveContext) { loopWithFuse(() => { if (moves.length === 0 && rules.getAutomaticMoves) { moves.push(...rules.getAutomaticMoves()) @@ -11,7 +11,7 @@ export function applyAutomaticMoves(rules: Rules new Error(`Infinite loop detected while applying move consequences: ${JSON.stringify(moves)})`) })