diff --git a/app/game-api/src/router/nation/endpoints/getChiefCenter.ts b/app/game-api/src/router/nation/endpoints/getChiefCenter.ts index 4048989..c0731f3 100644 --- a/app/game-api/src/router/nation/endpoints/getChiefCenter.ts +++ b/app/game-api/src/router/nation/endpoints/getChiefCenter.ts @@ -3,7 +3,7 @@ import { TRPCError } from '@trpc/server'; import { authedProcedure } from '../../../trpc.js'; import { getMyGeneral } from '../../shared/general.js'; import { resolveSecretPermission } from '../../shared/secretPermission.js'; -import { MAX_NATION_TURNS, listNationTurns } from '../../../turns/reservedTurns.js'; +import { MAX_NATION_TURNS, getNationTurnSnapshot } from '../../../turns/reservedTurns.js'; import { assertNationAccess } from '../shared.js'; export const getChiefCenter = authedProcedure.query(async ({ ctx }) => { @@ -56,9 +56,7 @@ export const getChiefCenter = authedProcedure.query(async ({ ctx }) => { const chiefLevels = [12, 10, 8, 6, 11, 9, 7, 5]; const generalByLevel = new Map(nationGenerals.map((general) => [general.officerLevel, general])); - const turnsByLevel = await Promise.all( - chiefLevels.map((level) => listNationTurns(ctx.db, nation.id, level)) - ); + const turnsByLevel = await Promise.all(chiefLevels.map((level) => getNationTurnSnapshot(ctx.db, nation.id, level))); const chiefs = chiefLevels.map((level, idx) => { const entry = generalByLevel.get(level); @@ -67,7 +65,8 @@ export const getChiefCenter = authedProcedure.query(async ({ ctx }) => { name: entry?.name ?? null, npcState: entry?.npcState ?? null, turnTime: entry?.turnTime ? entry.turnTime.toISOString() : null, - turns: turnsByLevel[idx], + revision: turnsByLevel[idx]?.revision ?? 0, + turns: turnsByLevel[idx]?.turns ?? [], }; }); diff --git a/app/game-api/src/router/turns/index.ts b/app/game-api/src/router/turns/index.ts index ee48607..003706b 100644 --- a/app/game-api/src/router/turns/index.ts +++ b/app/game-api/src/router/turns/index.ts @@ -14,8 +14,9 @@ import { import { MAX_GENERAL_TURNS, MAX_NATION_TURNS, - listGeneralTurns, - listNationTurns, + ReservedTurnRevisionConflictError, + getGeneralTurnSnapshot, + getNationTurnSnapshot, setGeneralTurn, setNationTurn, shiftGeneralTurns, @@ -45,6 +46,21 @@ const parseCommandArgs = async (scope: 'general' | 'nation', action: string, arg } }; +const mutateReservedTurns = async (mutation: () => Promise): Promise => { + try { + return await mutation(); + } catch (error) { + if (error instanceof ReservedTurnRevisionConflictError) { + throw new TRPCError({ + code: 'CONFLICT', + message: 'Reserved turn queue changed. Reload and retry.', + cause: error, + }); + } + throw error; + } +}; + export const turnsRouter = router({ getCommandTable: authedProcedure .input( @@ -164,7 +180,7 @@ export const turnsRouter = router({ .query(async ({ ctx, input }) => { await getOwnedGeneral(ctx, input.generalId); - return listGeneralTurns(ctx.db, input.generalId); + return getGeneralTurnSnapshot(ctx.db, input.generalId); }), getNation: authedProcedure .input( @@ -187,7 +203,7 @@ export const turnsRouter = router({ }); } - return listNationTurns(ctx.db, general.nationId, general.officerLevel); + return getNationTurnSnapshot(ctx.db, general.nationId, general.officerLevel); }), setGeneral: authedProcedure .input( @@ -200,33 +216,33 @@ export const turnsRouter = router({ .max(MAX_GENERAL_TURNS - 1), action: z.string().min(1), args: z.unknown().optional(), + expectedRevision: z.number().int().nonnegative(), }) ) .mutation(async ({ ctx, input }) => { await getOwnedGeneral(ctx, input.generalId); const args = await parseCommandArgs('general', input.action, input.args); - const turns = await setGeneralTurn( - ctx.db, - input.generalId, - input.turnIndex, - input.action, - args + const snapshot = await mutateReservedTurns(() => + setGeneralTurn(ctx.db, input.generalId, input.turnIndex, input.action, args, input.expectedRevision) ); - return { ok: true, turns }; + return { ok: true, ...snapshot }; }), shiftGeneral: authedProcedure .input( z.object({ generalId: z.number().int().positive(), amount: buildShiftAmountSchema(MAX_GENERAL_TURNS), + expectedRevision: z.number().int().nonnegative(), }) ) .mutation(async ({ ctx, input }) => { await getOwnedGeneral(ctx, input.generalId); - const turns = await shiftGeneralTurns(ctx.db, input.generalId, input.amount); - return { ok: true, turns }; + const snapshot = await mutateReservedTurns(() => + shiftGeneralTurns(ctx.db, input.generalId, input.amount, input.expectedRevision) + ); + return { ok: true, ...snapshot }; }), setNation: authedProcedure .input( @@ -239,6 +255,7 @@ export const turnsRouter = router({ .max(MAX_NATION_TURNS - 1), action: z.string().min(1), args: z.unknown().optional(), + expectedRevision: z.number().int().nonnegative(), }) ) .mutation(async ({ ctx, input }) => { @@ -257,21 +274,25 @@ export const turnsRouter = router({ } const args = await parseCommandArgs('nation', input.action, input.args); - const turns = await setNationTurn( - ctx.db, - general.nationId, - general.officerLevel, - input.turnIndex, - input.action, - args + const snapshot = await mutateReservedTurns(() => + setNationTurn( + ctx.db, + general.nationId, + general.officerLevel, + input.turnIndex, + input.action, + args, + input.expectedRevision + ) ); - return { ok: true, turns }; + return { ok: true, ...snapshot }; }), shiftNation: authedProcedure .input( z.object({ generalId: z.number().int().positive(), amount: buildShiftAmountSchema(MAX_NATION_TURNS), + expectedRevision: z.number().int().nonnegative(), }) ) .mutation(async ({ ctx, input }) => { @@ -289,8 +310,16 @@ export const turnsRouter = router({ }); } - const turns = await shiftNationTurns(ctx.db, general.nationId, general.officerLevel, input.amount); - return { ok: true, turns }; + const snapshot = await mutateReservedTurns(() => + shiftNationTurns( + ctx.db, + general.nationId, + general.officerLevel, + input.amount, + input.expectedRevision + ) + ); + return { ok: true, ...snapshot }; }), }), }); diff --git a/app/game-api/src/turns/reservedTurns.ts b/app/game-api/src/turns/reservedTurns.ts index 3ea6330..f6d8212 100644 --- a/app/game-api/src/turns/reservedTurns.ts +++ b/app/game-api/src/turns/reservedTurns.ts @@ -16,6 +16,21 @@ export interface ReservedTurnView { args: InputJsonValue; } +export interface ReservedTurnSnapshot { + revision: number; + turns: ReservedTurnView[]; +} + +export class ReservedTurnRevisionConflictError extends Error { + constructor( + readonly expectedRevision: number, + readonly currentRevision: number + ) { + super(`Reserved turn queue revision conflict: expected ${expectedRevision}, current ${currentRevision}.`); + this.name = 'ReservedTurnRevisionConflictError'; + } +} + const normalizeAction = (action: string | null | undefined): string => action && action.length > 0 ? action : DEFAULT_TURN_ACTION; @@ -111,6 +126,17 @@ export const listGeneralTurns = async (db: DatabaseClient, generalId: number): P return serializeTurnList(turns); }; +export const getGeneralTurnSnapshot = async (db: DatabaseClient, generalId: number): Promise => { + const [turns, revisionRow] = await Promise.all([ + loadGeneralTurns(db, generalId), + db.generalTurnRevision.findUnique({ where: { generalId } }), + ]); + return { + revision: revisionRow?.revision ?? 0, + turns: serializeTurnList(turns), + }; +}; + export const loadNationTurns = async ( db: DatabaseClient, nationId: number, @@ -132,31 +158,111 @@ export const listNationTurns = async ( return serializeTurnList(turns); }; +export const getNationTurnSnapshot = async ( + db: DatabaseClient, + nationId: number, + officerLevel: number +): Promise => { + const [turns, revisionRow] = await Promise.all([ + loadNationTurns(db, nationId, officerLevel), + db.nationTurnRevision.findUnique({ + where: { + nationId_officerLevel: { + nationId, + officerLevel, + }, + }, + }), + ]); + return { + revision: revisionRow?.revision ?? 0, + turns: serializeTurnList(turns), + }; +}; + +const claimGeneralRevision = async ( + db: DatabaseClient, + generalId: number, + expectedRevision: number +): Promise => { + const nextRevision = expectedRevision + 1; + const claimed = + expectedRevision === 0 + ? await db.generalTurnRevision.createMany({ + data: [{ generalId, revision: nextRevision }], + skipDuplicates: true, + }) + : await db.generalTurnRevision.updateMany({ + where: { generalId, revision: expectedRevision }, + data: { revision: nextRevision }, + }); + if (claimed.count === 1) { + return nextRevision; + } + const current = await db.generalTurnRevision.findUnique({ where: { generalId } }); + throw new ReservedTurnRevisionConflictError(expectedRevision, current?.revision ?? 0); +}; + +const claimNationRevision = async ( + db: DatabaseClient, + nationId: number, + officerLevel: number, + expectedRevision: number +): Promise => { + const nextRevision = expectedRevision + 1; + const claimed = + expectedRevision === 0 + ? await db.nationTurnRevision.createMany({ + data: [{ nationId, officerLevel, revision: nextRevision }], + skipDuplicates: true, + }) + : await db.nationTurnRevision.updateMany({ + where: { nationId, officerLevel, revision: expectedRevision }, + data: { revision: nextRevision }, + }); + if (claimed.count === 1) { + return nextRevision; + } + const current = await db.nationTurnRevision.findUnique({ + where: { + nationId_officerLevel: { + nationId, + officerLevel, + }, + }, + }); + throw new ReservedTurnRevisionConflictError(expectedRevision, current?.revision ?? 0); +}; + export const setGeneralTurn = async ( db: DatabaseClient, generalId: number, turnIndex: number, action: string, - args: unknown -): Promise => { + args: unknown, + expectedRevision: number +): Promise => { + const revision = await claimGeneralRevision(db, generalId, expectedRevision); const turns = await loadGeneralTurns(db, generalId); turns[turnIndex] = { action: normalizeAction(action), args: normalizeArgs(args), }; await persistGeneralTurns(db, generalId, turns); - return serializeTurnList(turns); + return { revision, turns: serializeTurnList(turns) }; }; export const shiftGeneralTurns = async ( db: DatabaseClient, generalId: number, - amount: number -): Promise => { + amount: number, + expectedRevision: number +): Promise => { + const revision = await claimGeneralRevision(db, generalId, expectedRevision); const turns = await loadGeneralTurns(db, generalId); const shifted = applyShift(turns, amount); await persistGeneralTurns(db, generalId, shifted); - return serializeTurnList(shifted); + return { revision, turns: serializeTurnList(shifted) }; }; export const setNationTurn = async ( @@ -165,25 +271,29 @@ export const setNationTurn = async ( officerLevel: number, turnIndex: number, action: string, - args: unknown -): Promise => { + args: unknown, + expectedRevision: number +): Promise => { + const revision = await claimNationRevision(db, nationId, officerLevel, expectedRevision); const turns = await loadNationTurns(db, nationId, officerLevel); turns[turnIndex] = { action: normalizeAction(action), args: normalizeArgs(args), }; await persistNationTurns(db, nationId, officerLevel, turns); - return serializeTurnList(turns); + return { revision, turns: serializeTurnList(turns) }; }; export const shiftNationTurns = async ( db: DatabaseClient, nationId: number, officerLevel: number, - amount: number -): Promise => { + amount: number, + expectedRevision: number +): Promise => { + const revision = await claimNationRevision(db, nationId, officerLevel, expectedRevision); const turns = await loadNationTurns(db, nationId, officerLevel); const shifted = applyShift(turns, amount); await persistNationTurns(db, nationId, officerLevel, shifted); - return serializeTurnList(shifted); + return { revision, turns: serializeTurnList(shifted) }; }; diff --git a/app/game-api/test/reservedTurnRevision.integration.test.ts b/app/game-api/test/reservedTurnRevision.integration.test.ts new file mode 100644 index 0000000..a7a7336 --- /dev/null +++ b/app/game-api/test/reservedTurnRevision.integration.test.ts @@ -0,0 +1,63 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { createGamePostgresConnector } from '@sammo-ts/infra'; +import type { DatabaseClient } from '../src/context.js'; +import { + ReservedTurnRevisionConflictError, + getGeneralTurnSnapshot, + setGeneralTurn, +} from '../src/turns/reservedTurns.js'; + +const databaseUrl = process.env.RESERVED_TURN_DATABASE_URL; +const describeIntegration = databaseUrl ? describe : describe.skip; +const GENERAL_ID = 2_147_400_001; + +describeIntegration('reserved turn queue revision integration', () => { + const connector = databaseUrl ? createGamePostgresConnector({ url: databaseUrl }) : null; + + beforeAll(async () => { + if (!connector) { + return; + } + await connector.connect(); + await connector.prisma.generalTurn.deleteMany({ where: { generalId: GENERAL_ID } }); + await connector.prisma.generalTurnRevision.deleteMany({ where: { generalId: GENERAL_ID } }); + }); + + afterAll(async () => { + if (!connector) { + return; + } + await connector.prisma.generalTurn.deleteMany({ where: { generalId: GENERAL_ID } }); + await connector.prisma.generalTurnRevision.deleteMany({ where: { generalId: GENERAL_ID } }); + await connector.disconnect(); + }); + + it('allows exactly one writer for the same expected revision', async () => { + if (!connector) { + throw new Error('integration connector is unavailable'); + } + + const write = (action: string) => + connector.prisma.$transaction((transaction) => + setGeneralTurn(transaction as unknown as DatabaseClient, GENERAL_ID, 0, action, {}, 0) + ); + + const results = await Promise.allSettled([write('che_훈련'), write('che_사기진작')]); + const fulfilled = results.filter( + (result): result is PromiseFulfilledResult>> => + result.status === 'fulfilled' + ); + const rejected = results.filter((result): result is PromiseRejectedResult => result.status === 'rejected'); + + expect(fulfilled).toHaveLength(1); + expect(fulfilled[0]?.value.revision).toBe(1); + expect(rejected).toHaveLength(1); + expect(rejected[0]?.reason).toBeInstanceOf(ReservedTurnRevisionConflictError); + + const snapshot = await getGeneralTurnSnapshot(connector.prisma as unknown as DatabaseClient, GENERAL_ID); + expect(snapshot.revision).toBe(1); + expect(['che_훈련', 'che_사기진작']).toContain(snapshot.turns[0]?.action); + expect(await connector.prisma.generalTurn.count({ where: { generalId: GENERAL_ID } })).toBe(30); + }); +}); diff --git a/app/game-api/test/reservedTurns.test.ts b/app/game-api/test/reservedTurns.test.ts index 570f4a8..eda5329 100644 --- a/app/game-api/test/reservedTurns.test.ts +++ b/app/game-api/test/reservedTurns.test.ts @@ -8,11 +8,14 @@ import { setNationTurn, 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]>; @@ -21,6 +24,16 @@ const buildDb = () => { 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: { @@ -64,6 +77,39 @@ const buildDb = () => { 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; @@ -100,6 +146,48 @@ const buildDb = () => { 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 }; @@ -109,30 +197,45 @@ describe('reservedTurns', () => { it('sets and shifts general turns', async () => { const { db } = buildDb(); - const initial = await setGeneralTurn(db, 1, 0, 'che_화계', { destCityId: 10 }); + const initial = await setGeneralTurn(db, 1, 0, 'che_화계', { destCityId: 10 }, 0); - expect(initial).toHaveLength(MAX_GENERAL_TURNS); - expect(initial[0]?.action).toBe('che_화계'); + 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); - expect(pushed[0]?.action).toBe('휴식'); - expect(pushed[1]?.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); - expect(pulled[0]?.action).toBe('che_화계'); - expect(pulled[MAX_GENERAL_TURNS - 1]?.action).toBe('휴식'); + 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 }); + const initial = await setNationTurn( + db, + 2, + 5, + 0, + 'che_포상', + { isGold: true, amount: 200, destGeneralId: 7 }, + 0 + ); - expect(initial).toHaveLength(MAX_NATION_TURNS); - expect(initial[0]?.action).toBe('che_포상'); + 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); - expect(pushed[0]?.action).toBe('휴식'); - expect(pushed[1]?.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_포상'); }); }); diff --git a/app/game-api/test/router.test.ts b/app/game-api/test/router.test.ts index dfb010c..6335177 100644 --- a/app/game-api/test/router.test.ts +++ b/app/game-api/test/router.test.ts @@ -101,6 +101,8 @@ const buildContext = (options?: { const battleSim = options?.battleSim ?? new InMemoryBattleSimTransport(); const generalTurns = options?.generalTurns ?? []; const nationTurns = options?.nationTurns ?? []; + let generalTurnRevision: number | undefined; + let nationTurnRevision: number | undefined; const db = { worldState: { findFirst: async () => { @@ -133,17 +135,60 @@ const buildContext = (options?: { return {}; }, }, + generalTurnRevision: { + findUnique: async () => + generalTurnRevision === undefined + ? null + : { generalId: options?.general?.id ?? 0, revision: generalTurnRevision, updatedAt: new Date() }, + createMany: async ({ data }: { data: Array<{ revision: number }> }) => { + if (generalTurnRevision !== undefined) { + return { count: 0 }; + } + generalTurnRevision = data[0]?.revision ?? 0; + return { count: 1 }; + }, + updateMany: async ({ where, data }: { where: { revision: number }; data: { revision: number } }) => { + if (generalTurnRevision !== where.revision) { + return { count: 0 }; + } + generalTurnRevision = data.revision; + return { count: 1 }; + }, + }, nationTurn: { findMany: async ({ where }: { where: { nationId: number; officerLevel: number } }) => - nationTurns.filter( - (row) => row.nationId === where.nationId && row.officerLevel === where.officerLevel - ), + nationTurns.filter((row) => row.nationId === where.nationId && row.officerLevel === where.officerLevel), deleteMany: async () => ({}), createMany: async (args: unknown) => { options?.nationTurnWrites?.push(args); return {}; }, }, + nationTurnRevision: { + findUnique: async () => + nationTurnRevision === undefined + ? null + : { + nationId: options?.general?.nationId ?? 0, + officerLevel: options?.general?.officerLevel ?? 0, + revision: nationTurnRevision, + updatedAt: new Date(), + }, + createMany: async ({ data }: { data: Array<{ revision: number }> }) => { + if (nationTurnRevision !== undefined) { + return { count: 0 }; + } + nationTurnRevision = data[0]?.revision ?? 0; + return { count: 1 }; + }, + updateMany: async ({ where, data }: { where: { revision: number }; data: { revision: number } }) => { + if (nationTurnRevision !== where.revision) { + return { count: 0 }; + } + nationTurnRevision = data.revision; + return { count: 1 }; + }, + }, }; const accessTokenStore = new RedisAccessTokenStore( { @@ -370,8 +415,9 @@ describe('appRouter', () => { const caller = appRouter.createCaller(buildContext({ general, generalTurns })); const response = await caller.turns.reserved.getGeneral({ generalId: 11 }); - expect(response[0]?.action).toBe('che_화계'); - expect(response[0]?.index).toBe(0); + expect(response.revision).toBe(0); + expect(response.turns[0]?.action).toBe('che_화계'); + expect(response.turns[0]?.index).toBe(0); }); it('returns reserved nation turns', async () => { @@ -390,8 +436,9 @@ describe('appRouter', () => { const caller = appRouter.createCaller(buildContext({ general, nationTurns })); const response = await caller.turns.reserved.getNation({ generalId: 12 }); - expect(response[0]?.action).toBe('che_포상'); - expect(response[0]?.index).toBe(0); + expect(response.revision).toBe(0); + expect(response.turns[0]?.action).toBe('che_포상'); + expect(response.turns[0]?.index).toBe(0); }); it('validates and persists general command arguments from the authenticated owner', async () => { @@ -404,6 +451,7 @@ describe('appRouter', () => { turnIndex: 0, action: 'che_화계', args: { destCityId: 7 }, + expectedRevision: 0, }); expect(response.turns[0]).toMatchObject({ action: 'che_화계', args: { destCityId: 7 } }); @@ -416,6 +464,16 @@ describe('appRouter', () => { actionCode: 'che_화계', arg: { destCityId: 7 }, }); + + await expect( + caller.turns.reserved.setGeneral({ + generalId: 13, + turnIndex: 1, + action: '휴식', + expectedRevision: 0, + }) + ).rejects.toMatchObject({ code: 'CONFLICT' }); + expect(writes).toHaveLength(1); }); it('rejects malformed and cross-scope arguments without writing turns', async () => { @@ -432,6 +490,7 @@ describe('appRouter', () => { turnIndex: 0, action: 'che_화계', args: { destCityId: '7' }, + expectedRevision: 0, }) ).rejects.toMatchObject({ code: 'BAD_REQUEST' }); await expect( @@ -440,6 +499,7 @@ describe('appRouter', () => { turnIndex: 0, action: 'che_포상', args: { isGold: true, amount: 1, destGeneralId: 7 }, + expectedRevision: 0, }) ).rejects.toMatchObject({ code: 'BAD_REQUEST' }); @@ -465,6 +525,7 @@ describe('appRouter', () => { generalId: general.id, turnIndex: 0, action: '휴식', + expectedRevision: 0, }) ).rejects.toMatchObject({ code: 'FORBIDDEN', @@ -473,6 +534,7 @@ describe('appRouter', () => { caller.turns.reserved.shiftGeneral({ generalId: general.id, amount: 1, + expectedRevision: 0, }) ).rejects.toMatchObject({ code: 'FORBIDDEN', @@ -482,6 +544,7 @@ describe('appRouter', () => { generalId: general.id, turnIndex: 0, action: '휴식', + expectedRevision: 0, }) ).rejects.toMatchObject({ code: 'FORBIDDEN', @@ -490,6 +553,7 @@ describe('appRouter', () => { caller.turns.reserved.shiftNation({ generalId: general.id, amount: 1, + expectedRevision: 0, }) ).rejects.toMatchObject({ code: 'FORBIDDEN', diff --git a/app/game-engine/src/turn/databaseHooks.ts b/app/game-engine/src/turn/databaseHooks.ts index f0a865f..2ec788a 100644 --- a/app/game-engine/src/turn/databaseHooks.ts +++ b/app/game-engine/src/turn/databaseHooks.ts @@ -315,9 +315,7 @@ const upsertRankRows = async ( await prisma.$executeRaw(GamePrisma.sql` INSERT INTO "rank_data" ("general_id", "nation_id", "type", "value") VALUES ${GamePrisma.join( - batch.map( - (row) => GamePrisma.sql`(${row.generalId}, ${row.nationId}, ${row.type}, ${row.value})` - ) + batch.map((row) => GamePrisma.sql`(${row.generalId}, ${row.nationId}, ${row.type}, ${row.value})`) )} ON CONFLICT ("general_id", "type") DO UPDATE SET @@ -799,6 +797,11 @@ export const createDatabaseTurnHooks = async ( } if (deletedGenerals.length > 0) { + if (prisma.generalTurnRevision) { + await prisma.generalTurnRevision.deleteMany({ + where: { generalId: { in: deletedGenerals } }, + }); + } await prisma.generalTurn.deleteMany({ where: { generalId: { in: deletedGenerals } }, }); @@ -816,6 +819,11 @@ export const createDatabaseTurnHooks = async ( OR: [{ srcNationId: { in: deletedNations } }, { destNationId: { in: deletedNations } }], }, }); + if (prisma.nationTurnRevision) { + await prisma.nationTurnRevision.deleteMany({ + where: { nationId: { in: deletedNations } }, + }); + } await prisma.nationTurn.deleteMany({ where: { nationId: { in: deletedNations } }, }); diff --git a/app/game-engine/src/turn/reservedTurnStore.ts b/app/game-engine/src/turn/reservedTurnStore.ts index 96de3e3..9390b1b 100644 --- a/app/game-engine/src/turn/reservedTurnStore.ts +++ b/app/game-engine/src/turn/reservedTurnStore.ts @@ -70,7 +70,10 @@ const buildTurnListFromRows = ( const buildNationKey = (nationId: number, officerLevel: number): string => `${nationId}:${officerLevel}`; -type ReservedTurnDatabaseClient = Pick; +type ReservedTurnDatabaseClient = Pick & { + generalTurnRevision?: Pick, 'upsert'>; + nationTurnRevision?: Pick, 'upsert'>; +}; export interface ReservedTurnChanges { generalIds: number[]; @@ -281,6 +284,13 @@ export class InMemoryReservedTurnStore { arg: asJson(normalizeArgs(entry.args)), })), }); + if (prisma.generalTurnRevision) { + await prisma.generalTurnRevision.upsert({ + where: { generalId }, + create: { generalId, revision: 1 }, + update: { revision: { increment: 1 } }, + }); + } } for (const generalId of changes.generalInitializationIds) { @@ -316,6 +326,18 @@ export class InMemoryReservedTurnStore { arg: asJson(normalizeArgs(entry.args)), })), }); + if (prisma.nationTurnRevision) { + await prisma.nationTurnRevision.upsert({ + where: { + nationId_officerLevel: { + nationId, + officerLevel, + }, + }, + create: { nationId, officerLevel, revision: 1 }, + update: { revision: { increment: 1 } }, + }); + } } for (const key of changes.nationInitializationKeys) { diff --git a/app/game-engine/test/inputEventAtomicity.test.ts b/app/game-engine/test/inputEventAtomicity.test.ts index 3790bd1..2dcca08 100644 --- a/app/game-engine/test/inputEventAtomicity.test.ts +++ b/app/game-engine/test/inputEventAtomicity.test.ts @@ -27,6 +27,7 @@ const processor: TurnProcessor = { describe('input event atomicity', () => { it('keeps reserved-turn dirty state when persistence fails', async () => { let failCreate = true; + const revisionUpsert = vi.fn(async () => ({ revision: 1 })); const prisma = { generalTurn: { findMany: vi.fn(async () => []), @@ -38,6 +39,9 @@ describe('input event atomicity', () => { return { count: 1 }; }), }, + generalTurnRevision: { + upsert: revisionUpsert, + }, nationTurn: { findMany: vi.fn(async () => []), deleteMany: vi.fn(async () => ({ count: 0 })), @@ -61,6 +65,12 @@ describe('input event atomicity', () => { failCreate = false; await store.flushChanges(); + expect(revisionUpsert).toHaveBeenCalledOnce(); + expect(revisionUpsert).toHaveBeenCalledWith({ + where: { generalId: 7 }, + create: { generalId: 7, revision: 1 }, + update: { revision: { increment: 1 } }, + }); expect(store.peekDirtyState()).toEqual({ generalIds: [], generalInitializationIds: [], diff --git a/app/game-frontend/e2e/commandArgumentsLive.spec.ts b/app/game-frontend/e2e/commandArgumentsLive.spec.ts index c9328a7..bb1bf1d 100644 --- a/app/game-frontend/e2e/commandArgumentsLive.spec.ts +++ b/app/game-frontend/e2e/commandArgumentsLive.spec.ts @@ -48,12 +48,10 @@ test('reserves an argument command in the real game API and reads it back from P const context = await game.general.me.query(); if (!context) throw new Error('demo1 general is missing'); const generalId = context.general.id; - const original = ( - (await game.turns.reserved.getGeneral.query({ generalId })) as unknown as PlainTurn[] - )[29]; - const originalNation = ( - (await game.turns.reserved.getNation.query({ generalId })) as unknown as PlainTurn[] - )[11]; + const originalGeneralSnapshot = await game.turns.reserved.getGeneral.query({ generalId }); + const original = (originalGeneralSnapshot.turns as unknown as PlainTurn[])[29]; + const originalNationSnapshot = await game.turns.reserved.getNation.query({ generalId }); + const originalNation = (originalNationSnapshot.turns as unknown as PlainTurn[])[11]; await page.addInitScript( ({ token }) => { @@ -71,9 +69,9 @@ test('reserves an argument command in the real game API and reads it back from P const form = page.getByTestId('command-argument-form'); await expect(form).toBeVisible(); const citySelect = form.locator('select'); - const optionValues = await citySelect.locator('option').evaluateAll((options) => - options.map((option) => (option as HTMLOptionElement).value) - ); + const optionValues = await citySelect + .locator('option') + .evaluateAll((options) => options.map((option) => (option as HTMLOptionElement).value)); const targetCityId = Number(optionValues.find((value) => Number(value) !== context.general.cityId)); expect(targetCityId).toBeGreaterThan(0); await citySelect.selectOption(String(targetCityId)); @@ -83,7 +81,7 @@ test('reserves an argument command in the real game API and reads it back from P await lastTurn.getByRole('button', { name: '배치' }).click(); await expect(lastTurn.locator('.turn-action')).toHaveText('che_화계'); - const persisted = (await game.turns.reserved.getGeneral.query({ generalId }))[29]; + const persisted = (await game.turns.reserved.getGeneral.query({ generalId })).turns[29]; expect(persisted).toEqual({ index: 29, action: 'che_화계', @@ -95,9 +93,9 @@ test('reserves an argument command in the real game API and reads it back from P await form.getByRole('button', { name: '쌀', exact: true }).click(); await form.locator('input[type=number]').fill('1'); const generalSelect = form.locator('select'); - const generalValues = await generalSelect.locator('option').evaluateAll((options) => - options.map((option) => (option as HTMLOptionElement).value) - ); + const generalValues = await generalSelect + .locator('option') + .evaluateAll((options) => options.map((option) => (option as HTMLOptionElement).value)); const targetGeneralId = Number(generalValues.find((value) => Number(value) !== generalId)); expect(targetGeneralId).toBeGreaterThan(0); await generalSelect.selectOption(String(targetGeneralId)); @@ -105,7 +103,7 @@ test('reserves an argument command in the real game API and reads it back from P const lastNationTurn = nationSection.locator('.reserved-item').nth(11); await lastNationTurn.getByRole('button', { name: '배치' }).click(); await expect(lastNationTurn.locator('.turn-action')).toHaveText('che_포상'); - const persistedNation = (await game.turns.reserved.getNation.query({ generalId }))[11]; + const persistedNation = (await game.turns.reserved.getNation.query({ generalId })).turns[11]; expect(persistedNation).toEqual({ index: 11, action: 'che_포상', @@ -116,17 +114,21 @@ test('reserves an argument command in the real game API and reads it back from P fullPage: true, }); } finally { + const currentGeneral = await game.turns.reserved.getGeneral.query({ generalId }); await game.turns.reserved.setGeneral.mutate({ generalId, turnIndex: 29, action: original?.action ?? '휴식', args: original?.args ?? {}, + expectedRevision: currentGeneral.revision, }); + const currentNation = await game.turns.reserved.getNation.query({ generalId }); await game.turns.reserved.setNation.mutate({ generalId, turnIndex: 11, action: originalNation?.action ?? '휴식', args: originalNation?.args ?? {}, + expectedRevision: currentNation.revision, }); } }); diff --git a/app/game-frontend/src/stores/mainDashboard.ts b/app/game-frontend/src/stores/mainDashboard.ts index c02c3c5..3dd36f3 100644 --- a/app/game-frontend/src/stores/mainDashboard.ts +++ b/app/game-frontend/src/stores/mainDashboard.ts @@ -25,7 +25,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { type MessageBundle = Awaited>; type MessageContacts = Awaited>; type BoardAccess = Awaited>; - type ReservedTurnView = Awaited>[number]; + type ReservedTurnView = Awaited>['turns'][number]; type RecentRecord = Awaited>['global'][number]; type FrontStatus = Awaited>; @@ -46,6 +46,8 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { const boardAccess = ref(null); const reservedGeneralTurns = ref(null); const reservedNationTurns = ref(null); + const reservedGeneralRevision = ref(0); + const reservedNationRevision = ref(0); const globalRecords = ref([]); const generalRecords = ref([]); const worldHistory = ref([]); @@ -260,6 +262,8 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { if (!context) { reservedGeneralTurns.value = null; reservedNationTurns.value = null; + reservedGeneralRevision.value = 0; + reservedNationRevision.value = 0; boardAccess.value = null; resetRecentRecords(null); loading.value = false; @@ -322,8 +326,10 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { messages.value = messageData; messageContacts.value = contacts; boardAccess.value = access; - reservedGeneralTurns.value = generalTurns; - reservedNationTurns.value = nationTurns; + reservedGeneralTurns.value = generalTurns.turns; + reservedGeneralRevision.value = generalTurns.revision; + reservedNationTurns.value = nationTurns?.turns ?? null; + reservedNationRevision.value = nationTurns?.revision ?? 0; if (records) { globalRecords.value = mergeRecentRecords(globalRecords.value, records.global); generalRecords.value = mergeRecentRecords(generalRecords.value, records.general); @@ -485,10 +491,17 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { turnIndex, action, args, + expectedRevision: reservedGeneralRevision.value, }); reservedGeneralTurns.value = result.turns; + reservedGeneralRevision.value = result.revision; } catch (err) { error.value = resolveErrorMessage(err); + const snapshot = await trpc.turns.reserved.getGeneral.query({ generalId: id }).catch(() => null); + if (snapshot) { + reservedGeneralTurns.value = snapshot.turns; + reservedGeneralRevision.value = snapshot.revision; + } } }; @@ -501,10 +514,17 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { const result = await trpc.turns.reserved.shiftGeneral.mutate({ generalId: id, amount, + expectedRevision: reservedGeneralRevision.value, }); reservedGeneralTurns.value = result.turns; + reservedGeneralRevision.value = result.revision; } catch (err) { error.value = resolveErrorMessage(err); + const snapshot = await trpc.turns.reserved.getGeneral.query({ generalId: id }).catch(() => null); + if (snapshot) { + reservedGeneralTurns.value = snapshot.turns; + reservedGeneralRevision.value = snapshot.revision; + } } }; @@ -523,10 +543,17 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { turnIndex, action, args, + expectedRevision: reservedNationRevision.value, }); reservedNationTurns.value = result.turns; + reservedNationRevision.value = result.revision; } catch (err) { error.value = resolveErrorMessage(err); + const snapshot = await trpc.turns.reserved.getNation.query({ generalId: id }).catch(() => null); + if (snapshot) { + reservedNationTurns.value = snapshot.turns; + reservedNationRevision.value = snapshot.revision; + } } }; @@ -543,10 +570,17 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { const result = await trpc.turns.reserved.shiftNation.mutate({ generalId: id, amount, + expectedRevision: reservedNationRevision.value, }); reservedNationTurns.value = result.turns; + reservedNationRevision.value = result.revision; } catch (err) { error.value = resolveErrorMessage(err); + const snapshot = await trpc.turns.reserved.getNation.query({ generalId: id }).catch(() => null); + if (snapshot) { + reservedNationTurns.value = snapshot.turns; + reservedNationRevision.value = snapshot.revision; + } } }; diff --git a/app/game-frontend/src/views/ChiefCenterView.vue b/app/game-frontend/src/views/ChiefCenterView.vue index d5fa2ed..8d7ac83 100644 --- a/app/game-frontend/src/views/ChiefCenterView.vue +++ b/app/game-frontend/src/views/ChiefCenterView.vue @@ -21,6 +21,7 @@ type ChiefEntry = { name: string | null; npcState: number | null; turnTime: string | null; + revision: number; turns: ChiefTurn[]; }; @@ -59,7 +60,8 @@ type CommandAvailability = { step?: number; constValue?: string | number; options?: Array<{ value: string | number; label: string; color?: string }>; - optionSource?: 'cities' | 'nations' | 'generals' | 'crewTypes' | 'armTypes' | 'nationTypes' | 'colors' | 'items'; + optionSource?: + 'cities' | 'nations' | 'generals' | 'crewTypes' | 'armTypes' | 'nationTypes' | 'colors' | 'items'; tupleLabels?: string[]; }>; }; @@ -181,9 +183,7 @@ watch( return; } const preferred = - snapshot.me.officerLevel >= 5 - ? snapshot.me.officerLevel - : snapshot.chiefs[0]?.officerLevel ?? null; + snapshot.me.officerLevel >= 5 ? snapshot.me.officerLevel : (snapshot.chiefs[0]?.officerLevel ?? null); selectedChiefLevel.value = preferred; } ); @@ -315,7 +315,7 @@ const selectedChiefRows = computed(() => { return buildTurnRows(selectedChief.value); }); -const updateMyTurns = (turns: ChiefEntry['turns']) => { +const updateMyTurns = (turns: ChiefEntry['turns'], revision: number) => { if (!data.value) { return; } @@ -325,6 +325,7 @@ const updateMyTurns = (turns: ChiefEntry['turns']) => { return; } entry.turns = turns; + entry.revision = revision; }; const reserveTurn = async (turnIndex: number) => { @@ -340,9 +341,11 @@ const reserveTurn = async (turnIndex: number) => { turnIndex, action: selectedCommand.value.key, args: commandArgs.value, + expectedRevision: selectedChief.value?.revision ?? 0, }); - updateMyTurns(result.turns); + updateMyTurns(result.turns, result.revision); } catch (err) { + await loadChiefCenter(); error.value = resolveErrorMessage(err); } }; @@ -357,9 +360,11 @@ const clearTurn = async (turnIndex: number) => { turnIndex, action: '휴식', args: {}, + expectedRevision: selectedChief.value?.revision ?? 0, }); - updateMyTurns(result.turns); + updateMyTurns(result.turns, result.revision); } catch (err) { + await loadChiefCenter(); error.value = resolveErrorMessage(err); } }; @@ -372,9 +377,11 @@ const shiftTurns = async (amount: number) => { const result = await trpc.turns.reserved.shiftNation.mutate({ generalId: data.value.me.id, amount, + expectedRevision: selectedChief.value?.revision ?? 0, }); - updateMyTurns(result.turns); + updateMyTurns(result.turns, result.revision); } catch (err) { + await loadChiefCenter(); error.value = resolveErrorMessage(err); } }; @@ -496,9 +503,7 @@ const shiftTurns = async (amount: number) => {
-
- 사령부 편집은 본인 관직에서만 가능합니다. -
+
사령부 편집은 본인 관직에서만 가능합니다.
; createMany(args?: unknown): Promise; }; + generalTurnRevision?: { + upsert(args: unknown): Promise; + deleteMany(args?: unknown): Promise; + }; nationTurn: { findMany(args?: unknown): Promise; deleteMany(args?: unknown): Promise; createMany(args?: unknown): Promise; }; + nationTurnRevision?: { + upsert(args: unknown): Promise; + deleteMany(args?: unknown): Promise; + }; } diff --git a/tools/integration-tests/test/initialization.test.ts b/tools/integration-tests/test/initialization.test.ts index f36efaa..a1e36fc 100644 --- a/tools/integration-tests/test/initialization.test.ts +++ b/tools/integration-tests/test/initialization.test.ts @@ -338,12 +338,20 @@ describe('integration initialization flow', () => { for (const [idx, user] of userSessions.entries()) { const accessRef = { value: user.accessToken }; const userGameClient = createGameClient(gameUrl, gameServer.config.trpcPath, accessRef); - if (idx < 3) { - await userGameClient.turns.reserved.setGeneral.mutate({ + let queueRevision = ( + await userGameClient.turns.reserved.getGeneral.query({ generalId: user.generalId, - turnIndex: 0, - action: 'che_거병', - }); + }) + ).revision; + if (idx < 3) { + queueRevision = ( + await userGameClient.turns.reserved.setGeneral.mutate({ + generalId: user.generalId, + turnIndex: 0, + action: 'che_거병', + expectedRevision: queueRevision, + }) + ).revision; await userGameClient.turns.reserved.setGeneral.mutate({ generalId: user.generalId, turnIndex: 1, @@ -353,20 +361,25 @@ describe('integration initialization flow', () => { nationType: 'che_def', colorType: idx, }, + expectedRevision: queueRevision, }); } else { const destNationId = joinNationIds[(idx - 3) % joinNationIds.length]!; - await userGameClient.turns.reserved.setGeneral.mutate({ - generalId: user.generalId, - turnIndex: 0, - action: 'che_임관', - args: { destNationId }, - }); + queueRevision = ( + await userGameClient.turns.reserved.setGeneral.mutate({ + generalId: user.generalId, + turnIndex: 0, + action: 'che_임관', + args: { destNationId }, + expectedRevision: queueRevision, + }) + ).revision; await userGameClient.turns.reserved.setGeneral.mutate({ generalId: user.generalId, turnIndex: 1, action: 'che_임관', args: { destNationId }, + expectedRevision: queueRevision, }); } } diff --git a/tools/run-conditional-integration.sh b/tools/run-conditional-integration.sh index 64d20f7..4237548 100755 --- a/tools/run-conditional-integration.sh +++ b/tools/run-conditional-integration.sh @@ -99,10 +99,11 @@ export INPUT_EVENT_DATABASE_URL=$database_url export GENERAL_LIFECYCLE_DATABASE_URL=$database_url export TURN_DAEMON_LEASE_DATABASE_URL=$database_url export TURN_DIFFERENTIAL_DATABASE_URL=$database_url +export RESERVED_TURN_DATABASE_URL=$database_url pnpm --filter @sammo-ts/infra prisma:db:push:game -database_markers='INPUT_EVENT_DATABASE_URL|GENERAL_LIFECYCLE_DATABASE_URL|TURN_DAEMON_LEASE_DATABASE_URL' +database_markers='INPUT_EVENT_DATABASE_URL|GENERAL_LIFECYCLE_DATABASE_URL|TURN_DAEMON_LEASE_DATABASE_URL|RESERVED_TURN_DATABASE_URL' run_marked_tests app/game-api "$database_markers" "PostgreSQL" run_marked_tests app/game-engine "$database_markers" "PostgreSQL" run_marked_tests tools/integration-tests \