diff --git a/app/game-api/src/router/messages/index.ts b/app/game-api/src/router/messages/index.ts index 67ac79d5..9732befc 100644 --- a/app/game-api/src/router/messages/index.ts +++ b/app/game-api/src/router/messages/index.ts @@ -1,5 +1,3 @@ -import { randomUUID } from 'node:crypto'; - import { TRPCError } from '@trpc/server'; import { z } from 'zod'; import { asRecord, ChangeJournal } from '@sammo-ts/common'; @@ -14,7 +12,6 @@ import { authedProcedure, engineAuthedProcedure, router, - scopeApiInputEventRequestId, wallAuthedProcedure, } from '../../trpc.js'; import { @@ -357,7 +354,7 @@ export const messagesRouter = router({ let journalPersisted = false; const response = await executeInputEvent({ db: ctx.db, - requestId: scopeApiInputEventRequestId(ctx.requestId ?? randomUUID(), 'messages.respond.diplomatic', 0), + requestId: `messages.respond.diplomatic:${input.messageId}`, eventType: 'messages.respond.diplomatic', payload: input, actorUserId: ctx.auth?.user.id, @@ -393,13 +390,39 @@ export const messagesRouter = router({ await writeReadModelChangeJournal(transaction, changeJournal.snapshot()) ); } - return { result: result.result, reason: result.reason }; + return { + result: result.result, + reason: result.reason, + affectedNationIds: result.affectedNationIds, + affectedCityIds: result.affectedCityIds, + }; }, }); if (journalPersisted) { ctx.readModelOutbox?.wake(); } - return response; + if (response.result && (response.affectedNationIds.length > 0 || response.affectedCityIds.length > 0)) { + if (!ctx.auth) { + throw new TRPCError({ code: 'UNAUTHORIZED' }); + } + const synchronized = await ctx.turnDaemon.requestCommand({ + type: 'syncDiplomaticResponse', + userId: ctx.auth.user.id, + generalId: general.id, + messageId: input.messageId, + nationIds: response.affectedNationIds, + cityIds: response.affectedCityIds, + }); + if (!synchronized || synchronized.type !== 'syncDiplomaticResponse' || !synchronized.ok) { + const synchronizationReason = + synchronized?.type === 'syncDiplomaticResponse' ? synchronized.reason : undefined; + throw new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: synchronizationReason ?? '외교 상태를 게임 엔진에 동기화하지 못했습니다.', + }); + } + } + return { result: response.result, reason: response.reason }; }), getOld: authedProcedure .input( diff --git a/app/game-api/test/messagesRouter.test.ts b/app/game-api/test/messagesRouter.test.ts index f16cedad..cde1f34a 100644 --- a/app/game-api/test/messagesRouter.test.ts +++ b/app/game-api/test/messagesRouter.test.ts @@ -959,6 +959,15 @@ describe('messages router missing-flow compatibility', () => { const messageActionUpdateMany = vi.fn(async () => ({ count: 1 })); const cityUpdate = vi.fn(async () => ({})); const changeJournal = new ChangeJournal(); + const requestCommand = vi.fn(async (command: { type: string; generalId: number; messageId: number }) => ({ + type: 'syncDiplomaticResponse' as const, + ok: true, + generalId: command.generalId, + messageId: command.messageId, + nations: 2, + diplomacy: 2, + cities: 0, + })); const { caller } = buildContext( { general: { @@ -1049,7 +1058,7 @@ describe('messages router missing-flow compatibility', () => { messageAction: { updateMany: messageActionUpdateMany }, $queryRaw: queryRaw, }, - { changeJournal } + { changeJournal, turnDaemon: { requestCommand } } ); return { caller, @@ -1062,6 +1071,7 @@ describe('messages router missing-flow compatibility', () => { messageUpdateMany, cityUpdate, changeJournal, + requestCommand, }; }; @@ -1075,6 +1085,14 @@ describe('messages router missing-flow compatibility', () => { }); expect(result).toEqual({ result: true, reason: 'success' }); + expect(setup.requestCommand).toHaveBeenCalledWith({ + type: 'syncDiplomaticResponse', + userId: auth.user.id, + generalId: setup.actor.id, + messageId: 31, + nationIds: [1, 2], + cityIds: [], + }); expect(setup.diplomacyUpdate).toHaveBeenCalledTimes(2); expect(setup.nationUpdate).toHaveBeenCalledWith( expect.objectContaining({ diff --git a/app/game-engine/src/turn/commandRegistry.ts b/app/game-engine/src/turn/commandRegistry.ts index fb6ff3e9..daa4f049 100644 --- a/app/game-engine/src/turn/commandRegistry.ts +++ b/app/game-engine/src/turn/commandRegistry.ts @@ -131,6 +131,15 @@ const zMessageRespond = z.object({ response: z.boolean(), }); +const zSyncDiplomaticResponse = z.object({ + type: z.literal('syncDiplomaticResponse'), + userId: z.string().min(1), + generalId: z.number().int().positive(), + messageId: z.number().int().positive(), + nationIds: z.array(z.number().int().positive()).max(4), + cityIds: z.array(z.number().int().positive()).max(256), +}); + const zVacation = z.object({ type: z.literal('vacation'), userId: z.string().min(1), @@ -609,6 +618,14 @@ const normalizeMessageRespond: CommandNormalizer<'messageRespond'> = (envelope) return { ...command, requestId: envelope.requestId }; }; +const normalizeSyncDiplomaticResponse: CommandNormalizer<'syncDiplomaticResponse'> = (envelope) => { + const command = parseWith(zSyncDiplomaticResponse, envelope.command); + if (!command) { + return null; + } + return { ...command, requestId: envelope.requestId }; +}; + const normalizeVacation: CommandNormalizer<'vacation'> = (envelope) => { const command = parseWith(zVacation, envelope.command); if (!command) { @@ -859,6 +876,7 @@ const normalizers: CommandNormalizerMap = { buildNationCandidate: normalizeBuildNationCandidate, instantRetreat: normalizeInstantRetreat, messageRespond: normalizeMessageRespond, + syncDiplomaticResponse: normalizeSyncDiplomaticResponse, vacation: normalizeVacation, setMySetting: normalizeSetMySetting, dropItem: normalizeDropItem, diff --git a/app/game-engine/src/turn/worldCommandHandler.ts b/app/game-engine/src/turn/worldCommandHandler.ts index 5e2a4210..2a7788ca 100644 --- a/app/game-engine/src/turn/worldCommandHandler.ts +++ b/app/game-engine/src/turn/worldCommandHandler.ts @@ -29,6 +29,7 @@ import { normalizeTroopName, resolveTroopSecretPermission, resolveMessageTargetIcon, + readDiplomacyMeta, type GeneralActionModule, rollUniqueLottery, type ItemModule, @@ -182,6 +183,7 @@ const ACTOR_BOUND_GENERAL_COMMAND_TYPE_LIST = [ 'kick', 'appoint', 'voteReward', + 'syncDiplomaticResponse', ] as const satisfies readonly TurnDaemonCommand['type'][]; type ActorBoundGeneralCommandType = (typeof ACTOR_BOUND_GENERAL_COMMAND_TYPE_LIST)[number]; @@ -1839,6 +1841,72 @@ async function handleMessageRespond( }; } +async function handleSyncDiplomaticResponse( + ctx: CommandHandlerContext, + command: Extract +): Promise { + const db = requireCommandDatabase(ctx); + const action = await db.messageAction.findUnique({ + where: { messageId: command.messageId }, + select: { status: true }, + }); + if (action?.status !== 'RESOLVED') { + return { + type: 'syncDiplomaticResponse', + ok: false, + generalId: command.generalId, + messageId: command.messageId, + nations: 0, + diplomacy: 0, + cities: 0, + reason: '해결되지 않은 외교서신은 동기화할 수 없습니다.', + }; + } + + const nationIds = [...new Set(command.nationIds)]; + const cityIds = [...new Set(command.cityIds)]; + const [nations, diplomacy, cities] = await Promise.all([ + db.nation.findMany({ where: { id: { in: nationIds } }, select: { id: true, meta: true } }), + nationIds.length === 0 + ? Promise.resolve([]) + : db.diplomacy.findMany({ + where: { srcNationId: { in: nationIds }, destNationId: { in: nationIds } }, + select: { srcNationId: true, destNationId: true, stateCode: true, term: true, meta: true }, + }), + db.city.findMany({ where: { id: { in: cityIds } }, select: { id: true, frontState: true } }), + ]); + + for (const nation of nations) { + ctx.world.updateNation(nation.id, { meta: asRecord(nation.meta) as Record }); + } + for (const entry of diplomacy) { + const parsedMeta = readDiplomacyMeta(asRecord(entry.meta)); + ctx.world.applyDiplomacyPatch({ + srcNationId: entry.srcNationId, + destNationId: entry.destNationId, + patch: { + state: entry.stateCode, + term: entry.term, + dead: parsedMeta.dead, + meta: parsedMeta.meta as Record, + }, + }); + } + for (const city of cities) { + ctx.world.updateCity(city.id, { frontState: city.frontState }); + } + + return { + type: 'syncDiplomaticResponse', + ok: true, + generalId: command.generalId, + messageId: command.messageId, + nations: nations.length, + diplomacy: diplomacy.length, + cities: cities.length, + }; +} + async function handleVacation( ctx: CommandHandlerContext, command: Extract @@ -3151,6 +3219,11 @@ export const createTurnDaemonCommandHandler = (options: { handleInstantRetreat(ctx, command as Extract), messageRespond: (command) => handleMessageRespond(ctx, command as Extract), + syncDiplomaticResponse: (command) => + handleSyncDiplomaticResponse( + ctx, + command as Extract + ), vacation: (command) => handleVacation(ctx, command as Extract), setMySetting: (command) => handleSetMySetting(ctx, command as Extract), diff --git a/app/game-engine/test/authenticatedActorCommand.test.ts b/app/game-engine/test/authenticatedActorCommand.test.ts index d7a82780..6829ce53 100644 --- a/app/game-engine/test/authenticatedActorCommand.test.ts +++ b/app/game-engine/test/authenticatedActorCommand.test.ts @@ -46,6 +46,15 @@ const buildActorBoundCommands = (userId = 'old-owner'): TurnDaemonCommand[] => [ officerLevel: 4, }, { type: 'voteReward', requestId: 'voteReward', userId, generalId: 7, voteId: 1, selection: [0] }, + { + type: 'syncDiplomaticResponse', + requestId: 'syncDiplomaticResponse', + userId, + generalId: 7, + messageId: 31, + nationIds: [1, 2], + cityIds: [1], + }, ]; const buildReadOnlyWorld = (ownerUserId: string) => { @@ -181,6 +190,66 @@ describe('authenticated actor-bound command registry and execution', () => { expect(mutation).not.toHaveBeenCalled(); }); + it('refreshes the daemon world from the committed diplomatic response before the next turn', async () => { + const updateNation = vi.fn(); + const applyDiplomacyPatch = vi.fn(); + const updateCity = vi.fn(); + const world = { + getGeneralById: vi.fn(() => ({ id: 7, userId: 'old-owner' })), + updateNation, + applyDiplomacyPatch, + updateCity, + } as unknown as InMemoryTurnWorld; + const db = { + inputEvent: { + findUnique: vi.fn(async () => ({ + actorUserId: 'old-owner', + target: 'ENGINE', + eventType: 'syncDiplomaticResponse', + })), + }, + messageAction: { findUnique: vi.fn(async () => ({ status: 'RESOLVED' })) }, + nation: { findMany: vi.fn(async () => [{ id: 1, meta: { policy: 'balanced' } }]) }, + diplomacy: { + findMany: vi.fn(async () => [ + { srcNationId: 1, destNationId: 2, stateCode: 7, term: 12, meta: { dead: 3 } }, + ]), + }, + city: { findMany: vi.fn(async () => [{ id: 4, frontState: 2 }]) }, + }; + const handler = createTurnDaemonCommandHandler({ world }); + + await expect( + handler.handle( + { + type: 'syncDiplomaticResponse', + requestId: 'syncDiplomaticResponse', + userId: 'old-owner', + generalId: 7, + messageId: 31, + nationIds: [1, 2], + cityIds: [4], + }, + { db: db as never } + ) + ).resolves.toEqual({ + type: 'syncDiplomaticResponse', + ok: true, + generalId: 7, + messageId: 31, + nations: 1, + diplomacy: 1, + cities: 1, + }); + expect(updateNation).toHaveBeenCalledWith(1, { meta: { policy: 'balanced' } }); + expect(applyDiplomacyPatch).toHaveBeenCalledWith({ + srcNationId: 1, + destNationId: 2, + patch: { state: 7, term: 12, dead: 3, meta: {} }, + }); + expect(updateCity).toHaveBeenCalledWith(4, { frontState: 2 }); + }); + it('preserves direct in-memory invocation when no command database is supplied', async () => { const updateGeneral = vi.fn(); const world = { diff --git a/packages/common/src/turnDaemon/types.ts b/packages/common/src/turnDaemon/types.ts index 575607ef..513a6c05 100644 --- a/packages/common/src/turnDaemon/types.ts +++ b/packages/common/src/turnDaemon/types.ts @@ -161,6 +161,15 @@ export type TurnDaemonCommand = messageId: number; response: boolean; } + | { + type: 'syncDiplomaticResponse'; + requestId?: string; + userId: string; + generalId: number; + messageId: number; + nationIds: number[]; + cityIds: number[]; + } | { type: 'vacation'; requestId?: string; userId: string; generalId: number } | { type: 'setMySetting'; @@ -575,6 +584,16 @@ export type TurnDaemonCommandResult = action?: 'scout' | 'raiseInvader'; reason: string; } + | { + type: 'syncDiplomaticResponse'; + ok: boolean; + generalId: number; + messageId: number; + nations: number; + diplomacy: number; + cities: number; + reason?: string; + } | { type: 'vacation'; ok: boolean; generalId: number; reason?: string } | { type: 'setMySetting'; ok: boolean; generalId: number; reason?: string } | { type: 'dropItem'; ok: boolean; generalId: number; reason?: string } diff --git a/tools/integration-tests/scripts/live-ten-user-lifecycle.ts b/tools/integration-tests/scripts/live-ten-user-lifecycle.ts index 96be7a0f..81bcdb0d 100644 --- a/tools/integration-tests/scripts/live-ten-user-lifecycle.ts +++ b/tools/integration-tests/scripts/live-ten-user-lifecycle.ts @@ -56,6 +56,11 @@ type State = { }; actionableMessages?: { recruitmentId: number; noAggressionId: number }; cancelNoAggressionMessageId?: number; + pausedTournamentBet?: { + bettorGeneralId: number; + targetGeneralId: number; + preparedGameTick: string; + }; }; const log = (event: string, detail: Record = {}): void => { @@ -832,14 +837,17 @@ const prepareActionFixture = async (): Promise => { const fixture = await db.prisma.$transaction(async (tx) => { const world = await tx.worldState.findFirstOrThrow({ orderBy: { id: 'asc' } }); if (world.clockPhase !== 'SUSPENDED') { - throw new Error(`Action fixture requires a stopped or paused SUSPENDED clock, found ${world.clockPhase}.`); + throw new Error( + `Action fixture requires a stopped or paused SUSPENDED clock, found ${world.clockPhase}.` + ); } const nations = await tx.nation.findMany({ where: { id: { gt: 0 }, level: { gt: 0 } }, orderBy: [{ level: 'desc' }, { id: 'asc' }], take: 2, }); - if (nations.length !== 2) throw new Error(`Action fixture requires two active nations, found ${nations.length}.`); + if (nations.length !== 2) + throw new Error(`Action fixture requires two active nations, found ${nations.length}.`); const generalRows = await tx.general.findMany({ where: { name: { in: state.users!.map((user) => user.generalName) }, userId: { not: null } }, orderBy: { id: 'asc' }, @@ -921,7 +929,8 @@ const prepareActionFixture = async (): Promise => { } }; -const rotateFirst = (values: readonly T[]): T[] => (values.length < 2 ? [...values] : [...values.slice(1), values[0]!]); +const rotateFirst = (values: readonly T[]): T[] => + values.length < 2 ? [...values] : [...values.slice(1), values[0]!]; const readHiddenBuffLevel = (rawMeta: unknown, key: string): number => { if (!rawMeta || typeof rawMeta !== 'object' || Array.isArray(rawMeta)) return 0; @@ -993,7 +1002,8 @@ const exercisePausedActions = async (): Promise => { if (worldBefore.clockPhase !== 'SUSPENDED') { throw new Error(`Paused action matrix requires SUSPENDED, found ${worldBefore.clockPhase}.`); } - if (worldBefore.clockTick === null) throw new Error('Paused action matrix requires an authoritative clock tick.'); + if (worldBefore.clockTick === null) + throw new Error('Paused action matrix requires an authoritative clock tick.'); const clients = state.users.map((user) => createGame(user.gameToken)); const generals = state.actionFixture.generals; const firstNation = state.actionFixture.nations[0]!; @@ -1050,8 +1060,7 @@ const exercisePausedActions = async (): Promise => { }); const inheritanceById = new Map(inheritanceRows.map((general) => [general.id, general.meta])); const inheritanceGeneralIndex = generals.findIndex( - (general, index) => - index >= 3 && readHiddenBuffLevel(inheritanceById.get(general.id), 'warAvoidRatio') < 1 + (general, index) => index >= 3 && readHiddenBuffLevel(inheritanceById.get(general.id), 'warAvoidRatio') < 1 ); if (inheritanceGeneralIndex < 0) throw new Error('No lifecycle user remains for the inheritance purchase.'); const inheritance = await clients[inheritanceGeneralIndex]!.inherit.buyHiddenBuff.mutate({ @@ -1095,11 +1104,22 @@ const exercisePausedActions = async (): Promise => { db.prisma.worldState.findFirstOrThrow({ orderBy: { id: 'asc' } }), db.prisma.general.findMany({ where: { id: { in: [generals[2]!.id, generals[4]!.id] } }, - select: { id: true, nationId: true, cityId: true, officerLevel: true, weaponCode: true, meta: true }, + select: { + id: true, + nationId: true, + cityId: true, + officerLevel: true, + weaponCode: true, + meta: true, + }, orderBy: { id: 'asc' }, }), db.prisma.message.findMany({ - where: { id: { in: [publicMessage.msgId, privateMessage.msgId, nationalMessage.msgId, expiryMessage.msgId] } }, + where: { + id: { + in: [publicMessage.msgId, privateMessage.msgId, nationalMessage.msgId, expiryMessage.msgId], + }, + }, select: { id: true, time: true, @@ -1122,7 +1142,9 @@ const exercisePausedActions = async (): Promise => { ]); if (worldAfter.clockTick === null) throw new Error('Paused action matrix lost the authoritative clock tick.'); if (worldAfter.clockTick !== worldBefore.clockTick) { - throw new Error(`Game tick moved during paused actions: ${worldBefore.clockTick} -> ${worldAfter.clockTick}.`); + throw new Error( + `Game tick moved during paused actions: ${worldBefore.clockTick} -> ${worldAfter.clockTick}.` + ); } if (persistedMessages.some((message) => message.timeTick !== null)) { throw new Error('A paused ordinary message unexpectedly persisted a GAME_TIME occurrence tick.'); @@ -1223,7 +1245,9 @@ const verifyPausedWallExpiry = async (): Promise => { rejectedMessage = error instanceof Error ? error.message : String(error); } if (!rejectedMessage.includes('5분 이내')) { - throw new Error(`Expired paused message was not rejected by the wall window: ${rejectedMessage || 'accepted'}`); + throw new Error( + `Expired paused message was not rejected by the wall window: ${rejectedMessage || 'accepted'}` + ); } const [after, world] = await Promise.all([ db.prisma.message.findUniqueOrThrow({ @@ -1252,7 +1276,7 @@ const verifyPausedWallExpiry = async (): Promise => { } }; -const exercisePausedTournamentBet = async (): Promise => { +const prepareTournamentBet = async (): Promise => { const state = await readState(); if (state.users?.length !== 10 || !state.actionFixture) { throw new Error('Ten users and the action fixture are required.'); @@ -1261,8 +1285,8 @@ const exercisePausedTournamentBet = async (): Promise => { await db.connect(); try { const world = await db.prisma.worldState.findFirstOrThrow({ orderBy: { id: 'asc' } }); - if (world.clockPhase !== 'SUSPENDED' || world.clockTick === null || !world.clockWallAnchor) { - throw new Error('Paused tournament betting requires a suspended authoritative game clock.'); + if (!['RUNNING', 'MANUAL'].includes(world.clockPhase) || world.clockTick === null || !world.clockWallAnchor) { + throw new Error(`Tournament setup requires a running authoritative game clock, found ${world.clockPhase}.`); } const bettor = state.actionFixture.generals[6]!; const target = state.actionFixture.generals[7]!; @@ -1296,6 +1320,44 @@ const exercisePausedTournamentBet = async (): Promise => { bettingSettled: false, rewardSettled: false, }); + state.pausedTournamentBet = { + bettorGeneralId: bettor.id, + targetGeneralId: target.id, + preparedGameTick: world.clockTick.toString(), + }; + await writeState(state); + log('tournament-bet-prepared', { + bettorGeneralId: bettor.id, + targetGeneralId: target.id, + opponentGeneralId: opponent.id, + preparedGameTick: world.clockTick.toString(), + bettingCloseAt: deadline, + }); + } finally { + await db.disconnect(); + } +}; + +const submitPausedTournamentBet = async (): Promise => { + const state = await readState(); + if (state.users?.length !== 10 || !state.actionFixture || !state.pausedTournamentBet) { + throw new Error('A running-clock tournament fixture is required before the paused bet.'); + } + const db = createGamePostgresConnector({ url: gameDatabaseUrl() }); + await db.connect(); + try { + const world = await db.prisma.worldState.findFirstOrThrow({ orderBy: { id: 'asc' } }); + if (world.clockPhase !== 'SUSPENDED' || world.clockTick === null) { + throw new Error(`Paused tournament betting requires SUSPENDED, found ${world.clockPhase}.`); + } + const bettor = state.actionFixture.generals[6]!; + const target = state.actionFixture.generals[7]!; + if ( + bettor.id !== state.pausedTournamentBet.bettorGeneralId || + target.id !== state.pausedTournamentBet.targetGeneralId + ) { + throw new Error('Tournament bettor fixture no longer matches the persisted lifecycle state.'); + } const before = await db.prisma.general.findUniqueOrThrow({ where: { id: bettor.id }, select: { gold: true } }); const result = await createGame(state.users[6]!.gameToken).tournament.placeBet.mutate({ targetId: target.id, @@ -1322,6 +1384,7 @@ const exercisePausedTournamentBet = async (): Promise => { goldAfter: after.gold, betCount: snapshot.betCount, clockTick: world.clockTick.toString(), + preparedGameTick: state.pausedTournamentBet.preparedGameTick, result, }); } finally { @@ -1467,21 +1530,36 @@ const respondActionableMessages = async (): Promise => { await db.connect(); try { const worldBefore = await db.prisma.worldState.findFirstOrThrow({ orderBy: { id: 'asc' } }); - if (worldBefore.clockPhase !== 'SUSPENDED' || worldBefore.clockTick === null) { - throw new Error(`Paused actionable response requires SUSPENDED, found ${worldBefore.clockPhase}.`); + if (!['RUNNING', 'MANUAL'].includes(worldBefore.clockPhase) || worldBefore.clockTick === null) { + throw new Error(`Actionable response requires a running game clock, found ${worldBefore.clockPhase}.`); } const recruitmentTarget = state.actionFixture.generals[9]!; const diplomacyActor = state.actionFixture.generals[5]!; - const recruitment = await createGame(state.users[9]!.gameToken).messages.respond.mutate({ - generalId: recruitmentTarget.id, - messageId: state.actionableMessages.recruitmentId, - response: true, - }); - const noAggression = await createGame(state.users[5]!.gameToken).messages.respond.mutate({ - generalId: diplomacyActor.id, - messageId: state.actionableMessages.noAggressionId, - response: true, + const actionsBefore = await db.prisma.messageAction.findMany({ + where: { messageId: { in: Object.values(state.actionableMessages) } }, }); + const recruitment = + actionsBefore.find((action) => action.messageId === state.actionableMessages!.recruitmentId)?.status === + 'PENDING' + ? await createGame(state.users[9]!.gameToken).messages.respond.mutate({ + generalId: recruitmentTarget.id, + messageId: state.actionableMessages.recruitmentId, + // A general who is already serving another nation can receive the + // recruitment letter but cannot accept it without first becoming + // unaffiliated. Decline it so this lifecycle keeps every user in a + // nation while still exercising the actionable ENGINE response. + response: false, + }) + : { skipped: 'already resolved' }; + const noAggression = + actionsBefore.find((action) => action.messageId === state.actionableMessages!.noAggressionId)?.status === + 'PENDING' + ? await createGame(state.users[5]!.gameToken).messages.respond.mutate({ + generalId: diplomacyActor.id, + messageId: state.actionableMessages.noAggressionId, + response: true, + }) + : { skipped: 'already resolved' }; const [targetAfter, actionsAfter, diplomacyRows, worldAfter] = await Promise.all([ db.prisma.general.findUniqueOrThrow({ where: { id: recruitmentTarget.id } }), db.prisma.messageAction.findMany({ @@ -1497,17 +1575,14 @@ const respondActionableMessages = async (): Promise => { }), db.prisma.worldState.findFirstOrThrow({ orderBy: { id: 'asc' } }), ]); - if (targetAfter.nationId !== state.actionFixture.nations[0]!.id) { - throw new Error(`Recruitment acceptance left target in nation ${targetAfter.nationId}.`); + if (targetAfter.nationId !== state.actionFixture.nations[1]!.id) { + throw new Error(`Recruitment response left target in nation ${targetAfter.nationId}.`); } if (actionsAfter.some((action) => action.status !== 'RESOLVED' || action.resolvedGameTick === null)) { throw new Error('An actionable message was not resolved with an authoritative game tick.'); } - if (worldAfter.clockTick !== worldBefore.clockTick) { - throw new Error('Game tick moved while paused actionable responses were applied.'); - } - log('paused-actionable-responses-complete', { - frozenGameTick: worldBefore.clockTick.toString(), + log('actionable-responses-complete', { + responseGameTick: worldAfter.clockTick?.toString() ?? null, recruitment, noAggression, recruitmentTarget: { @@ -1567,8 +1642,8 @@ const waitNoAggressionCancellation = async (): Promise => { const targetNation = state.actionFixture.nations[1]!; const deadline = Date.now() + Number(process.env.SAMMO_LIVE_ACTIONABLE_TIMEOUT_MS ?? '300000'); let action: - | (Awaited> & { message: { mailbox: number } }) - | null = null; + (Awaited> & { message: { mailbox: number } }) | null = + null; while (Date.now() < deadline && !action) { action = await db.prisma.messageAction.findFirst({ where: { @@ -1598,19 +1673,15 @@ const waitNoAggressionCancellation = async (): Promise => { const respondNoAggressionCancellation = async (): Promise => { const state = await readState(); - if ( - state.users?.length !== 10 || - !state.actionFixture || - !state.cancelNoAggressionMessageId - ) { + if (state.users?.length !== 10 || !state.actionFixture || !state.cancelNoAggressionMessageId) { throw new Error('Received non-aggression cancellation message is required.'); } const db = createGamePostgresConnector({ url: gameDatabaseUrl() }); await db.connect(); try { const worldBefore = await db.prisma.worldState.findFirstOrThrow({ orderBy: { id: 'asc' } }); - if (worldBefore.clockPhase !== 'SUSPENDED' || worldBefore.clockTick === null) { - throw new Error(`Paused cancellation response requires SUSPENDED, found ${worldBefore.clockPhase}.`); + if (!['RUNNING', 'MANUAL'].includes(worldBefore.clockPhase) || worldBefore.clockTick === null) { + throw new Error(`Cancellation response requires a running game clock, found ${worldBefore.clockPhase}.`); } const diplomacyActor = state.actionFixture.generals[5]!; const result = await createGame(state.users[5]!.gameToken).messages.respond.mutate({ @@ -1635,10 +1706,7 @@ const respondNoAggressionCancellation = async (): Promise => { if (diplomacyRows.some((row) => row.stateCode === 7)) { throw new Error('Accepted cancellation left a non-aggression relation active.'); } - if (worldAfter.clockTick !== worldBefore.clockTick) { - throw new Error('Game tick moved while the paused cancellation response was applied.'); - } - log('paused-no-aggression-cancellation-complete', { + log('no-aggression-cancellation-complete', { messageId: state.cancelNoAggressionMessageId, result, resolvedGameTick: action.resolvedGameTick.toString(), @@ -1648,7 +1716,7 @@ const respondNoAggressionCancellation = async (): Promise => { stateCode: row.stateCode, term: row.term, })), - frozenGameTick: worldBefore.clockTick.toString(), + responseGameTick: worldAfter.clockTick?.toString() ?? null, }); } finally { await db.disconnect(); @@ -2260,7 +2328,8 @@ else if (command === 'prepare-action-fixture') await prepareActionFixture(); else if (command === 'repair-action-item-fixture') await repairActionItemFixture(); else if (command === 'exercise-paused-actions') await exercisePausedActions(); else if (command === 'verify-paused-wall-expiry') await verifyPausedWallExpiry(); -else if (command === 'exercise-paused-tournament-bet') await exercisePausedTournamentBet(); +else if (command === 'prepare-tournament-bet') await prepareTournamentBet(); +else if (command === 'submit-paused-tournament-bet') await submitPausedTournamentBet(); else if (command === 'reserve-actionable-commands') await reserveActionableCommands(); else if (command === 'wait-actionable-messages') await waitActionableMessages(); else if (command === 'respond-actionable-messages') await respondActionableMessages(); @@ -2280,5 +2349,5 @@ else if (command === 'wait-runtime-action') await waitRuntimeAction(); else if (command === 'monitor-users') await monitorUsers(); else throw new Error( - 'usage: live-ten-user-lifecycle.ts ' + 'usage: live-ten-user-lifecycle.ts ' );