From 6eba8e24c34bfc7da5f6020f88264246decba8de Mon Sep 17 00:00:00 2001 From: hided62 Date: Wed, 5 Aug 2026 01:21:16 +0000 Subject: [PATCH] fix: skip deleted reserved turn queues during flush --- app/game-engine/src/turn/databaseHooks.ts | 29 +++++++++++++++++-- .../test/databaseHooksDeletedQueue.test.ts | 29 +++++++++++++++++++ ...thlyInvaderPersistence.integration.test.ts | 20 +++++++++++++ 3 files changed, 75 insertions(+), 3 deletions(-) create mode 100644 app/game-engine/test/databaseHooksDeletedQueue.test.ts diff --git a/app/game-engine/src/turn/databaseHooks.ts b/app/game-engine/src/turn/databaseHooks.ts index ac0aa02..b55e94d 100644 --- a/app/game-engine/src/turn/databaseHooks.ts +++ b/app/game-engine/src/turn/databaseHooks.ts @@ -26,7 +26,7 @@ import { asRecord } from '@sammo-ts/common'; import type { TurnDaemonCommandResult, TurnDaemonHooks } from '../lifecycle/types.js'; import type { InMemoryTurnWorld } from './inMemoryWorld.js'; -import type { InMemoryReservedTurnStore } from './reservedTurnStore.js'; +import type { InMemoryReservedTurnStore, ReservedTurnChanges } from './reservedTurnStore.js'; import { buildDiplomacyMeta } from '@sammo-ts/logic'; import { ensureItemInventory, withSerializedItemInventory } from '@sammo-ts/logic/items/index.js'; import { persistGeneralLifecycleEvents } from './generalTurnLifecyclePersistence.js'; @@ -43,6 +43,26 @@ export interface DatabaseTurnHooks { close(): Promise; } +export const excludeDeletedReservedTurnQueues = ( + changes: ReservedTurnChanges, + deletedGeneralIds: readonly number[], + deletedNationIds: readonly number[] +): ReservedTurnChanges => { + const deletedGenerals = new Set(deletedGeneralIds); + const deletedNations = new Set(deletedNationIds); + const keepGeneral = (generalId: number): boolean => !deletedGenerals.has(generalId); + const keepNation = (key: string): boolean => !deletedNations.has(Number(key.split(':', 1)[0])); + + return { + generalIds: changes.generalIds.filter(keepGeneral), + generalInitializationIds: changes.generalInitializationIds.filter(keepGeneral), + generalLeaseIds: changes.generalLeaseIds.filter(keepGeneral), + nationKeys: changes.nationKeys.filter(keepNation), + nationInitializationKeys: changes.nationInitializationKeys.filter(keepNation), + nationLeaseKeys: changes.nationLeaseKeys.filter(keepNation), + }; +}; + const asJson = (value: unknown): InputJsonValue => value as InputJsonValue; const formatLegacyNumber = (value: number): string => Math.round(value).toLocaleString('en-US'); @@ -602,6 +622,9 @@ export const createDatabaseTurnHooks = async ( pendingUnificationFinalizations, } = changes; const reservedTurnChanges = options?.reservedTurns?.peekDirtyState(); + const persistedReservedTurnChanges = reservedTurnChanges + ? excludeDeletedReservedTurnQueues(reservedTurnChanges, deletedGenerals, deletedNations) + : undefined; const worldStateUpdate: TurnEngineWorldStateUpdateInput = { currentYear: state.currentYear, @@ -1010,8 +1033,8 @@ export const createDatabaseTurnHooks = async ( message ); } - if (options?.reservedTurns && reservedTurnChanges) { - await options.reservedTurns.persistChanges(prisma, reservedTurnChanges); + if (options?.reservedTurns && persistedReservedTurnChanges) { + await options.reservedTurns.persistChanges(prisma, persistedReservedTurnChanges); } if (commandCompletion) { await prisma.inputEvent.update({ diff --git a/app/game-engine/test/databaseHooksDeletedQueue.test.ts b/app/game-engine/test/databaseHooksDeletedQueue.test.ts new file mode 100644 index 0000000..1ae9109 --- /dev/null +++ b/app/game-engine/test/databaseHooksDeletedQueue.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest'; + +import { excludeDeletedReservedTurnQueues } from '../src/turn/databaseHooks.js'; + +describe('excludeDeletedReservedTurnQueues', () => { + it('omits dirty, initialization, and leased queues owned by deleted entities', () => { + expect( + excludeDeletedReservedTurnQueues( + { + generalIds: [1, 2], + generalInitializationIds: [2, 3], + generalLeaseIds: [1, 2, 3], + nationKeys: ['10:12', '20:11'], + nationInitializationKeys: ['20:9', '30:12'], + nationLeaseKeys: ['10:12', '20:11', '30:12'], + }, + [2], + [20] + ) + ).toEqual({ + generalIds: [1], + generalInitializationIds: [3], + generalLeaseIds: [1, 3], + nationKeys: ['10:12'], + nationInitializationKeys: ['30:12'], + nationLeaseKeys: ['10:12', '30:12'], + }); + }); +}); diff --git a/app/game-engine/test/monthlyInvaderPersistence.integration.test.ts b/app/game-engine/test/monthlyInvaderPersistence.integration.test.ts index 3001ec2..cb7c871 100644 --- a/app/game-engine/test/monthlyInvaderPersistence.integration.test.ts +++ b/app/game-engine/test/monthlyInvaderPersistence.integration.test.ts @@ -524,6 +524,12 @@ integration('RaiseInvader database persistence', () => { ).toBe(30); } + await reservedTurns.prepareTurnsForExecution(firstCreatedGeneralId, { + nationId: createdNationId, + officerLevel: 12, + }); + reservedTurns.shiftGeneralTurns(firstCreatedGeneralId, -1); + reservedTurns.shiftNationTurns(createdNationId, 12, -1); for (const generalId of createdGeneralIds) { expect(world.removeGeneral(generalId)).toBe(true); } @@ -539,6 +545,20 @@ integration('RaiseInvader database persistence', () => { }); expect(await db.event.findUnique({ where: { id: createdEventIds[1]! } })).toBeNull(); + expect(await db.generalTurnRevision.findUnique({ where: { generalId: firstCreatedGeneralId } })).toBeNull(); + expect( + await db.nationTurnRevision.findUnique({ + where: { + nationId_officerLevel: { nationId: createdNationId, officerLevel: 12 }, + }, + }) + ).toBeNull(); + expect(reservedTurns.inspectState()).toMatchObject({ + dirtyGeneralIds: [], + dirtyNationKeys: [], + leasedGeneralIds: [], + leasedNationKeys: [], + }); expect(await db.worldState.findUniqueOrThrow({ where: { id: stateRow.id } })).toMatchObject({ meta: expect.objectContaining({ isunited: 3, isUnited: 3, refreshLimit: 300 }), });