import { describe, expect, it } from 'vitest'; import { GAME_TICKS_PER_TURN } from '@sammo-ts/common'; import { parseScenarioGeneralPoolCandidate } from '@sammo-ts/logic'; import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js'; import { reserveSelectionPool } from '../src/turn/selectPoolService.js'; import type { TurnGeneralPoolEntry, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js'; interface TestPoolRow { id: number; uniqueName: string; ownerUserId: string | null; generalId: number | null; reservedUntil: Date | null; reservedUntilTick: bigint | null; info: Record; } interface PoolWhere { id?: { in: number[] }; ownerUserId?: string | null; generalId?: number | null; reservedUntil?: null | { lt?: Date; gte?: Date }; reservedUntilTick?: null | { lt?: bigint; gte?: bigint }; OR?: PoolWhere[]; } const acceptedAt = new Date('0200-05-01T00:00:00.000Z'); const buildRows = (): TestPoolRow[] => Array.from({ length: 29 }, (_, index) => { const uniqueName = `P${String(index + 1).padStart(2, '0')}`; return { id: index + 1, uniqueName, ownerUserId: index === 0 ? 'existing-user' : null, generalId: null, reservedUntil: index === 0 ? new Date(acceptedAt.getTime() + 30 * 60_000) : null, reservedUntilTick: index === 0 ? 3_000_000n : null, info: { uniqueName, generalName: uniqueName, leadership: 70, strength: 80, intel: 10, specialDomestic: null, dex: [100 + index, 0, 0, 0, 0], imgsvr: 0, picture: 'default.jpg', }, }; }); const buildWorld = (rows: TestPoolRow[]): InMemoryTurnWorld => { const generalPoolEntries: TurnGeneralPoolEntry[] = rows.map((row) => ({ id: row.id, uniqueName: row.uniqueName, ownerUserId: row.ownerUserId, generalId: row.generalId, reservedUntil: row.reservedUntil, reservedUntilTick: row.reservedUntilTick === null ? null : Number(row.reservedUntilTick), candidate: parseScenarioGeneralPoolCandidate(row), })); const state: TurnWorldState = { id: 1, currentYear: 200, currentMonth: 5, tickSeconds: 300, lastTurnTime: acceptedAt, meta: { hiddenSeed: 'selection-reservation-test' }, }; const snapshot: TurnWorldSnapshot = { scenarioConfig: { stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 75, npcMin: 10, chiefMin: 70 }, iconPath: '', map: { targetGeneralPool: 'SPoolUnderU30' }, const: {}, environment: { mapName: 'test', unitSet: 'default' }, }, map: { id: 'test', name: 'test', cities: [], defaults: { trust: 50, trade: 100, supplyState: 1, frontState: 0 }, }, generals: [], cities: [], nations: [], troops: [], diplomacy: [], events: [], initialEvents: [], generalPoolEntries, }; return new InMemoryTurnWorld(state, snapshot, { schedule: { entries: [{ startMinute: 0, tickMinutes: 5 }] }, }); }; const matchesPoolWhere = (row: TestPoolRow, where: PoolWhere): boolean => { if (where.id && !where.id.in.includes(row.id)) { return false; } if (where.ownerUserId !== undefined && row.ownerUserId !== where.ownerUserId) { return false; } if (where.generalId !== undefined && row.generalId !== where.generalId) { return false; } if (where.reservedUntil !== undefined) { if (where.reservedUntil === null) { if (row.reservedUntil !== null) { return false; } } else if ( row.reservedUntil === null || (where.reservedUntil.lt !== undefined && row.reservedUntil >= where.reservedUntil.lt) || (where.reservedUntil.gte !== undefined && row.reservedUntil < where.reservedUntil.gte) ) { return false; } } if (where.reservedUntilTick !== undefined) { if (where.reservedUntilTick === null) { if (row.reservedUntilTick !== null) { return false; } } else if ( row.reservedUntilTick === null || (where.reservedUntilTick.lt !== undefined && row.reservedUntilTick >= where.reservedUntilTick.lt) || (where.reservedUntilTick.gte !== undefined && row.reservedUntilTick < where.reservedUntilTick.gte) ) { return false; } } return where.OR === undefined || where.OR.some((alternative) => matchesPoolWhere(row, alternative)); }; const buildDb = (rows: TestPoolRow[]) => ({ $executeRaw: async () => 0, general: { findFirst: async () => null, }, selectPoolEntry: { findMany: async () => structuredClone(rows), updateMany: async (input: { where: PoolWhere; data: Partial }) => { let count = 0; for (const row of rows) { if (!matchesPoolWhere(row, input.where)) { continue; } Object.assign(row, input.data); count += 1; } return { count }; }, }, }); const worldState = { currentYear: 200, currentMonth: 5, tickSeconds: 300, config: { npcMode: 2, turnTermMinutes: 5, map: { targetGeneralPool: 'SPoolUnderU30' }, }, meta: { hiddenSeed: 'selection-reservation-test' }, }; describe('selection-pool reservation command state', () => { it('excludes current reservations and keeps serialized users disjoint in DB and memory', async () => { const rows = buildRows(); const world = buildWorld(rows); const db = buildDb(rows); const reserve = (userId: string, acceptedGameTick: number) => reserveSelectionPool({ db: db as never, world, worldState: worldState as never, userId, seedOwnerIdentity: userId, now: acceptedAt, acceptedGameTick, }); const first = await reserve('first-user', 0); const retried = await reserve('first-user', 1); const second = await reserve('second-user', 2); expect(retried).toEqual(first); expect(first.candidates).toHaveLength(14); expect(second.candidates).toHaveLength(14); expect(new Set(first.candidates.map((candidate) => candidate.uniqueName))).not.toContain('P01'); expect( first.candidates.some((candidate) => second.candidates.some((other) => other.uniqueName === candidate.uniqueName) ) ).toBe(false); expect(rows.filter((row) => row.ownerUserId === 'first-user')).toHaveLength(14); expect(rows.filter((row) => row.ownerUserId === 'second-user')).toHaveLength(14); expect(rows.find((row) => row.ownerUserId === 'first-user')?.reservedUntilTick).toBe( BigInt(2 * GAME_TICKS_PER_TURN) ); expect(first.validUntil).toBe(world.gameTickToDate(2 * GAME_TICKS_PER_TURN).toISOString()); expect(world.listGeneralPoolCandidates(acceptedAt)).toEqual([]); }); it('uses Ref nowTick equality and resynchronizes DB expiry into the in-memory pool', async () => { const rows = buildRows(); rows[0]!.ownerUserId = 'stale-user'; rows[0]!.reservedUntil = new Date(acceptedAt.getTime() + 60 * 60_000); rows[0]!.reservedUntilTick = -1n; rows[1]!.ownerUserId = 'exact-user'; rows[1]!.reservedUntil = new Date(acceptedAt.getTime() - 60_000); rows[1]!.reservedUntilTick = 0n; const world = buildWorld(rows); const db = buildDb(rows); const reserve = (userId: string, acceptedGameTick: number) => reserveSelectionPool({ db: db as never, world, worldState: worldState as never, userId, seedOwnerIdentity: userId, now: acceptedAt, acceptedGameTick, }); const first = await reserve('first-user', 0); expect(rows[0]!.ownerUserId).not.toBe('stale-user'); expect(rows[1]).toMatchObject({ ownerUserId: 'exact-user', reservedUntilTick: 0n }); expect(first.candidates.map((candidate) => candidate.uniqueName)).not.toContain('P02'); expect(first.validUntil).toBe(world.gameTickToDate(2 * GAME_TICKS_PER_TURN).toISOString()); await reserve('second-user', 1); expect(rows[1]!.ownerUserId).not.toBe('exact-user'); const synchronizedById = new Map(world.listGeneralPoolEntries()?.map((entry) => [entry.id, entry])); for (const row of rows) { expect(synchronizedById.get(row.id)).toMatchObject({ ownerUserId: row.ownerUserId, generalId: row.generalId, reservedUntil: row.reservedUntil, reservedUntilTick: row.reservedUntilTick === null ? null : Number(row.reservedUntilTick), }); } }); });