From 7a18bb48f2bb654d70607432c1f7ee10a784b3f6 Mon Sep 17 00:00:00 2001 From: hided62 Date: Sun, 26 Jul 2026 18:41:51 +0000 Subject: [PATCH] fix reserved turn bulk and repeat parity --- app/game-api/src/router/turns/index.ts | 159 ++++++++++++++++++++-- app/game-api/src/turns/reservedTurns.ts | 170 +++++++++++++++++++++--- app/game-api/test/reservedTurns.test.ts | 100 ++++++++++++++ app/game-api/test/router.test.ts | 107 +++++++++++++++ 4 files changed, 507 insertions(+), 29 deletions(-) diff --git a/app/game-api/src/router/turns/index.ts b/app/game-api/src/router/turns/index.ts index 003706b..cc6e24f 100644 --- a/app/game-api/src/router/turns/index.ts +++ b/app/game-api/src/router/turns/index.ts @@ -15,23 +15,42 @@ import { MAX_GENERAL_TURNS, MAX_NATION_TURNS, ReservedTurnRevisionConflictError, + expandGeneralTurnIndices, getGeneralTurnSnapshot, getNationTurnSnapshot, + repeatGeneralTurns, + repeatNationTurns, setGeneralTurn, + setGeneralTurns, setNationTurn, + setNationTurns, shiftGeneralTurns, shiftNationTurns, } from '../../turns/reservedTurns.js'; import { getOwnedGeneral } from '../shared/general.js'; -const buildShiftAmountSchema = (maxTurns: number) => +const zPushAmount = z + .number() + .int() + .min(-12) + .max(12) + .refine((value) => value !== 0, { + message: 'Amount must be non-zero.', + }); + +const zRepeatAmount = z.number().int().min(1).max(12); + +const buildTurnListSchema = (minimum: number, maximum: number) => z - .number() - .int() - .min(-(maxTurns - 1)) - .max(maxTurns - 1) - .refine((value) => value !== 0, { - message: 'Amount must be non-zero.', + .array(z.number().int().min(minimum).max(maximum)) + .min(1); + +const buildBulkEntrySchema = (turnList: z.ZodType) => + z + .object({ + turnList, + action: z.string().min(1), + args: z.unknown().optional(), }); const parseCommandArgs = async (scope: 'general' | 'nation', action: string, args: unknown) => { @@ -232,7 +251,7 @@ export const turnsRouter = router({ .input( z.object({ generalId: z.number().int().positive(), - amount: buildShiftAmountSchema(MAX_GENERAL_TURNS), + amount: zPushAmount, expectedRevision: z.number().int().nonnegative(), }) ) @@ -244,6 +263,49 @@ export const turnsRouter = router({ ); return { ok: true, ...snapshot }; }), + repeatGeneral: authedProcedure + .input( + z.object({ + generalId: z.number().int().positive(), + amount: zRepeatAmount, + expectedRevision: z.number().int().nonnegative(), + }) + ) + .mutation(async ({ ctx, input }) => { + await getOwnedGeneral(ctx, input.generalId); + const snapshot = await mutateReservedTurns(() => + repeatGeneralTurns(ctx.db, input.generalId, input.amount, input.expectedRevision) + ); + return { ok: true, ...snapshot }; + }), + setGeneralBulk: authedProcedure + .input( + z.object({ + generalId: z.number().int().positive(), + entries: z + .array( + buildBulkEntrySchema( + buildTurnListSchema(-3, MAX_GENERAL_TURNS - 1) + ) + ) + .min(1), + expectedRevision: z.number().int().nonnegative(), + }) + ) + .mutation(async ({ ctx, input }) => { + await getOwnedGeneral(ctx, input.generalId); + const updates = await Promise.all( + input.entries.map(async (entry) => ({ + turnIndices: expandGeneralTurnIndices(entry.turnList), + action: entry.action, + args: await parseCommandArgs('general', entry.action, entry.args), + })) + ); + const snapshot = await mutateReservedTurns(() => + setGeneralTurns(ctx.db, input.generalId, updates, input.expectedRevision) + ); + return { ok: true, ...snapshot }; + }), setNation: authedProcedure .input( z.object({ @@ -291,7 +353,7 @@ export const turnsRouter = router({ .input( z.object({ generalId: z.number().int().positive(), - amount: buildShiftAmountSchema(MAX_NATION_TURNS), + amount: zPushAmount, expectedRevision: z.number().int().nonnegative(), }) ) @@ -321,5 +383,84 @@ export const turnsRouter = router({ ); return { ok: true, ...snapshot }; }), + repeatNation: authedProcedure + .input( + z.object({ + generalId: z.number().int().positive(), + amount: zRepeatAmount, + expectedRevision: z.number().int().nonnegative(), + }) + ) + .mutation(async ({ ctx, input }) => { + const general = await getOwnedGeneral(ctx, input.generalId); + if (general.nationId <= 0) { + throw new TRPCError({ + code: 'PRECONDITION_FAILED', + message: 'General is not part of a nation.', + }); + } + if (general.officerLevel < 5) { + throw new TRPCError({ + code: 'FORBIDDEN', + message: 'General is not an officer.', + }); + } + const snapshot = await mutateReservedTurns(() => + repeatNationTurns( + ctx.db, + general.nationId, + general.officerLevel, + input.amount, + input.expectedRevision + ) + ); + return { ok: true, ...snapshot }; + }), + setNationBulk: authedProcedure + .input( + z.object({ + generalId: z.number().int().positive(), + entries: z + .array( + buildBulkEntrySchema( + buildTurnListSchema(0, MAX_NATION_TURNS - 1) + ) + ) + .min(1), + expectedRevision: z.number().int().nonnegative(), + }) + ) + .mutation(async ({ ctx, input }) => { + const general = await getOwnedGeneral(ctx, input.generalId); + if (general.nationId <= 0) { + throw new TRPCError({ + code: 'PRECONDITION_FAILED', + message: 'General is not part of a nation.', + }); + } + if (general.officerLevel < 5) { + throw new TRPCError({ + code: 'FORBIDDEN', + message: 'General is not an officer.', + }); + } + const updates = await Promise.all( + input.entries.map(async (entry) => ({ + turnIndices: entry.turnList, + action: entry.action, + args: await parseCommandArgs('nation', entry.action, entry.args), + })) + ); + const snapshot = await mutateReservedTurns(() => + setNationTurns( + ctx.db, + general.nationId, + general.officerLevel, + updates, + 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 f6d8212..d6abe7a 100644 --- a/app/game-api/src/turns/reservedTurns.ts +++ b/app/game-api/src/turns/reservedTurns.ts @@ -21,6 +21,12 @@ export interface ReservedTurnSnapshot { turns: ReservedTurnView[]; } +export interface ReservedTurnUpdate { + turnIndices: readonly number[]; + action: string; + args: unknown; +} + export class ReservedTurnRevisionConflictError extends Error { constructor( readonly expectedRevision: number, @@ -58,6 +64,43 @@ const applyShift = (turns: ReservedTurnEntry[], amount: number): ReservedTurnEnt return sliced.concat(padding); }; +const applyRepeat = (turns: ReservedTurnEntry[], amount: number): ReservedTurnEntry[] => { + if (amount <= 0 || amount >= turns.length) { + return turns.slice(); + } + const repeated = turns.map((entry) => ({ + action: entry.action, + args: entry.args, + })); + const sourceCount = amount * 2 > turns.length ? turns.length - amount : amount; + for (let sourceIndex = 0; sourceIndex < sourceCount; sourceIndex += 1) { + const source = turns[sourceIndex] ?? createDefaultEntry(); + for (let targetIndex = sourceIndex + amount; targetIndex < turns.length; targetIndex += amount) { + repeated[targetIndex] = { + action: source.action, + args: source.args, + }; + } + } + return repeated; +}; + +export const expandGeneralTurnIndices = (rawTurnIndices: readonly number[]): number[] => { + const expanded = new Set(); + for (const turnIndex of rawTurnIndices) { + if (turnIndex >= 0) { + expanded.add(turnIndex); + continue; + } + const start = turnIndex === -2 ? 1 : 0; + const step = turnIndex === -3 ? 1 : 2; + for (let index = start; index < MAX_GENERAL_TURNS; index += step) { + expanded.add(index); + } + } + return Array.from(expanded); +}; + const buildTurnListFromRows = (rows: Array, maxTurns: number): ReservedTurnEntry[] => { const result = buildDefaultTurns(maxTurns); for (const row of rows) { @@ -234,6 +277,51 @@ const claimNationRevision = async ( throw new ReservedTurnRevisionConflictError(expectedRevision, current?.revision ?? 0); }; +const assertGeneralRevision = async ( + db: DatabaseClient, + generalId: number, + expectedRevision: number +): Promise => { + const snapshot = await getGeneralTurnSnapshot(db, generalId); + if (snapshot.revision !== expectedRevision) { + throw new ReservedTurnRevisionConflictError(expectedRevision, snapshot.revision); + } + return snapshot; +}; + +const assertNationRevision = async ( + db: DatabaseClient, + nationId: number, + officerLevel: number, + expectedRevision: number +): Promise => { + const snapshot = await getNationTurnSnapshot(db, nationId, officerLevel); + if (snapshot.revision !== expectedRevision) { + throw new ReservedTurnRevisionConflictError(expectedRevision, snapshot.revision); + } + return snapshot; +}; + +export const setGeneralTurns = async ( + db: DatabaseClient, + generalId: number, + updates: readonly ReservedTurnUpdate[], + expectedRevision: number +): Promise => { + const revision = await claimGeneralRevision(db, generalId, expectedRevision); + const turns = await loadGeneralTurns(db, generalId); + for (const update of updates) { + for (const turnIndex of update.turnIndices) { + turns[turnIndex] = { + action: normalizeAction(update.action), + args: normalizeArgs(update.args), + }; + } + } + await persistGeneralTurns(db, generalId, turns); + return { revision, turns: serializeTurnList(turns) }; +}; + export const setGeneralTurn = async ( db: DatabaseClient, generalId: number, @@ -241,16 +329,8 @@ export const setGeneralTurn = async ( action: string, 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 { revision, turns: serializeTurnList(turns) }; -}; +): Promise => + setGeneralTurns(db, generalId, [{ turnIndices: [turnIndex], action, args }], expectedRevision); export const shiftGeneralTurns = async ( db: DatabaseClient, @@ -258,6 +338,9 @@ export const shiftGeneralTurns = async ( amount: number, expectedRevision: number ): Promise => { + if (Math.abs(amount) >= MAX_GENERAL_TURNS) { + return assertGeneralRevision(db, generalId, expectedRevision); + } const revision = await claimGeneralRevision(db, generalId, expectedRevision); const turns = await loadGeneralTurns(db, generalId); const shifted = applyShift(turns, amount); @@ -265,6 +348,42 @@ export const shiftGeneralTurns = async ( return { revision, turns: serializeTurnList(shifted) }; }; +export const repeatGeneralTurns = async ( + db: DatabaseClient, + generalId: number, + amount: number, + expectedRevision: number +): Promise => { + if (amount >= MAX_GENERAL_TURNS) { + return assertGeneralRevision(db, generalId, expectedRevision); + } + const revision = await claimGeneralRevision(db, generalId, expectedRevision); + const turns = applyRepeat(await loadGeneralTurns(db, generalId), amount); + await persistGeneralTurns(db, generalId, turns); + return { revision, turns: serializeTurnList(turns) }; +}; + +export const setNationTurns = async ( + db: DatabaseClient, + nationId: number, + officerLevel: number, + updates: readonly ReservedTurnUpdate[], + expectedRevision: number +): Promise => { + const revision = await claimNationRevision(db, nationId, officerLevel, expectedRevision); + const turns = await loadNationTurns(db, nationId, officerLevel); + for (const update of updates) { + for (const turnIndex of update.turnIndices) { + turns[turnIndex] = { + action: normalizeAction(update.action), + args: normalizeArgs(update.args), + }; + } + } + await persistNationTurns(db, nationId, officerLevel, turns); + return { revision, turns: serializeTurnList(turns) }; +}; + export const setNationTurn = async ( db: DatabaseClient, nationId: number, @@ -273,16 +392,8 @@ export const setNationTurn = async ( action: string, 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 { revision, turns: serializeTurnList(turns) }; -}; +): Promise => + setNationTurns(db, nationId, officerLevel, [{ turnIndices: [turnIndex], action, args }], expectedRevision); export const shiftNationTurns = async ( db: DatabaseClient, @@ -291,9 +402,28 @@ export const shiftNationTurns = async ( amount: number, expectedRevision: number ): Promise => { + if (Math.abs(amount) >= MAX_NATION_TURNS) { + return assertNationRevision(db, nationId, officerLevel, expectedRevision); + } 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 { revision, turns: serializeTurnList(shifted) }; }; + +export const repeatNationTurns = async ( + db: DatabaseClient, + nationId: number, + officerLevel: number, + amount: number, + expectedRevision: number +): Promise => { + if (amount >= MAX_NATION_TURNS) { + return assertNationRevision(db, nationId, officerLevel, expectedRevision); + } + const revision = await claimNationRevision(db, nationId, officerLevel, expectedRevision); + const turns = applyRepeat(await loadNationTurns(db, nationId, officerLevel), amount); + await persistNationTurns(db, nationId, officerLevel, turns); + return { revision, turns: serializeTurnList(turns) }; +}; diff --git a/app/game-api/test/reservedTurns.test.ts b/app/game-api/test/reservedTurns.test.ts index eda5329..869b1c6 100644 --- a/app/game-api/test/reservedTurns.test.ts +++ b/app/game-api/test/reservedTurns.test.ts @@ -4,8 +4,13 @@ import type { DatabaseClient, GeneralTurnRow, NationTurnRow } from '../src/conte import { MAX_GENERAL_TURNS, MAX_NATION_TURNS, + expandGeneralTurnIndices, + repeatGeneralTurns, + repeatNationTurns, setGeneralTurn, + setGeneralTurns, setNationTurn, + setNationTurns, shiftGeneralTurns, shiftNationTurns, ReservedTurnRevisionConflictError, @@ -238,4 +243,99 @@ describe('reservedTurns', () => { 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); + }); }); diff --git a/app/game-api/test/router.test.ts b/app/game-api/test/router.test.ts index 6335177..c30ea5f 100644 --- a/app/game-api/test/router.test.ts +++ b/app/game-api/test/router.test.ts @@ -476,6 +476,113 @@ describe('appRouter', () => { expect(writes).toHaveLength(1); }); + it('applies legacy general sentinel bulk entries and repeats the leading pattern', async () => { + const general = buildGeneralRow({ id: 16 }); + const bulkWrites: unknown[] = []; + const bulkCaller = appRouter.createCaller(buildContext({ general, generalTurnWrites: bulkWrites })); + + const bulk = await bulkCaller.turns.reserved.setGeneralBulk({ + generalId: general.id, + entries: [ + { turnList: [-1], action: 'che_훈련' }, + { turnList: [-2], action: 'che_사기진작' }, + ], + expectedRevision: 0, + }); + expect(bulk.turns[0]?.action).toBe('che_훈련'); + expect(bulk.turns[1]?.action).toBe('che_사기진작'); + expect(bulk.turns[29]?.action).toBe('che_사기진작'); + expect(bulkWrites).toHaveLength(1); + + const repeatWrites: unknown[] = []; + const repeatCaller = appRouter.createCaller( + buildContext({ + general, + generalTurns: [ + { + id: 1, + generalId: general.id, + turnIdx: 0, + actionCode: 'che_훈련', + arg: {}, + createdAt: new Date(), + }, + { + id: 2, + generalId: general.id, + turnIdx: 1, + actionCode: 'che_사기진작', + arg: {}, + createdAt: new Date(), + }, + ], + generalTurnWrites: repeatWrites, + }) + ); + const repeated = await repeatCaller.turns.reserved.repeatGeneral({ + generalId: general.id, + amount: 2, + expectedRevision: 0, + }); + expect(repeated.turns.slice(0, 6).map((turn) => turn.action)).toEqual([ + 'che_훈련', + 'che_사기진작', + 'che_훈련', + 'che_사기진작', + 'che_훈련', + 'che_사기진작', + ]); + expect(repeatWrites).toHaveLength(1); + }); + + it('accepts the legacy nation amount-12 no-op without incrementing revision', async () => { + const general = buildGeneralRow({ id: 17, nationId: 3, officerLevel: 12 }); + const nationWrites: unknown[] = []; + const caller = appRouter.createCaller(buildContext({ general, nationTurnWrites: nationWrites })); + + const shifted = await caller.turns.reserved.shiftNation({ + generalId: general.id, + amount: 12, + expectedRevision: 0, + }); + const repeated = await caller.turns.reserved.repeatNation({ + generalId: general.id, + amount: 12, + expectedRevision: 0, + }); + + expect(shifted.revision).toBe(0); + expect(repeated.revision).toBe(0); + expect(nationWrites).toHaveLength(0); + }); + + it('applies nation bulk entries sequentially so later entries overwrite overlaps', async () => { + const general = buildGeneralRow({ id: 18, nationId: 3, officerLevel: 12 }); + const nationWrites: unknown[] = []; + const caller = appRouter.createCaller(buildContext({ general, nationTurnWrites: nationWrites })); + + const response = await caller.turns.reserved.setNationBulk({ + generalId: general.id, + entries: [ + { + turnList: [0, 2], + action: 'che_포상', + args: { isGold: true, amount: 1, destGeneralId: 7 }, + }, + { + turnList: [2], + action: 'che_포상', + args: { isGold: false, amount: 2, destGeneralId: 8 }, + }, + ], + expectedRevision: 0, + }); + + expect(response.turns[0]?.args).toEqual({ isGold: true, amount: 1, destGeneralId: 7 }); + expect(response.turns[2]?.args).toEqual({ isGold: false, amount: 2, destGeneralId: 8 }); + expect(nationWrites).toHaveLength(1); + }); + it('rejects malformed and cross-scope arguments without writing turns', async () => { const general = buildGeneralRow({ id: 14, nationId: 3, officerLevel: 5 }); const generalWrites: unknown[] = [];