import { describe, expect, it, vi } from 'vitest'; import type { DatabaseClient, GeneralTurnRow, NationTurnRow } from '../src/context.js'; import { MAX_GENERAL_TURNS, MAX_NATION_TURNS, expandGeneralTurnIndices, repeatGeneralTurns, repeatNationTurns, setGeneralTurn, setGeneralTurns, setNationTurn, setNationTurns, shiftGeneralTurns, shiftNationTurns, ReservedTurnRevisionConflictError, } from '../src/turns/reservedTurns.js'; const buildDb = () => { const generalTurns = new Map(); const nationTurns = new Map(); const generalRevisions = new Map(); const nationRevisions = new Map(); type GeneralTurnFindManyArgs = Parameters[0]; type GeneralTurnDeleteManyArgs = NonNullable[0]>; type GeneralTurnCreateManyArgs = NonNullable[0]>; type NationTurnFindManyArgs = Parameters[0]; type NationTurnDeleteManyArgs = NonNullable[0]>; type NationTurnCreateManyArgs = NonNullable[0]>; type GeneralRevisionFindArgs = Parameters[0]; type GeneralRevisionCreateManyArgs = NonNullable< Parameters[0] >; type GeneralRevisionUpdateManyArgs = NonNullable< Parameters[0] >; type NationRevisionFindArgs = Parameters[0]; type NationRevisionCreateManyArgs = NonNullable[0]>; type NationRevisionUpdateManyArgs = NonNullable[0]>; const db = { worldState: { findFirst: async () => null, }, general: { findUnique: async () => null, }, city: { findUnique: async () => null, }, nation: { findUnique: async () => null, }, generalTurn: { findMany: async (args?: GeneralTurnFindManyArgs) => { const generalId = typeof args?.where?.generalId === 'number' ? args.where.generalId : undefined; return generalId !== undefined ? (generalTurns.get(generalId) ?? []) : []; }, deleteMany: async ({ where }: GeneralTurnDeleteManyArgs) => { if (where && typeof where.generalId === 'number') { generalTurns.delete(where.generalId); } return {}; }, createMany: async ({ data }: GeneralTurnCreateManyArgs) => { const dataList = (Array.isArray(data) ? data : [data]) as Record[]; const rows: GeneralTurnRow[] = dataList.map((row, index: number) => ({ id: index + 1, generalId: row.generalId as number, turnIdx: row.turnIdx as number, actionCode: row.actionCode as string, arg: row.arg as unknown as GeneralTurnRow['arg'], createdAt: new Date(), })); const firstRow = dataList[0]; const generalId = firstRow?.generalId as number | undefined; if (generalId !== undefined) { generalTurns.set(generalId, rows); } return {}; }, }, generalTurnRevision: { findUnique: async ({ where }: GeneralRevisionFindArgs) => { const generalId = where.generalId as number; const revision = generalRevisions.get(generalId); return revision === undefined ? null : { generalId, revision, updatedAt: new Date(), }; }, createMany: async ({ data }: GeneralRevisionCreateManyArgs) => { const row = (Array.isArray(data) ? data[0] : data) as { generalId: number; revision?: number; }; if (generalRevisions.has(row.generalId)) { return { count: 0 }; } generalRevisions.set(row.generalId, row.revision ?? 0); return { count: 1 }; }, updateMany: async ({ where, data }: GeneralRevisionUpdateManyArgs) => { const generalId = typeof where?.generalId === 'number' ? where.generalId : -1; const expected = typeof where?.revision === 'number' ? where.revision : -1; if (generalRevisions.get(generalId) !== expected || typeof data.revision !== 'number') { return { count: 0 }; } generalRevisions.set(generalId, data.revision); return { count: 1 }; }, }, nationTurn: { findMany: async (args?: NationTurnFindManyArgs) => { const nationId = typeof args?.where?.nationId === 'number' ? args.where.nationId : undefined; const officerLevel = typeof args?.where?.officerLevel === 'number' ? args.where.officerLevel : undefined; if (nationId === undefined || officerLevel === undefined) { return []; } return nationTurns.get(`${nationId}:${officerLevel}`) ?? []; }, deleteMany: async ({ where }: NationTurnDeleteManyArgs) => { if (where && typeof where.nationId === 'number' && typeof where.officerLevel === 'number') { nationTurns.delete(`${where.nationId}:${where.officerLevel}`); } return {}; }, createMany: async ({ data }: NationTurnCreateManyArgs) => { const dataList = (Array.isArray(data) ? data : [data]) as Record[]; const rows: NationTurnRow[] = dataList.map((row, index: number) => ({ id: index + 1, nationId: row.nationId as number, officerLevel: row.officerLevel as number, turnIdx: row.turnIdx as number, actionCode: row.actionCode as string, arg: row.arg as unknown as NationTurnRow['arg'], createdAt: new Date(), })); const firstRow = dataList[0]; const nationId = firstRow?.nationId as number | undefined; const officerLevel = firstRow?.officerLevel as number | undefined; if (nationId !== undefined && officerLevel !== undefined) { nationTurns.set(`${nationId}:${officerLevel}`, rows); } return {}; }, }, nationTurnRevision: { findUnique: async ({ where }: NationRevisionFindArgs) => { const compound = where.nationId_officerLevel; if (!compound) { return null; } const key = `${compound.nationId}:${compound.officerLevel}`; const revision = nationRevisions.get(key); return revision === undefined ? null : { nationId: compound.nationId, officerLevel: compound.officerLevel, revision, updatedAt: new Date(), }; }, createMany: async ({ data }: NationRevisionCreateManyArgs) => { const row = (Array.isArray(data) ? data[0] : data) as { nationId: number; officerLevel: number; revision?: number; }; const key = `${row.nationId}:${row.officerLevel}`; if (nationRevisions.has(key)) { return { count: 0 }; } nationRevisions.set(key, row.revision ?? 0); return { count: 1 }; }, updateMany: async ({ where, data }: NationRevisionUpdateManyArgs) => { const nationId = typeof where?.nationId === 'number' ? where.nationId : -1; const officerLevel = typeof where?.officerLevel === 'number' ? where.officerLevel : -1; const expected = typeof where?.revision === 'number' ? where.revision : -1; const key = `${nationId}:${officerLevel}`; if (nationRevisions.get(key) !== expected || typeof data.revision !== 'number') { return { count: 0 }; } nationRevisions.set(key, data.revision); return { count: 1 }; }, }, } as unknown as DatabaseClient; return { db }; }; describe('reservedTurns', () => { it('sets and shifts general turns', async () => { const { db } = buildDb(); const initial = await setGeneralTurn(db, 1, 0, 'che_화계', { destCityId: 10 }, 0); expect(initial.revision).toBe(1); expect(initial.turns).toHaveLength(MAX_GENERAL_TURNS); expect(initial.turns[0]?.action).toBe('che_화계'); const pushed = await shiftGeneralTurns(db, 1, 1, initial.revision); expect(pushed.revision).toBe(2); expect(pushed.turns[0]?.action).toBe('휴식'); expect(pushed.turns[1]?.action).toBe('che_화계'); const pulled = await shiftGeneralTurns(db, 1, -1, pushed.revision); expect(pulled.turns[0]?.action).toBe('che_화계'); expect(pulled.turns[MAX_GENERAL_TURNS - 1]?.action).toBe('휴식'); await expect(setGeneralTurn(db, 1, 2, 'che_훈련', {}, 1)).rejects.toBeInstanceOf( ReservedTurnRevisionConflictError ); }); it('sets and shifts nation turns', async () => { const { db } = buildDb(); const initial = await setNationTurn( db, 2, 5, 0, 'che_포상', { isGold: true, amount: 200, destGeneralId: 7 }, 0 ); expect(initial.revision).toBe(1); expect(initial.turns).toHaveLength(MAX_NATION_TURNS); expect(initial.turns[0]?.action).toBe('che_포상'); const pushed = await shiftNationTurns(db, 2, 5, 1, initial.revision); expect(pushed.turns[0]?.action).toBe('휴식'); expect(pushed.turns[1]?.action).toBe('che_포상'); }); it('expands legacy general sentinel turn lists and applies bulk entries in order', async () => { const { db } = buildDb(); expect(expandGeneralTurnIndices([-1])).toEqual( Array.from({ length: MAX_GENERAL_TURNS / 2 }, (_, index) => index * 2) ); expect(expandGeneralTurnIndices([-2])).toEqual( Array.from({ length: MAX_GENERAL_TURNS / 2 }, (_, index) => index * 2 + 1) ); expect(expandGeneralTurnIndices([-3])).toEqual(Array.from({ length: MAX_GENERAL_TURNS }, (_, index) => index)); const result = await setGeneralTurns( db, 3, [ { turnIndices: expandGeneralTurnIndices([-1]), action: 'che_훈련', args: {}, }, { turnIndices: expandGeneralTurnIndices([-2]), action: 'che_사기진작', args: {}, }, { turnIndices: [29], action: '휴식', args: {}, }, ], 0 ); expect(result.turns[0]?.action).toBe('che_훈련'); expect(result.turns[1]?.action).toBe('che_사기진작'); expect(result.turns[28]?.action).toBe('che_훈련'); expect(result.turns[29]?.action).toBe('휴식'); }); it('repeats the leading general pattern at the legacy interval', async () => { const { db } = buildDb(); const seeded = await setGeneralTurns( db, 4, [ { turnIndices: [0], action: 'che_훈련', args: {} }, { turnIndices: [1], action: 'che_사기진작', args: {} }, { turnIndices: [2], action: 'che_징병', args: { crewTypeId: 1100, amount: 100 } }, ], 0 ); const repeated = await repeatGeneralTurns(db, 4, 3, seeded.revision); expect(repeated.turns.slice(0, 9).map((turn) => turn.action)).toEqual([ 'che_훈련', 'che_사기진작', 'che_징병', 'che_훈련', 'che_사기진작', 'che_징병', 'che_훈련', 'che_사기진작', 'che_징병', ]); }); it('supports nation bulk/repeat and preserves the legacy amount-12 no-op', async () => { const { db } = buildDb(); const seeded = await setNationTurns( db, 5, 12, [ { turnIndices: [0, 2], action: 'che_포상', args: { amount: 1 } }, { turnIndices: [1], action: 'che_몰수', args: { amount: 1 } }, ], 0 ); const repeated = await repeatNationTurns(db, 5, 12, 3, seeded.revision); expect(repeated.turns.slice(0, 6).map((turn) => turn.action)).toEqual([ 'che_포상', 'che_몰수', 'che_포상', 'che_포상', 'che_몰수', 'che_포상', ]); const noOpRepeat = await repeatNationTurns(db, 5, 12, 12, repeated.revision); expect(noOpRepeat).toEqual(repeated); const noOpPush = await shiftNationTurns(db, 5, 12, 12, repeated.revision); expect(noOpPush).toEqual(repeated); }); it('rejects an API writer while the daemon holds the queue lease without touching turns', async () => { const deleteMany = vi.fn(async () => ({})); const createMany = vi.fn(async () => ({})); const db = { generalTurnRevision: { updateMany: vi.fn(async () => ({ count: 0 })), createMany: vi.fn(async () => ({ count: 0 })), findUnique: vi.fn(async () => ({ generalId: 9, revision: 0, leaseOwner: 'daemon-1', leaseExpiresAt: new Date(Date.now() + 60_000), updatedAt: new Date(), })), }, generalTurn: { findMany: vi.fn(async () => []), deleteMany, createMany, }, } as unknown as DatabaseClient; await expect(setGeneralTurn(db, 9, 0, 'che_훈련', {}, 0)).rejects.toBeInstanceOf( ReservedTurnRevisionConflictError ); expect(deleteMany).not.toHaveBeenCalled(); expect(createMany).not.toHaveBeenCalled(); }); });