From 4dac08b916ce8aebe40fbd2d19bdc5d404cb4b7d Mon Sep 17 00:00:00 2001 From: hided62 Date: Sat, 12 Sep 2026 08:04:48 +0000 Subject: [PATCH] =?UTF-8?q?=ED=86=A0=EB=84=88=EB=A8=BC=ED=8A=B8=20NPC=20?= =?UTF-8?q?=EA=B0=9C=EB=B0=A9=20=EB=B2=A0=ED=8C=85=EC=9D=84=20=EB=B3=B5?= =?UTF-8?q?=EA=B5=AC=ED=95=98=EA=B3=A0=20=EC=9E=A5=EC=88=98=20DB=20?= =?UTF-8?q?=EC=A0=80=EC=9E=A5=EC=9D=84=20=EC=9D=BC=EA=B4=84=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/game-api/src/tournament/store.ts | 17 +- app/game-api/src/tournament/types.ts | 1 + app/game-api/src/tournament/worker.ts | 6 +- app/game-api/src/tournament/workerHelpers.ts | 79 ++++-- .../test/tournamentStoreRevision.test.ts | 2 +- app/game-api/test/tournamentWorker.test.ts | 156 +++++++++++- app/game-engine/src/turn/databaseHooks.ts | 47 ++-- .../src/turn/generalBatchPersistence.ts | 100 ++++++++ ...eneralBatchPersistence.integration.test.ts | 235 ++++++++++++++++++ 9 files changed, 581 insertions(+), 62 deletions(-) create mode 100644 app/game-engine/src/turn/generalBatchPersistence.ts create mode 100644 app/game-engine/test/generalBatchPersistence.integration.test.ts diff --git a/app/game-api/src/tournament/store.ts b/app/game-api/src/tournament/store.ts index 6ea917f4..277c0d64 100644 --- a/app/game-api/src/tournament/store.ts +++ b/app/game-api/src/tournament/store.ts @@ -28,6 +28,14 @@ export class CorruptTournamentProjectionError extends Error { } } +const zTournamentBet = z + .object({ + generalId: z.number().int(), + targetId: z.number().int(), + amount: z.number(), + }) + .passthrough(); + const zTournamentState = z .object({ stage: z.number().int(), @@ -46,6 +54,7 @@ const zTournamentState = z bettingCloseTick: z.number().int().safe().optional(), winnerId: z.number().int().optional(), bettingSettled: z.boolean().optional(), + npcBettingPlan: z.array(zTournamentBet).optional(), rewardSettled: z.boolean().optional(), participantsLockedAt: z.string().optional(), lastError: z.string().optional(), @@ -111,14 +120,6 @@ const zTournamentMatch = z }) .passthrough(); -const zTournamentBet = z - .object({ - generalId: z.number().int(), - targetId: z.number().int(), - amount: z.number(), - }) - .passthrough(); - const parseProjection = (raw: string | null, key: string, schema: z.ZodType): T | null => { if (raw === null) { return null; diff --git a/app/game-api/src/tournament/types.ts b/app/game-api/src/tournament/types.ts index 0511bb3c..f464b24b 100644 --- a/app/game-api/src/tournament/types.ts +++ b/app/game-api/src/tournament/types.ts @@ -17,6 +17,7 @@ export interface TournamentState { bettingCloseTick?: number; winnerId?: number; bettingSettled?: boolean; + npcBettingPlan?: TournamentBetEntry[]; rewardSettled?: boolean; participantsLockedAt?: string; lastError?: string; diff --git a/app/game-api/src/tournament/worker.ts b/app/game-api/src/tournament/worker.ts index 2ce052be..3cf8884b 100644 --- a/app/game-api/src/tournament/worker.ts +++ b/app/game-api/src/tournament/worker.ts @@ -465,8 +465,12 @@ export const applyPreBattleStage = async ( bettingCloseAt: resolveBettingCloseAt(state), nextAt: resolveNextAt(state), }; - await store.setState(nextState); + // Keep the opening identity across retries, but do not expose stage 6 + // until its initial NPC bets have been written successfully. + await store.setState({ ...state, bettingId: nextState.bettingId }); await seedNpcBets({ prisma, store, state: nextState, baseSeed, daemonTransport }); + nextState.npcBettingPlan = undefined; + await store.setState(nextState); return nextState; } diff --git a/app/game-api/src/tournament/workerHelpers.ts b/app/game-api/src/tournament/workerHelpers.ts index da8a7990..6e1ff7ee 100644 --- a/app/game-api/src/tournament/workerHelpers.ts +++ b/app/game-api/src/tournament/workerHelpers.ts @@ -696,31 +696,38 @@ const resolveNumber = (source: Record, keys: string[], fallback return fallback; }; -export const seedNpcBets = async (options: { +const buildNpcBettingPlan = async (options: { prisma: TournamentPrismaClient; store: TournamentStore; state: TournamentState; baseSeed: string; - daemonTransport: TurnDaemonTransport; -}): Promise => { - const { prisma, store, state, baseSeed, daemonTransport } = options; +}): Promise => { + const { prisma, store, state, baseSeed } = options; const existing = await store.getBettingEntries(); - if (existing.length > 0) { - return; - } + const existingBettors = new Set(existing.map((entry) => entry.generalId)); const matches = await store.getMatches(); const candidateIds = Array.from( - new Set(matches.filter((match) => match.stage === 7).flatMap((match) => [match.attackerId, match.defenderId])) + new Set( + matches + .filter((match) => match.stage === 7) + .flatMap((match) => [match.attackerId, match.defenderId]) + .filter((id) => id > 0) + ) ); if (candidateIds.length === 0) { - return; + return []; } const worldState = await prisma.worldState.findFirst(); const config = asRecord(worldState?.config ?? {}); const constValues = asRecord(config.const ?? config); - const startYear = resolveNumber(constValues, ['startYear', 'startyear'], state.openYear); + const scenarioMeta = asRecord(asRecord(worldState?.meta).scenarioMeta); + const startYear = resolveNumber( + scenarioMeta, + ['startYear'], + resolveNumber(constValues, ['startYear', 'startyear'], state.openYear) + ); const currentYear = worldState?.currentYear ?? state.openYear; const betGold = Math.max(10, Math.floor((3 + currentYear - startYear) * 0.334) * 10); @@ -733,9 +740,10 @@ export const seedNpcBets = async (options: { }); const npcBetList = npcList .map((entry) => asRecord(entry)) - .filter((entry) => typeof entry.id === 'number' && typeof entry.gold === 'number'); + .filter((entry) => typeof entry.id === 'number' && typeof entry.gold === 'number') + .sort((left, right) => (left.id as number) - (right.id as number)); if (npcBetList.length === 0) { - return; + return []; } const rng = createTournamentRng(baseSeed, { @@ -748,27 +756,52 @@ export const seedNpcBets = async (options: { extraSeed: `OpenBettingTournament:${state.bettingId ?? 'none'}`, }); - const entries = [...existing]; + const entries: TournamentBetEntry[] = []; for (const npc of npcBetList) { const targetId = rng.choice(candidateIds); - entries.push({ generalId: npc.id as number, targetId, amount: betGold }); + if (!existingBettors.has(npc.id as number)) { + entries.push({ generalId: npc.id as number, targetId, amount: betGold }); + } + } + return entries; +}; + +export const seedNpcBets = async (options: { + prisma: TournamentPrismaClient; + store: TournamentStore; + state: TournamentState; + baseSeed: string; + daemonTransport: TurnDaemonTransport; +}): Promise => { + const { store, state, daemonTransport } = options; + // Persist the selected pool, amounts and targets before any resource command. + // A retry must not reselect after a debit changes an NPC's eligibility. + const plan = state.npcBettingPlan ?? (await buildNpcBettingPlan(options)); + if (plan.length === 0) { + return; + } + if (!state.npcBettingPlan) { + const storedState = await store.getState(); + if (!storedState) { + throw new Error('Tournament state missing while preparing NPC bets.'); + } + await store.setState({ ...storedState, npcBettingPlan: plan }); } + const requestPrefix = `tournament:${state.bettingId ?? `${state.openYear}:${state.openMonth}:${state.type}`}:npc-bet`; await daemonTransport.sendCommand({ type: 'adjustGeneralResources', + requestId: `${requestPrefix}:resources`, reason: 'tournamentNpcBet', - adjustments: npcBetList.map((npc) => ({ - generalId: npc.id as number, - goldDelta: -betGold, - })), + adjustments: plan.map((entry) => ({ generalId: entry.generalId, goldDelta: -entry.amount })), }); await daemonTransport.sendCommand({ type: 'adjustGeneralMeta', + requestId: `${requestPrefix}:meta`, reason: 'tournamentNpcBet', - adjustments: npcBetList.map((npc) => ({ - generalId: npc.id as number, - metaDelta: { betgold: betGold }, - })), + adjustments: plan.map((entry) => ({ generalId: entry.generalId, metaDelta: { betgold: entry.amount } })), }); - await store.setBettingEntries(entries); + const existing = await store.getBettingEntries(); + const existingBettors = new Set(existing.map((entry) => entry.generalId)); + await store.setBettingEntries(existing.concat(plan.filter((entry) => !existingBettors.has(entry.generalId)))); }; diff --git a/app/game-api/test/tournamentStoreRevision.test.ts b/app/game-api/test/tournamentStoreRevision.test.ts index 5cf3bbfd..50aaa08b 100644 --- a/app/game-api/test/tournamentStoreRevision.test.ts +++ b/app/game-api/test/tournamentStoreRevision.test.ts @@ -174,6 +174,7 @@ describe('TournamentStore source revision', () => { ['bettingId', '123'], ['rewardSettled', 'yes'], ['bettingSettled', 1], + ['npcBettingPlan', [{ generalId: 3, targetId: 11, amount: '10' }]], ] as const) { await redis.set(keys.stateKey, JSON.stringify({ ...canonicalState, [field]: value })); await expect(store.getState(), field).rejects.toBeInstanceOf(CorruptTournamentProjectionError); @@ -239,5 +240,4 @@ describe('TournamentStore source revision', () => { await redis.set(keys.matchesKey, JSON.stringify([{ ...match, lastEnergy: { attacker: '90', defender: 0 } }])); await expect(store.getMatches()).rejects.toBeInstanceOf(CorruptTournamentProjectionError); }); - }); diff --git a/app/game-api/test/tournamentWorker.test.ts b/app/game-api/test/tournamentWorker.test.ts index bb633a2e..9281c1e3 100644 --- a/app/game-api/test/tournamentWorker.test.ts +++ b/app/game-api/test/tournamentWorker.test.ts @@ -17,6 +17,7 @@ import { buildBettingPayouts, resolveBettingCloseAt, resolveNextAt, + seedNpcBets, } from '../src/tournament/workerHelpers.js'; import type { TurnDaemonTransport } from '../src/daemon/transport.js'; @@ -149,6 +150,7 @@ const createPrismaMock = (options: { }>; baseSeed?: string; currentYear?: number; + startYear?: number; }) => { const applicants = options.applicants ?? []; const npcs = options.npcs ?? []; @@ -169,7 +171,11 @@ const createPrismaMock = (options: { if (isRecord(npcState) && typeof npcState.gte === 'number') { const gold = isRecord(where.gold) ? where.gold : null; if (isRecord(gold) && typeof gold.gte === 'number') { - return npcBetting; + const minimumGold = gold.gte; + const minimumNpcState = npcState.gte; + return npcBetting.filter( + (entry) => entry.gold >= minimumGold && entry.npcState >= minimumNpcState + ); } return npcs; } @@ -180,8 +186,8 @@ const createPrismaMock = (options: { }, worldState: { findFirst: async () => ({ - meta: { hiddenSeed: options.baseSeed ?? 'seed' }, - config: { const: { startYear: 1 } }, + meta: { hiddenSeed: options.baseSeed ?? 'seed', scenarioMeta: { startYear: options.startYear ?? 1 } }, + config: { const: {} }, currentYear: options.currentYear ?? 1, }), }, @@ -403,6 +409,150 @@ describe('tournament worker (in-memory)', () => { }); }); + it.each([ + [200, 200, 10], + [201, 200, 10], + [203, 200, 20], + [210, 200, 40], + [230, 200, 110], + ])('seeds Ref opening bets in year %i from scenario year %i (%i gold)', async (currentYear, startYear, amount) => { + const store = new TournamentStore(new MemoryRedis(), buildTournamentKeys('npc-opening')); + const state = createTournamentState({ stage: 5, openYear: currentYear, bettingId: 123 }); + await store.setState(state); + await store.setMatches([ + { id: 1, stage: 7, roundIndex: 0, attackerId: 11, defenderId: 12 }, + { id: 2, stage: 7, roundIndex: 1, attackerId: -1, defenderId: 13 }, + ]); + // Existing user bets must not suppress the opening NPC pool. + await store.setBettingEntries([{ generalId: 90, targetId: 11, amount: 100 }]); + const npcBetting = [0, 1, 2, 3, 4, 5, 6].map((npcState) => ({ + id: npcState + 1, + name: `NPC${npcState}`, + leadership: 50, + strength: 50, + intel: 50, + meta: {}, + npcState, + gold: 500 + amount, + })); + npcBetting.push({ ...npcBetting[2]!, id: 80, gold: 499 + amount }); + const prisma = createPrismaMock({ npcBetting, currentYear, startYear }); + const commands: TurnDaemonCommand[] = []; + const daemonTransport: TurnDaemonTransport = { + ...createNoopDaemonTransport(), + sendCommand: async (command) => { + expect((await store.getState())?.stage).toBe(5); + commands.push(command); + return 'ok'; + }, + }; + const opened = await applyPreBattleStage(store, prisma, state, 'opening-seed', daemonTransport); + expect(opened.stage).toBe(6); + const bets = await store.getBettingEntries(); + expect(bets[0]).toEqual({ generalId: 90, targetId: 11, amount: 100 }); + expect(bets.slice(1).map((bet) => bet.generalId)).toEqual([3, 4, 5, 6, 7]); + expect(bets.slice(1).every((bet) => bet.amount === amount && [11, 12, 13].includes(bet.targetId))).toBe(true); + expect(new Set(bets.slice(1).map((bet) => bet.targetId)).size).toBeGreaterThan(1); + expect(commands).toEqual([ + { + type: 'adjustGeneralResources', + requestId: 'tournament:123:npc-bet:resources', + reason: 'tournamentNpcBet', + adjustments: [3, 4, 5, 6, 7].map((generalId) => ({ generalId, goldDelta: -amount })), + }, + { + type: 'adjustGeneralMeta', + requestId: 'tournament:123:npc-bet:meta', + reason: 'tournamentNpcBet', + adjustments: [3, 4, 5, 6, 7].map((generalId) => ({ generalId, metaDelta: { betgold: amount } })), + }, + ]); + await seedNpcBets({ prisma, store, state: opened, baseSeed: 'opening-seed', daemonTransport }); + expect(await store.getBettingEntries()).toEqual(bets); + expect(commands).toHaveLength(2); + // A fresh projection with the same seed and DB inputs has the same choices. + await store.setBettingEntries([bets[0]!]); + await seedNpcBets({ + prisma, + store, + state: opened, + baseSeed: 'opening-seed', + daemonTransport: createNoopDaemonTransport(), + }); + expect(await store.getBettingEntries()).toEqual(bets); + }); + + it('retries an opening query failure without advancing stage or changing the betting identity', async () => { + const store = new TournamentStore(new MemoryRedis(), buildTournamentKeys('npc-opening-retry')); + const state = createTournamentState({ stage: 5 }); + await store.setState(state); + await store.setMatches([{ id: 1, stage: 7, roundIndex: 0, attackerId: 11, defenderId: 12 }]); + const prisma = createPrismaMock({ + npcBetting: [ + { id: 3, name: 'n장', leadership: 50, strength: 50, intel: 50, meta: {}, npcState: 2, gold: 1000 }, + ], + }); + const findMany = prisma.general.findMany; + prisma.general.findMany = async () => { + throw new Error('query unavailable'); + }; + const transport = createNoopDaemonTransport(); + await expect(applyPreBattleStage(store, prisma, state, 'seed', transport, () => 1000)).rejects.toThrow( + 'query unavailable' + ); + const retryState = (await store.getState())!; + expect(retryState).toMatchObject({ stage: 5, bettingId: 1000 }); + expect(await store.getBettingEntries()).toEqual([]); + prisma.general.findMany = findMany; + const opened = await applyPreBattleStage(store, prisma, retryState, 'seed', transport, () => 2000); + expect(opened).toMatchObject({ stage: 6, bettingId: 1000 }); + expect(await store.getBettingEntries()).toHaveLength(1); + }); + + it('reuses durable command identities after a partial enqueue failure', async () => { + const store = new TournamentStore(new MemoryRedis(), buildTournamentKeys('npc-enqueue-retry')); + const state = createTournamentState({ stage: 5, bettingId: 456 }); + await store.setState(state); + await store.setMatches([{ id: 1, stage: 7, roundIndex: 0, attackerId: 11, defenderId: 12 }]); + const prisma = createPrismaMock({ + npcBetting: [ + { id: 3, name: 'n장', leadership: 50, strength: 50, intel: 50, meta: {}, npcState: 2, gold: 1000 }, + ], + }); + const commands: TurnDaemonCommand[] = []; + let fail = true; + const transport: TurnDaemonTransport = { + ...createNoopDaemonTransport(), + sendCommand: async (command) => { + commands.push(command); + if (command.type === 'adjustGeneralMeta' && fail) { + fail = false; + // The accepted debit may already have committed before retry. + prisma.general.findMany = async () => []; + throw new Error('enqueue unavailable'); + } + return 'ok'; + }, + }; + await expect(applyPreBattleStage(store, prisma, state, 'seed', transport)).rejects.toThrow( + 'enqueue unavailable' + ); + expect(await store.getState()).toMatchObject({ + stage: 5, + npcBettingPlan: [{ generalId: 3, targetId: expect.any(Number), amount: 10 }], + }); + expect(await store.getBettingEntries()).toEqual([]); + await applyPreBattleStage(store, prisma, (await store.getState())!, 'seed', transport); + expect(commands.slice(2)).toEqual(commands.slice(0, 2)); + expect(commands.map((command) => command.requestId)).toEqual([ + 'tournament:456:npc-bet:resources', + 'tournament:456:npc-bet:meta', + 'tournament:456:npc-bet:resources', + 'tournament:456:npc-bet:meta', + ]); + expect(await store.getBettingEntries()).toHaveLength(1); + }); + it('runs all four tournament types and emits enough rank and NPC-betting commands for a top ten', async () => { for (const type of [ TournamentType.TOTAL, diff --git a/app/game-engine/src/turn/databaseHooks.ts b/app/game-engine/src/turn/databaseHooks.ts index a696e7c9..d6802cf8 100644 --- a/app/game-engine/src/turn/databaseHooks.ts +++ b/app/game-engine/src/turn/databaseHooks.ts @@ -1,3 +1,4 @@ +import { persistGeneralAccessScores, persistGeneralUpdates } from './generalBatchPersistence.js'; import { areSeasonRecordsFinalized } from './seasonRecords.js'; import { acquireGameSchemaAdvisoryXactLock, @@ -1791,32 +1792,26 @@ export const createDatabaseTurnHooks = async ( } await Promise.all([ - ...generals - .filter((general) => !createdIds.has(general.id)) - .map((general) => - prisma.general.update({ - where: { id: general.id }, - data: buildGeneralUpdate(general), - }) - ), - ...generals - .filter( - (general) => - typeof general.refreshScoreTotal === 'number' && Number.isFinite(general.refreshScoreTotal) - ) - .map((general) => - prisma.generalAccessLog.upsert({ - where: { generalId: general.id }, - update: { - refreshScoreTotal: Math.floor(general.refreshScoreTotal ?? 0), - }, - create: { - generalId: general.id, - userId: general.userId ?? null, - refreshScoreTotal: Math.floor(general.refreshScoreTotal ?? 0), - }, - }) - ), + persistGeneralUpdates( + prisma, + generals + .filter((general) => !createdIds.has(general.id)) + .map((general) => ({ id: general.id, data: buildGeneralUpdate(general) })) + ), + persistGeneralAccessScores( + prisma, + generals + .filter( + (general) => + typeof general.refreshScoreTotal === 'number' && + Number.isFinite(general.refreshScoreTotal) + ) + .map((general) => ({ + generalId: general.id, + userId: general.userId ?? null, + refreshScoreTotal: Math.floor(general.refreshScoreTotal ?? 0), + })) + ), ...cities.map((city) => prisma.city.update({ where: { id: city.id }, diff --git a/app/game-engine/src/turn/generalBatchPersistence.ts b/app/game-engine/src/turn/generalBatchPersistence.ts new file mode 100644 index 00000000..3b4a931f --- /dev/null +++ b/app/game-engine/src/turn/generalBatchPersistence.ts @@ -0,0 +1,100 @@ +import { GamePrisma, type TurnEngineGeneralUpdateInput } from '@sammo-ts/infra'; + +// Fixed schema identifiers only; all row values remain bound parameters. +const columns = { + userId: 'user_id', + name: 'name', + nationId: 'nation_id', + cityId: 'city_id', + troopId: 'troop_id', + leadership: 'leadership', + strength: 'strength', + intel: 'intel', + experience: 'experience', + dedication: 'dedication', + officerLevel: 'officer_level', + injury: 'injury', + gold: 'gold', + rice: 'rice', + crew: 'crew', + crewTypeId: 'crew_type_id', + train: 'train', + atmos: 'atmos', + age: 'age', + npcState: 'npc_state', + affinity: 'affinity', + bornYear: 'born_year', + deadYear: 'dead_year', + picture: 'picture', + imageServer: 'image_server', + startAge: 'start_age', + horseCode: 'horse_code', + weaponCode: 'weapon_code', + bookCode: 'book_code', + itemCode: 'item_code', + personalCode: 'personal_code', + specialCode: 'special_code', + special2Code: 'special2_code', + lastTurn: 'last_turn', + penalty: 'penalty', + meta: 'meta', + turnTime: 'turn_time', + turnTick: 'turn_tick', + recentWarTime: 'recent_war_time', + recentWarTick: 'recent_war_tick', +} satisfies Record; + +export const GENERAL_UPDATE_BATCH_SIZE = 500; + +export const persistGeneralUpdates = async ( + database: { $executeRaw(query: GamePrisma.Sql): Promise }, + updates: Array<{ id: number; data: TurnEngineGeneralUpdateInput }> +): Promise => { + if (new Set(updates.map((entry) => entry.id)).size !== updates.length) { + throw new Error('Duplicate general IDs in persistence batch.'); + } + const assignments = Object.entries(columns).map(([key, column]) => { + const identifier = GamePrisma.raw(`"${column}"`); + // Prisma omits undefined optional birth/death years instead of clearing them. + return key === 'bornYear' || key === 'deadYear' + ? GamePrisma.sql`${identifier} = COALESCE(source.${identifier}, target.${identifier})` + : GamePrisma.sql`${identifier} = source.${identifier}`; + }); + for (let offset = 0; offset < updates.length; offset += GENERAL_UPDATE_BATCH_SIZE) { + const batch = updates.slice(offset, offset + GENERAL_UPDATE_BATCH_SIZE); + const rows = batch.map(({ id, data }) => ({ + id, + ...Object.fromEntries( + (Object.keys(columns) as Array).map((key) => [columns[key], data[key]]) + ), + })); + const payload = JSON.stringify(rows, (_key, value: unknown) => + typeof value === 'bigint' ? value.toString() : value + ); + const updated = await database.$executeRaw(GamePrisma.sql` + UPDATE "general" AS target + SET ${GamePrisma.join(assignments)} + FROM jsonb_populate_recordset(NULL::"general", ${payload}::jsonb) AS source + WHERE target."id" = source."id" + `); + // Preserve Prisma update's missing-row failure and transaction rollback. + if (updated !== batch.length) { + throw new Error(`General persistence batch expected ${batch.length} rows, updated ${updated}.`); + } + } +}; + +export const persistGeneralAccessScores = async ( + database: { $executeRaw(query: GamePrisma.Sql): Promise }, + updates: Array<{ generalId: number; userId: string | null; refreshScoreTotal: number }> +): Promise => { + for (let offset = 0; offset < updates.length; offset += GENERAL_UPDATE_BATCH_SIZE) { + const batch = updates.slice(offset, offset + GENERAL_UPDATE_BATCH_SIZE); + await database.$executeRaw(GamePrisma.sql` + INSERT INTO "general_access_log" ("general_id", "user_id", "refresh_score_total") + VALUES ${GamePrisma.join(batch.map((row) => GamePrisma.sql`(${row.generalId}, ${row.userId}, ${row.refreshScoreTotal})`))} + ON CONFLICT ("general_id") DO UPDATE + SET "refresh_score_total" = EXCLUDED."refresh_score_total" + `); + } +}; diff --git a/app/game-engine/test/generalBatchPersistence.integration.test.ts b/app/game-engine/test/generalBatchPersistence.integration.test.ts new file mode 100644 index 00000000..7842f92c --- /dev/null +++ b/app/game-engine/test/generalBatchPersistence.integration.test.ts @@ -0,0 +1,235 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { createGamePostgresConnector, type GamePrismaClient, type TurnEngineGeneralUpdateInput } from '@sammo-ts/infra'; +import { + GENERAL_UPDATE_BATCH_SIZE, + persistGeneralAccessScores, + persistGeneralUpdates, +} from '../src/turn/generalBatchPersistence.js'; + +const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL; +const integration = describe.skipIf(!databaseUrl); +const firstId = 995_000; +const at = new Date('0200-01-01T00:00:00.000Z'); + +const dataFor = (index: number): TurnEngineGeneralUpdateInput => ({ + userId: null, + name: `n장 ${index} ' "`, + nationId: 0, + cityId: 0, + troopId: 0, + leadership: 51, + strength: 52, + intel: 53, + experience: 123, + dedication: 456, + officerLevel: 0, + injury: 0, + gold: 700 + index, + rice: 1200 + index, + crew: 100, + crewTypeId: 1100, + train: 70, + atmos: 80, + age: 21, + npcState: 2, + affinity: null, + bornYear: 179, + deadYear: 299, + picture: null, + imageServer: 0, + startAge: 20, + horseCode: 'None', + weaponCode: 'None', + bookCode: 'None', + itemCode: 'None', + personalCode: 'None', + specialCode: 'None', + special2Code: 'None', + lastTurn: { command: '휴식', args: { text: "한글 ' 문자열" } }, + penalty: {}, + meta: { betgold: index * 10, nested: { preserved: true }, list: [1, null, '값'] }, + turnTime: at, + turnTick: 9_007_199_254_740_993n, + recentWarTime: index % 2 ? at : null, + recentWarTick: index % 2 ? 123n : null, +}); + +integration('general batch persistence', () => { + let db: GamePrismaClient; + let close: () => Promise; + beforeAll(async () => { + const connector = createGamePostgresConnector({ url: databaseUrl! }); + await connector.connect(); + db = connector.prisma; + close = () => connector.disconnect(); + await db.general.createMany({ + data: Array.from({ length: GENERAL_UPDATE_BATCH_SIZE + 1 }, (_, index) => ({ + id: firstId + index, + name: `original${index}`, + turnTime: at, + createdAt: at, + updatedAt: at, + })), + }); + }); + afterAll(async () => { + await db.generalAccessLog.deleteMany({ + where: { generalId: { gte: firstId, lte: firstId + GENERAL_UPDATE_BATCH_SIZE } }, + }); + await db.general.deleteMany({ where: { id: { gte: firstId, lte: firstId + GENERAL_UPDATE_BATCH_SIZE } } }); + await close(); + }); + + it('matches Prisma row updates including JSON, nulls, dates, bigint and untouched columns', async () => { + const data = dataFor(1); + await db.general.update({ where: { id: firstId }, data }); + let writes = 0; + await db.$transaction(async (transaction) => { + await persistGeneralUpdates( + { + $executeRaw: (query) => { + writes += 1; + return transaction.$executeRaw(query); + }, + }, + [{ id: firstId + 1, data }] + ); + }); + const { id: _leftId, ...left } = await db.general.findUniqueOrThrow({ where: { id: firstId } }); + const { id: _rightId, ...right } = await db.general.findUniqueOrThrow({ where: { id: firstId + 1 } }); + expect(right).toEqual(left); + expect(writes).toBe(1); + }); + + it('writes 501 distinct generals in two SQL statements and preserves omitted optional years', async () => { + const updates = Array.from({ length: GENERAL_UPDATE_BATCH_SIZE + 1 }, (_, index) => ({ + id: firstId + index, + data: { ...dataFor(index), bornYear: undefined, deadYear: undefined }, + })); + let writes = 0; + await db.$transaction(async (transaction) => { + await persistGeneralUpdates( + { + $executeRaw: (query) => { + writes += 1; + return transaction.$executeRaw(query); + }, + }, + updates + ); + }); + expect(writes).toBe(2); + const rows = await db.general.findMany({ + where: { id: { gte: firstId, lte: firstId + GENERAL_UPDATE_BATCH_SIZE } }, + orderBy: { id: 'asc' }, + }); + rows.forEach((row, index) => { + expect(row).toMatchObject({ + gold: 700 + index, + rice: 1200 + index, + meta: dataFor(index).meta, + bornYear: index < 2 ? 179 : 180, + deadYear: index < 2 ? 299 : 300, + createdAt: at, + updatedAt: at, + turnTick: 9_007_199_254_740_993n, + }); + }); + }); + + it('rolls back the entire transaction when a target is missing', async () => { + const before = await db.general.findUniqueOrThrow({ where: { id: firstId } }); + await expect( + db.$transaction(async (transaction) => + persistGeneralUpdates(transaction, [ + { id: firstId, data: dataFor(999) }, + { id: firstId - 1, data: dataFor(999) }, + ]) + ) + ).rejects.toThrow('expected 2 rows, updated 1'); + expect(await db.general.findUniqueOrThrow({ where: { id: firstId } })).toEqual(before); + }); + + it('preserves unique constraints and rolls back conflicting ownership', async () => { + const before = await db.general.findMany({ + where: { id: { in: [firstId, firstId + 1] } }, + orderBy: { id: 'asc' }, + }); + await expect( + db.$transaction(async (transaction) => + persistGeneralUpdates(transaction, [ + { id: firstId, data: { ...dataFor(1), userId: 'batch-collision' } }, + { id: firstId + 1, data: { ...dataFor(2), userId: 'batch-collision' } }, + ]) + ) + ).rejects.toThrow(); + expect( + await db.general.findMany({ where: { id: { in: [firstId, firstId + 1] } }, orderBy: { id: 'asc' } }) + ).toEqual(before); + }); + + it('batch upserts access totals while preserving existing actor and activity fields', async () => { + await db.generalAccessLog.create({ + data: { + generalId: firstId, + userId: 'original-owner', + lastRefresh: at, + lastActionAt: at, + refresh: 3, + refreshTotal: 7, + refreshScore: 5, + }, + }); + const updates = Array.from({ length: GENERAL_UPDATE_BATCH_SIZE + 1 }, (_, index) => ({ + generalId: firstId + index, + userId: null, + refreshScoreTotal: index + 10, + })); + let writes = 0; + await db.$transaction(async (transaction) => { + await persistGeneralAccessScores( + { + $executeRaw: (query) => { + writes += 1; + return transaction.$executeRaw(query); + }, + }, + updates + ); + }); + expect(writes).toBe(2); + expect(await db.generalAccessLog.findUniqueOrThrow({ where: { generalId: firstId } })).toMatchObject({ + userId: 'original-owner', + lastRefresh: at, + lastActionAt: at, + refresh: 3, + refreshTotal: 7, + refreshScore: 5, + refreshScoreTotal: 10, + }); + expect(await db.generalAccessLog.findUniqueOrThrow({ where: { generalId: firstId + 500 } })).toMatchObject({ + userId: null, + lastRefresh: null, + refresh: 0, + refreshScoreTotal: 510, + }); + }); + + it('does not write an empty batch and rejects duplicate IDs before SQL', async () => { + let writes = 0; + const database = { + $executeRaw: async () => { + writes += 1; + return 0; + }, + }; + await persistGeneralUpdates(database, []); + await expect( + persistGeneralUpdates(database, [ + { id: firstId, data: dataFor(1) }, + { id: firstId, data: dataFor(2) }, + ]) + ).rejects.toThrow('Duplicate general IDs'); + expect(writes).toBe(0); + }); +});