diff --git a/app/game-api/src/router/inherit/index.ts b/app/game-api/src/router/inherit/index.ts index 2af45b3b..b67d17d8 100644 --- a/app/game-api/src/router/inherit/index.ts +++ b/app/game-api/src/router/inherit/index.ts @@ -12,6 +12,7 @@ import { isWarTraitKey, } from '@sammo-ts/logic'; import type { InheritBuffType } from '@sammo-ts/logic'; +import { simpleSerialize } from '@sammo-ts/logic/war/utils.js'; import { appendInheritanceLog, buildResetCost, @@ -153,17 +154,24 @@ const buildTurnTimeZoneList = (tickMinutes: number): string[] => { return zones; }; -const alignToTurnBase = (time: Date, tickMinutes: number): Date => { - const base = new Date(time.getFullYear(), time.getMonth(), time.getDate() - 1, 1, 0, 0, 0); - const elapsedMinutes = Math.floor((time.getTime() - base.getTime()) / 60000); - const alignedMinutes = elapsedMinutes - (elapsedMinutes % tickMinutes); - return new Date(base.getTime() + alignedMinutes * 60000); +const formatTurnTimeBaseLabel = (value: number): string => { + const wholeSeconds = Math.trunc(value); + const hours = String(Math.trunc(wholeSeconds / 3600)).padStart(2, '0'); + const minutes = String(Math.trunc((wholeSeconds % 3600) / 60)).padStart(2, '0'); + return `${hours}:${minutes}`; }; -const formatTimeLabel = (value: Date): string => { - const hours = String(value.getHours()).padStart(2, '0'); - const minutes = String(value.getMinutes()).padStart(2, '0'); - return `${hours}:${minutes}`; +export const resolveResetTurnTimeBase = (options: { + hiddenSeed: string | number; + userId: string; + previousTurnTimeBase: string | number; + tickSeconds: number; +}): { nextTurnTimeBase: number; nextTurnTimeLabel: string } => { + const rng = new LiteHashDRBG( + simpleSerialize(options.hiddenSeed, 'ResetTurnTime', options.userId, options.previousTurnTimeBase) + ); + const nextTurnTimeBase = rng.nextFloat1() * Math.max(60, options.tickSeconds); + return { nextTurnTimeBase, nextTurnTimeLabel: formatTurnTimeBaseLabel(nextTurnTimeBase) }; }; const resolveSeasonValue = (meta: Record): number | null => { @@ -491,7 +499,7 @@ export const inheritRouter = router({ const worldState = await resolveWorld(ctx); const worldMeta = asRecord(worldState.meta); - if (typeof worldMeta.isUnited === 'number' && worldMeta.isUnited !== 0) { + if (asNumber(worldMeta.isunited ?? worldMeta.isUnited, 0) !== 0) { throw new TRPCError({ code: 'FORBIDDEN', message: '이미 천하가 통일되었습니다.' }); } @@ -553,7 +561,7 @@ export const inheritRouter = router({ const general = await ctx.db.general.findFirst({ where: { userId }, - select: { id: true, meta: true, turnTime: true }, + select: { id: true, meta: true, turnTick: true }, }); if (!general) { throw new TRPCError({ code: 'PRECONDITION_FAILED', message: '장수가 존재하지 않습니다.' }); @@ -568,21 +576,30 @@ export const inheritRouter = router({ throw new TRPCError({ code: 'BAD_REQUEST', message: '유산 포인트가 부족합니다.' }); } - const tickMinutes = Math.max(1, Math.round(worldState.tickSeconds / 60)); - const baseTime = alignToTurnBase(general.turnTime ?? new Date(), tickMinutes); - const seedBase = `${asRecord(worldState.meta).hiddenSeed ?? 'inherit'}:ResetTurnTime:${userId}:${general.id}`; - const rng = new LiteHashDRBG(seedBase); - const offsetMinutes = rng.nextFloat1() * tickMinutes; - let nextTurnTime = new Date(baseTime.getTime() + offsetMinutes * 60000); - if (nextTurnTime.getTime() <= Date.now()) { - nextTurnTime = new Date(nextTurnTime.getTime() + tickMinutes * 60000); - } + const generalMeta = asRecord(general.meta); + const rawSeedTurnTime = generalMeta.nextTurnTimeBase ?? general.turnTick ?? 0; + const seedTurnTime = + typeof rawSeedTurnTime === 'string' || typeof rawSeedTurnTime === 'number' + ? rawSeedTurnTime + : typeof rawSeedTurnTime === 'bigint' + ? Number(rawSeedTurnTime) + : 0; + const hiddenSeed = + typeof worldMeta.hiddenSeed === 'string' || typeof worldMeta.hiddenSeed === 'number' + ? worldMeta.hiddenSeed + : 'inherit'; + const { nextTurnTimeBase, nextTurnTimeLabel } = resolveResetTurnTimeBase({ + hiddenSeed, + userId, + previousTurnTimeBase: seedTurnTime, + tickSeconds: worldState.tickSeconds, + }); await patchGeneral(ctx, general.id, { - turnTime: nextTurnTime.toISOString(), meta: { - ...asRecord(general.meta), + ...generalMeta, inheritResetTurnTime: nextLevel, + nextTurnTimeBase, }, }); @@ -592,9 +609,9 @@ export const inheritRouter = router({ userId, worldState.currentYear, worldState.currentMonth, - `${cost} 포인트로 턴 시간을 바꾸어 다다음 턴부터 ${formatTimeLabel(nextTurnTime)} 적용` + `${cost} 포인트로 턴 시간을 바꾸어 다다음 턴부터 ${nextTurnTimeLabel} 적용` ); - return { ok: true, nextTurnTime: nextTurnTime.toISOString() }; + return { ok: true, nextTurnTimeBase, nextTurnTimeLabel }; }), resetStat: authedProcedure .input( diff --git a/app/game-api/src/router/messages/index.ts b/app/game-api/src/router/messages/index.ts index aaf5556c..8baaa5a7 100644 --- a/app/game-api/src/router/messages/index.ts +++ b/app/game-api/src/router/messages/index.ts @@ -72,10 +72,7 @@ const hasPenalty = (penalty: unknown, key: string): boolean => { return value === true || value === 1 || value === '1'; }; -const markMessageMailboxes = ( - ctx: Pick, - mailboxes: Iterable -): void => { +const markMessageMailboxes = (ctx: Pick, mailboxes: Iterable): void => { for (const mailbox of mailboxes) { ctx.changeJournal?.mark('messages.mailbox', mailbox); } @@ -328,6 +325,27 @@ export const messagesRouter = router({ ) .mutation(async ({ ctx, input }) => { const general = await getOwnedGeneral(ctx, input.generalId); + const message = await fetchMessageById(ctx.db, input.messageId); + if (!message) { + throw new TRPCError({ code: 'NOT_FOUND', message: '메시지가 없습니다.' }); + } + const action = message.payload.option?.action; + if (action === 'scout' || action === 'raiseInvader') { + if (!ctx.auth) { + throw new TRPCError({ code: 'UNAUTHORIZED' }); + } + const commandResult = await ctx.turnDaemon.requestCommand({ + type: 'messageRespond', + userId: ctx.auth.user.id, + generalId: general.id, + messageId: input.messageId, + response: input.response, + }); + if (!commandResult || commandResult.type !== 'messageRespond') { + throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: '메시지 응답 처리에 실패했습니다.' }); + } + return { result: commandResult.ok, reason: commandResult.reason }; + } const result = await respondToDiplomaticMessage({ db: ctx.db, actor: general, diff --git a/app/game-api/test/inheritResetTurnTime.test.ts b/app/game-api/test/inheritResetTurnTime.test.ts new file mode 100644 index 00000000..cb679d7b --- /dev/null +++ b/app/game-api/test/inheritResetTurnTime.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest'; + +import { resolveResetTurnTimeBase } from '../src/router/inherit/index.js'; + +describe('inherit reset turn time Ref compatibility', () => { + it('matches the Ref PHP deterministic seed, offset, and displayed minute', () => { + const result = resolveResetTurnTimeBase({ + hiddenSeed: 'hidden-seed', + userId: 'user-7', + previousTurnTimeBase: 123_456, + tickSeconds: 600, + }); + + expect(result.nextTurnTimeBase).toBeCloseTo(302.5143852464758, 12); + expect(result.nextTurnTimeLabel).toBe('00:05'); + }); + + it('uses the prior pending base as the next deterministic seed input', () => { + const first = resolveResetTurnTimeBase({ + hiddenSeed: 'hidden-seed', + userId: 'user-7', + previousTurnTimeBase: 123_456, + tickSeconds: 600, + }); + const second = resolveResetTurnTimeBase({ + hiddenSeed: 'hidden-seed', + userId: 'user-7', + previousTurnTimeBase: first.nextTurnTimeBase, + tickSeconds: 600, + }); + + expect(second.nextTurnTimeBase).not.toBe(first.nextTurnTimeBase); + expect(second.nextTurnTimeBase).toBeGreaterThanOrEqual(0); + expect(second.nextTurnTimeBase).toBeLessThan(600); + }); +}); diff --git a/app/game-api/test/inheritRouter.test.ts b/app/game-api/test/inheritRouter.test.ts index ab9f8948..4235177b 100644 --- a/app/game-api/test/inheritRouter.test.ts +++ b/app/game-api/test/inheritRouter.test.ts @@ -8,6 +8,7 @@ import { InMemoryFlushStore } from '../src/auth/flushStore.js'; import type { DatabaseClient, GameApiContext, GeneralRow } from '../src/context.js'; import type { TurnDaemonTransport } from '../src/daemon/transport.js'; import { appRouter } from '../src/router.js'; +import { resolveResetTurnTimeBase } from '../src/router/inherit/index.js'; const buildGeneral = (overrides: Partial = {}): GeneralRow => ({ id: 7, @@ -278,6 +279,43 @@ describe('inherit router actor and permission boundaries', () => { ); }); + it('queues Ref-compatible nextTurnTimeBase without moving the current scheduled turn', async () => { + const fixture = buildContext({ + inheritancePoint: 2_000, + general: buildGeneral({ meta: { nextTurnTimeBase: 123_456 } }), + }); + const expected = resolveResetTurnTimeBase({ + hiddenSeed: 'test-seed', + userId: 'user-1', + previousTurnTimeBase: 123_456, + tickSeconds: worldState.tickSeconds, + }); + + await expect(appRouter.createCaller(fixture.context).inherit.resetTurnTime()).resolves.toEqual({ + ok: true, + ...expected, + }); + expect(fixture.requestCommand).toHaveBeenCalledWith({ + type: 'patchGeneral', + generalId: 7, + patch: { + meta: { + nextTurnTimeBase: expected.nextTurnTimeBase, + inheritResetTurnTime: 0, + }, + }, + }); + expect(fixture.pointUpsert).toHaveBeenCalledWith(expect.objectContaining({ update: { value: 1_000 } })); + expect(fixture.logCreate).toHaveBeenCalledWith({ + data: { + userId: 'user-1', + year: 200, + month: 4, + text: `1000 포인트로 턴 시간을 바꾸어 다다음 턴부터 ${expected.nextTurnTimeLabel} 적용`, + }, + }); + }); + it('reveals a target owner to the caller without using the caller general id from input', async () => { const fixture = buildContext({ inheritancePoint: 1500 }); diff --git a/app/game-api/test/messagesRouter.test.ts b/app/game-api/test/messagesRouter.test.ts index 5e46da41..3572cc13 100644 --- a/app/game-api/test/messagesRouter.test.ts +++ b/app/game-api/test/messagesRouter.test.ts @@ -565,6 +565,70 @@ describe('messages router missing-flow compatibility', () => { }); }); + it.each(['scout', 'raiseInvader'] as const)( + 'dispatches a private %s response through the durable engine command', + async (action) => { + const messageRow = { + id: 29, + mailbox: general.id, + type: 'private', + src: 8, + dest: general.id, + time: new Date(), + valid_until: new Date('9999-12-31T00:00:00Z'), + message: { + src: { + generalId: 8, + generalName: '제안자', + nationId: 2, + nationName: '촉', + color: '#000', + icon: '', + }, + dest: { + generalId: general.id, + generalName: general.name, + nationId: general.nationId, + nationName: '위', + color: '#fff', + icon: '', + }, + text: '응답할 메시지', + option: { action }, + }, + }; + const requestCommand = vi.fn(async () => ({ + type: 'messageRespond' as const, + ok: true, + generalId: general.id, + messageId: messageRow.id, + action, + reason: 'success', + })); + const { caller } = buildContext( + { $queryRaw: vi.fn(async () => [messageRow]) }, + { turnDaemon: { requestCommand } } + ); + + const result = await caller.messages.respond({ + generalId: general.id, + messageId: messageRow.id, + response: true, + }); + + expect(result).toEqual({ result: true, reason: 'success' }); + expect(requestCommand).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'messageRespond', + userId: auth.user.id, + generalId: general.id, + messageId: messageRow.id, + response: true, + }) + ); + } + ); + const buildDiplomaticContext = (options?: { action?: 'noAggression' | 'cancelNA' | 'stopWar'; actorNationId?: number; @@ -634,10 +698,10 @@ describe('messages router missing-flow compatibility', () => { let insertedId = 100; const queryRaw = vi.fn(async () => { rawCall += 1; - if (rawCall === 1) { + if (rawCall <= 2) { return [messageRow]; } - if (rawCall >= 4) { + if (rawCall >= 5) { insertedId += 1; return [{ id: insertedId }]; } @@ -693,84 +757,87 @@ describe('messages router missing-flow compatibility', () => { const messageUpdateMany = vi.fn(async () => ({ count: 1 })); const cityUpdate = vi.fn(async () => ({})); const changeJournal = new ChangeJournal(); - const { caller } = buildContext({ - general: { - findUnique: vi.fn(async ({ where }: { where: { id: number } }) => - where.id === actor.id ? actor : where.id === proposer.id ? proposer : null - ), - findMany: vi.fn(async () => []), + const { caller } = buildContext( + { + general: { + findUnique: vi.fn(async ({ where }: { where: { id: number } }) => + where.id === actor.id ? actor : where.id === proposer.id ? proposer : null + ), + findMany: vi.fn(async () => []), + }, + nation: { + findUnique: vi.fn(async ({ where }: { where: { id: number } }) => + where.id === actorNationId + ? { + id: actorNationId, + name: '위', + color: '#fff', + capitalCityId: 10, + chiefGeneralId: actor.id, + gold: 1000, + rice: 1000, + tech: 0, + level: 1, + typeCode: 'test', + meta: {}, + } + : where.id === proposerNationId + ? { + id: proposerNationId, + name: '촉', + color: '#000', + capitalCityId: 20, + chiefGeneralId: proposer.id, + gold: 1000, + rice: 1000, + tech: 0, + level: 1, + typeCode: 'test', + meta: { recv_assist: { [`n${actorNationId}`]: [actorNationId, 50] } }, + } + : null + ), + findMany: vi.fn(async () => []), + update: nationUpdate, + }, + city: { + findUnique: vi.fn(async () => ({ + id: 10, + nationId: actorNationId, + supplyState: 1, + })), + findMany: vi.fn(async () => options?.cities ?? []), + update: cityUpdate, + }, + diplomacy: { + findUnique: vi.fn( + async ({ + where, + }: { + where: { srcNationId_destNationId: { srcNationId: number; destNationId: number } }; + }) => + diplomacyRows.find( + (row) => + row.srcNationId === where.srcNationId_destNationId.srcNationId && + row.destNationId === where.srcNationId_destNationId.destNationId + ) ?? null + ), + findMany: vi.fn(async () => diplomacyRows), + update: diplomacyUpdate, + }, + worldState: { + findFirst: vi.fn(async () => ({ + currentYear: 200, + currentMonth: 3, + config: { environment: { mapName: 'che' } }, + })), + }, + logEntry: { createMany: logCreateMany }, + message: { updateMany: messageUpdateMany }, + $queryRaw: queryRaw, }, - nation: { - findUnique: vi.fn(async ({ where }: { where: { id: number } }) => - where.id === actorNationId - ? { - id: actorNationId, - name: '위', - color: '#fff', - capitalCityId: 10, - chiefGeneralId: actor.id, - gold: 1000, - rice: 1000, - tech: 0, - level: 1, - typeCode: 'test', - meta: {}, - } - : where.id === proposerNationId - ? { - id: proposerNationId, - name: '촉', - color: '#000', - capitalCityId: 20, - chiefGeneralId: proposer.id, - gold: 1000, - rice: 1000, - tech: 0, - level: 1, - typeCode: 'test', - meta: { recv_assist: { [`n${actorNationId}`]: [actorNationId, 50] } }, - } - : null - ), - findMany: vi.fn(async () => []), - update: nationUpdate, - }, - city: { - findUnique: vi.fn(async () => ({ - id: 10, - nationId: actorNationId, - supplyState: 1, - })), - findMany: vi.fn(async () => options?.cities ?? []), - update: cityUpdate, - }, - diplomacy: { - findUnique: vi.fn( - async ({ - where, - }: { - where: { srcNationId_destNationId: { srcNationId: number; destNationId: number } }; - }) => - diplomacyRows.find( - (row) => - row.srcNationId === where.srcNationId_destNationId.srcNationId && - row.destNationId === where.srcNationId_destNationId.destNationId - ) ?? null - ), - findMany: vi.fn(async () => diplomacyRows), - update: diplomacyUpdate, - }, - worldState: { - findFirst: vi.fn(async () => ({ - currentYear: 200, - currentMonth: 3, - config: { environment: { mapName: 'che' } }, - })), - }, - logEntry: { createMany: logCreateMany }, - message: { updateMany: messageUpdateMany }, - $queryRaw: queryRaw, - }, { changeJournal }); + { changeJournal } + ); return { caller, actor, @@ -811,7 +878,7 @@ describe('messages router missing-flow compatibility', () => { where: { id: { in: [31] } }, data: { validUntil: expect.any(Date) }, }); - expect(setup.queryRaw).toHaveBeenCalledTimes(8); + expect(setup.queryRaw).toHaveBeenCalledTimes(9); }); it('declines a diplomatic prompt without changing diplomacy', async () => { diff --git a/app/game-engine/src/turn/actionableMessageResponse.ts b/app/game-engine/src/turn/actionableMessageResponse.ts new file mode 100644 index 00000000..542de5c1 --- /dev/null +++ b/app/game-engine/src/turn/actionableMessageResponse.ts @@ -0,0 +1,291 @@ +import { asNumber, asRecord, JosaUtil, LiteHashDRBG, RandUtil } from '@sammo-ts/common'; +import { GamePrisma } from '@sammo-ts/infra'; +import { LogCategory, LogFormat, LogScope, type MessageDraft, type MessagePayload } from '@sammo-ts/logic'; + +import type { InMemoryTurnWorld } from './inMemoryWorld.js'; +import { createRaiseInvaderHandler } from './monthlyInvaderAction.js'; +import type { ImmediateGeneralActionExecutor } from './reservedTurnHandler.js'; +import { buildCommandEnv } from './reservedTurnCommands.js'; +import type { InMemoryReservedTurnStore } from './reservedTurnStore.js'; +import type { TurnEvent } from './types.js'; + +type ActionableMessageType = 'scout' | 'raiseInvader'; + +interface MessageRow { + id: number; + mailbox: number; + type: string; + validUntil: Date; + message: unknown; +} + +export interface ActionableMessageResponseResult { + ok: boolean; + action?: ActionableMessageType; + reason: string; +} + +const parsePayload = (value: unknown): MessagePayload => + (typeof value === 'string' ? JSON.parse(value) : value) as MessagePayload; + +const systemTarget: MessageDraft['src'] = { + generalId: 0, + generalName: '', + nationId: 0, + nationName: 'System', + color: '#000000', + icon: '', +}; + +const queuePrivateNotice = ( + world: InMemoryTurnWorld, + destination: MessagePayload['dest'], + text: string, + time: Date +): void => { + world.queueMessage({ + msgType: 'private', + src: systemTarget, + dest: destination, + text, + time, + validUntil: new Date('9999-12-31T00:00:00.000Z'), + option: {}, + sendDestOnly: true, + }); +}; + +const invalidateMessageIds = async ( + db: GamePrisma.TransactionClient, + world: InMemoryTurnWorld, + ids: number[], + now: Date +): Promise => { + const uniqueIds = [...new Set(ids.filter((id) => Number.isInteger(id) && id > 0))]; + if (uniqueIds.length === 0) return; + await db.message.updateMany({ + where: { id: { in: uniqueIds } }, + data: { + validUntil: now, + validUntilTick: BigInt(world.dateToGameTick(now)), + }, + }); +}; + +const validateActor = async (options: { + db: GamePrisma.TransactionClient; + world: InMemoryTurnWorld; + requestId?: string; + userId: string; + generalId: number; +}): Promise => { + const actor = options.world.getGeneralById(options.generalId); + if (!actor || actor.userId !== options.userId) { + throw new Error('messageRespond general owner does not match command user.'); + } + if (!options.requestId) return new Date(); + const event = await options.db.inputEvent.findUnique({ + where: { requestId: options.requestId }, + select: { actorUserId: true, target: true, eventType: true, createdAt: true }, + }); + if (!event) throw new Error(`ENGINE input event ${options.requestId} is missing.`); + if (event.actorUserId !== options.userId || event.target !== 'ENGINE' || event.eventType !== 'messageRespond') { + throw new Error('ENGINE input event actor or type does not match messageRespond.'); + } + return event.createdAt; +}; + +const fetchMessageForUpdate = async ( + db: GamePrisma.TransactionClient, + world: InMemoryTurnWorld, + messageId: number, + now: Date +): Promise => { + const currentTick = BigInt(world.dateToGameTick(now)); + const rows = await db.$queryRaw(GamePrisma.sql` + SELECT id, mailbox, type, valid_until AS "validUntil", message + FROM message + WHERE id = ${messageId} + AND ( + (valid_until_tick IS NOT NULL AND valid_until_tick > ${currentTick}) + OR (valid_until_tick IS NULL AND valid_until > ${now}) + ) + LIMIT 1 + FOR UPDATE + `); + return rows[0] ?? null; +}; + +const respondToScout = async (options: { + db: GamePrisma.TransactionClient; + world: InMemoryTurnWorld; + executor: ImmediateGeneralActionExecutor; + actorId: number; + response: boolean; + row: MessageRow; + payload: MessagePayload; + now: Date; +}): Promise => { + const { db, world, executor, actorId, response, row, payload, now } = options; + if (row.type !== 'private' || row.mailbox !== actorId || payload.dest.generalId !== actorId) { + return { ok: false, action: 'scout', reason: '올바른 수신자가 아닙니다.' }; + } + if (asRecord(payload.option).used === true) { + return { ok: false, action: 'scout', reason: '유효하지 않은 등용장입니다.' }; + } + + const sourceNationName = payload.src.nationName; + const sourceNationJosaRo = JosaUtil.pick(sourceNationName, '로'); + if (response) { + const execution = await executor.execute({ + actionKey: 'che_등용수락', + generalId: actorId, + args: { + destNationId: payload.src.nationId, + destGeneralId: payload.src.generalId, + }, + rng: new RandUtil(new LiteHashDRBG(`messageRespond:scout:${row.id}`)), + }); + if (!execution.ok) { + return { ok: true, action: 'scout', reason: execution.reason ?? '등용 수락 불가.' }; + } + + const otherRows = await db.$queryRaw>(GamePrisma.sql` + SELECT id + FROM message + WHERE mailbox = ${payload.src.generalId} + AND type = 'private' + AND dest = mailbox + AND id <> ${row.id} + AND ( + (valid_until_tick IS NOT NULL AND valid_until_tick > ${BigInt(world.dateToGameTick(now))}) + OR (valid_until_tick IS NULL AND valid_until > ${now}) + ) + AND message->'option'->>'action' = 'scout' + FOR UPDATE + `); + await invalidateMessageIds(db, world, [row.id, ...otherRows.map(({ id }) => id)], now); + world.queueMessage({ + msgType: 'private', + src: payload.src, + dest: payload.dest, + text: `${sourceNationName}${sourceNationJosaRo} 등용 제의 수락`, + time: now, + validUntil: new Date('9999-12-31T00:00:00.000Z'), + option: { delete: row.id }, + sendDestOnly: true, + }); + return { ok: true, action: 'scout', reason: 'success' }; + } + + const destinationJosaYi = JosaUtil.pick(payload.dest.generalName, '이'); + world.pushLog({ + scope: LogScope.GENERAL, + category: LogCategory.ACTION, + format: LogFormat.PLAIN, + generalId: actorId, + text: `${sourceNationName}${sourceNationJosaRo} 망명을 거부했습니다.`, + }); + world.pushLog({ + scope: LogScope.GENERAL, + category: LogCategory.ACTION, + format: LogFormat.PLAIN, + generalId: payload.src.generalId, + text: `${payload.dest.generalName}${destinationJosaYi} 등용을 거부했습니다.`, + }); + await invalidateMessageIds(db, world, [row.id], now); + world.queueMessage({ + msgType: 'private', + src: payload.src, + dest: payload.dest, + text: `${sourceNationName}${sourceNationJosaRo} 등용 제의 거부`, + time: now, + validUntil: new Date('9999-12-31T00:00:00.000Z'), + option: { delete: row.id }, + sendDestOnly: true, + }); + return { ok: true, action: 'scout', reason: 'success' }; +}; + +const respondToRaiseInvader = async (options: { + db: GamePrisma.TransactionClient; + world: InMemoryTurnWorld; + reservedTurns?: InMemoryReservedTurnStore; + actorId: number; + response: boolean; + row: MessageRow; + payload: MessagePayload; + now: Date; + loadArchivedNationMaxId?: (serverId: string) => Promise; +}): Promise => { + const { db, world, reservedTurns, actorId, response, row, payload, now } = options; + if (row.type !== 'private' || row.mailbox !== actorId || payload.dest.generalId !== actorId) { + return { ok: false, action: 'raiseInvader', reason: '올바른 수신자가 아닙니다.' }; + } + if (asRecord(payload.option).used === true) { + return { ok: false, action: 'raiseInvader', reason: '이미 사용하였습니다.' }; + } + if (!response) { + await invalidateMessageIds(db, world, [row.id], now); + return { ok: true, action: 'raiseInvader', reason: 'success' }; + } + const state = world.getState(); + if (asNumber(state.meta.isunited ?? state.meta.isUnited, 0) !== 2) { + const reason = '천하통일이 되지 않았습니다.'; + queuePrivateNotice(world, payload.dest, `${reason} 이민족 등장 불가.`, now); + return { ok: false, action: 'raiseInvader', reason }; + } + if (!reservedTurns) { + throw new Error('RaiseInvader message response requires the reserved-turn store.'); + } + const args = asRecord(payload.option).args; + if (!Array.isArray(args) || args.length !== 4 || args.some((value) => typeof value !== 'number')) { + return { ok: false, action: 'raiseInvader', reason: '이민족 소환 인자가 올바르지 않습니다.' }; + } + const handler = createRaiseInvaderHandler({ + getWorld: () => world, + reservedTurns, + env: buildCommandEnv(world.getScenarioConfig(), world.getUnitSet()), + loadArchivedNationMaxId: options.loadArchivedNationMaxId, + }); + const event: TurnEvent = { id: 0, targetCode: 'month', priority: 0, condition: true, action: [], meta: {} }; + await handler( + args, + { + year: state.currentYear, + month: state.currentMonth, + startyear: asNumber(state.meta.startYear, state.currentYear), + currentEventID: 0, + turnTime: now, + }, + event + ); + return { ok: true, action: 'raiseInvader', reason: 'success' }; +}; + +export const respondToActionableMessage = async (options: { + db: GamePrisma.TransactionClient; + world: InMemoryTurnWorld; + reservedTurns?: InMemoryReservedTurnStore; + executor: ImmediateGeneralActionExecutor; + requestId?: string; + userId: string; + generalId: number; + messageId: number; + response: boolean; + loadArchivedNationMaxId?: (serverId: string) => Promise; +}): Promise => { + const acceptedAt = await validateActor(options); + const now = options.world.getGameNow(acceptedAt); + const row = await fetchMessageForUpdate(options.db, options.world, options.messageId, now); + if (!row) return { ok: false, reason: '존재하지 않는 메시지입니다.' }; + const payload = parsePayload(row.message); + const action = asRecord(payload.option).action; + if (action === 'scout') { + return await respondToScout({ ...options, actorId: options.generalId, row, payload, now }); + } + if (action === 'raiseInvader') { + return await respondToRaiseInvader({ ...options, actorId: options.generalId, row, payload, now }); + } + return { ok: false, reason: '응답할 수 없는 메시지입니다.' }; +}; diff --git a/app/game-engine/src/turn/commandRegistry.ts b/app/game-engine/src/turn/commandRegistry.ts index 74cc5efc..2d218bb6 100644 --- a/app/game-engine/src/turn/commandRegistry.ts +++ b/app/game-engine/src/turn/commandRegistry.ts @@ -111,6 +111,14 @@ const zInstantRetreat = z.object({ generalId: zFiniteNumber, }); +const zMessageRespond = z.object({ + type: z.literal('messageRespond'), + userId: z.string().min(1), + generalId: z.number().int().positive(), + messageId: z.number().int().positive(), + response: z.boolean(), +}); + const zVacation = z.object({ type: z.literal('vacation'), generalId: zFiniteNumber, @@ -488,6 +496,14 @@ const normalizeInstantRetreat: CommandNormalizer<'instantRetreat'> = (envelope) return { ...command, requestId: envelope.requestId }; }; +const normalizeMessageRespond: CommandNormalizer<'messageRespond'> = (envelope) => { + const command = parseWith(zMessageRespond, envelope.command); + if (!command) { + return null; + } + return { ...command, requestId: envelope.requestId }; +}; + const normalizeVacation: CommandNormalizer<'vacation'> = (envelope) => { const command = parseWith(zVacation, envelope.command); if (!command) { @@ -705,6 +721,7 @@ const normalizers: CommandNormalizerMap = { ensureDieOnPrestartStatus: normalizeEnsureDieOnPrestartStatus, buildNationCandidate: normalizeBuildNationCandidate, instantRetreat: normalizeInstantRetreat, + messageRespond: normalizeMessageRespond, vacation: normalizeVacation, setMySetting: normalizeSetMySetting, dropItem: normalizeDropItem, diff --git a/app/game-engine/src/turn/databaseHooks.ts b/app/game-engine/src/turn/databaseHooks.ts index c43874cb..c80b3124 100644 --- a/app/game-engine/src/turn/databaseHooks.ts +++ b/app/game-engine/src/turn/databaseHooks.ts @@ -30,6 +30,7 @@ import { import { asRecord, ChangeJournal, + GAME_TICKS_PER_TURN, type CommittedReadModelInvalidation, type ReadModelDomain, type RealtimeReadModelChanges, @@ -1037,6 +1038,7 @@ export const createDatabaseTurnHooks = async ( const transactionOptions = { timeout: options?.transactionTimeoutMs ?? 30_000 }; const readModelBaseline = createRealtimeReadModelBaseline(world); let worldReadModelBaseline = createWorldReadModelSignature(world); + let persistedTickSeconds = world.getState().tickSeconds; const committedReceipts = new Map(); const enqueueCommittedReceipt = ( @@ -1159,6 +1161,54 @@ export const createDatabaseTurnHooks = async ( data: worldStateUpdate, }); + if ( + state.tickSeconds !== persistedTickSeconds && + commandCompletion?.result.type !== 'updateRuntimeSettings' + ) { + const ticksPerSecond = BigInt(GAME_TICKS_PER_TURN / state.tickSeconds); + const baseTime = state.clockBaseTime ?? state.lastTurnTime; + await prisma.$executeRaw(GamePrisma.sql` + UPDATE auction + SET close_at = CAST(${baseTime} AS timestamp) + + (close_tick / ${ticksPerSecond}) * INTERVAL '1 second' + + (((close_tick % ${ticksPerSecond}) * 1000) / ${ticksPerSecond}) * INTERVAL '1 millisecond', + updated_at = NOW() + WHERE close_tick IS NOT NULL + `); + await prisma.$executeRaw(GamePrisma.sql` + UPDATE message + SET time = CASE + WHEN time_tick IS NULL THEN time + ELSE CAST(${baseTime} AS timestamp) + + (time_tick / ${ticksPerSecond}) * INTERVAL '1 second' + + (((time_tick % ${ticksPerSecond}) * 1000) / ${ticksPerSecond}) * INTERVAL '1 millisecond' + END, + valid_until = CASE + WHEN valid_until_tick IS NULL THEN valid_until + ELSE CAST(${baseTime} AS timestamp) + + (valid_until_tick / ${ticksPerSecond}) * INTERVAL '1 second' + + (((valid_until_tick % ${ticksPerSecond}) * 1000) / ${ticksPerSecond}) * INTERVAL '1 millisecond' + END + WHERE time_tick IS NOT NULL OR valid_until_tick IS NOT NULL + `); + await prisma.$executeRaw(GamePrisma.sql` + UPDATE vote_poll + SET start_at = CASE + WHEN start_tick IS NULL THEN start_at + ELSE CAST(${baseTime} AS timestamp) + + (start_tick / ${ticksPerSecond}) * INTERVAL '1 second' + + (((start_tick % ${ticksPerSecond}) * 1000) / ${ticksPerSecond}) * INTERVAL '1 millisecond' + END, + end_at = CASE + WHEN end_tick IS NULL THEN end_at + ELSE CAST(${baseTime} AS timestamp) + + (end_tick / ${ticksPerSecond}) * INTERVAL '1 second' + + (((end_tick % ${ticksPerSecond}) * 1000) / ${ticksPerSecond}) * INTERVAL '1 millisecond' + END + WHERE start_tick IS NOT NULL OR end_tick IS NOT NULL + `); + } + for (const betting of pendingNationBettingOpens) { await persistNationBettingOpen(prisma, betting); } @@ -1530,7 +1580,8 @@ export const createDatabaseTurnHooks = async ( return id; }, }, - message + message, + { sendDestOnly: message.sendDestOnly } ); } if (options?.reservedTurns && persistedReservedTurnChanges) { @@ -1598,6 +1649,7 @@ export const createDatabaseTurnHooks = async ( } applyRealtimeReadModelBaseline(readModelBaseline, changes); worldReadModelBaseline = persisted.worldReadModelSignature; + persistedTickSeconds = state.tickSeconds; }, readModelChanges: persisted.readModelChanges, journalWrite: persisted.journalWrite, diff --git a/app/game-engine/src/turn/inMemoryTurnProcessor.ts b/app/game-engine/src/turn/inMemoryTurnProcessor.ts index ece475c9..6ba01021 100644 --- a/app/game-engine/src/turn/inMemoryTurnProcessor.ts +++ b/app/game-engine/src/turn/inMemoryTurnProcessor.ts @@ -27,7 +27,9 @@ const resolveTickMinutes = (world: InMemoryTurnWorld, override?: number): number const isWorldUnited = (world: InMemoryTurnWorld): boolean => { const meta = asRecord(world.getState().meta); - return asNumber(meta.isunited ?? meta.isUnited, 0) !== 0; + // Ref keeps the event game running at isunited=1. Only the post-unification + // choice wait (2) and the completed invader game (3) stop month progress. + return asNumber(meta.isunited ?? meta.isUnited, 0) >= 2; }; export class InMemoryTurnProcessor implements TurnProcessor { diff --git a/app/game-engine/src/turn/reservedTurnHandler.ts b/app/game-engine/src/turn/reservedTurnHandler.ts index ea21c4a5..aca2f06b 100644 --- a/app/game-engine/src/turn/reservedTurnHandler.ts +++ b/app/game-engine/src/turn/reservedTurnHandler.ts @@ -2149,13 +2149,14 @@ export const createReservedTurnHandler = async (options: { }; }; -export type ImmediateGeneralActionKey = 'che_거병' | 'che_접경귀환'; +export type ImmediateGeneralActionKey = 'che_거병' | 'che_접경귀환' | 'che_등용수락'; export type ImmediateGeneralActionExecutor = { execute(input: { actionKey: ImmediateGeneralActionKey; generalId: number; rng: RandUtil; + args?: Record; refreshKillturn?: boolean; }): Promise<{ ok: boolean; reason?: string }>; }; @@ -2182,7 +2183,7 @@ export const createImmediateGeneralActionExecutor = async (options: { }); const generalModuleLoader = new GeneralTurnCommandLoader(); const contextBuilders = new Map(); - for (const actionKey of ['che_거병', 'che_접경귀환'] as const) { + for (const actionKey of ['che_거병', 'che_접경귀환', 'che_등용수락'] as const) { if (!definitions.has(actionKey)) { continue; } @@ -2202,10 +2203,7 @@ export const createImmediateGeneralActionExecutor = async (options: { if (!definition) { return { ok: false, - reason: - input.actionKey === 'che_거병' - ? '거병할 수 없는 모드입니다.' - : '접경귀환을 사용할 수 없는 모드입니다.', + reason: `${input.actionKey}을 실행할 수 없는 모드입니다.`, }; } @@ -2215,7 +2213,7 @@ export const createImmediateGeneralActionExecutor = async (options: { } const city = options.world.getCityById(general.cityId) ?? undefined; const nation = general.nationId > 0 ? options.world.getNationById(general.nationId) : null; - const args = definition.parseArgs({}); + const args = definition.parseArgs(input.args ?? {}); if (args === null) { return { ok: false, reason: '인자가 올바르지 않습니다.' }; } @@ -2246,7 +2244,7 @@ export const createImmediateGeneralActionExecutor = async (options: { const failureText = definition.formatConstraintFailure?.(reason, constraintCtx, args, view) ?? `${reason} ${definition.name} 실패.`; - if (input.actionKey === 'che_접경귀환') { + if (input.actionKey === 'che_접경귀환' || input.actionKey === 'che_등용수락') { options.world.pushLog({ ...createActionLog(failureText), generalId: general.id, @@ -2334,23 +2332,34 @@ export const createImmediateGeneralActionExecutor = async (options: { const progressionLogs: LogEntryDraft[] = []; let nextGeneral = resolution.general as TurnGeneral; - if (input.actionKey === 'che_거병') { - const activeActionAmount = - ( - definition as GeneralActionDefinition & { - getInheritanceActiveActionAmount?: (context: ActionContextBase, args: unknown) => number; - } - ).getInheritanceActiveActionAmount?.(actionContext, args) ?? 0; - const nextMeta = { - ...nextGeneral.meta, - inherit_active_action: - readMetaNumber(asRecord(nextGeneral.meta), 'inherit_active_action', 0) + activeActionAmount, - ...(input.refreshKillturn ? { killturn: readMetaNumber(asRecord(state.meta), 'killturn', 0) } : {}), + const activeActionAmount = + ( + definition as GeneralActionDefinition & { + getInheritanceActiveActionAmount?: (context: ActionContextBase, args: unknown) => number; + } + ).getInheritanceActiveActionAmount?.(actionContext, args) ?? 0; + if ( + Number.isFinite(activeActionAmount) && + activeActionAmount !== 0 && + nextGeneral.userId && + nextGeneral.npcState < 2 + ) { + nextGeneral = { + ...nextGeneral, + meta: { + ...nextGeneral.meta, + inherit_active_action: + readMetaNumber(asRecord(nextGeneral.meta), 'inherit_active_action', 0) + activeActionAmount, + ...(input.refreshKillturn + ? { killturn: readMetaNumber(asRecord(state.meta), 'killturn', 0) } + : {}), + }, }; + } + if (input.actionKey === 'che_거병') { nextGeneral = applyLegacyGeneralProgression( { ...nextGeneral, - meta: nextMeta, lastTurn: { command: definition.name, arg: extractArgsRecord(args), @@ -2396,6 +2405,9 @@ export const createImmediateGeneralActionExecutor = async (options: { options.world.queueMessage(effect.draft); } } + for (const troopId of resolution.deletedTroopIds ?? []) { + options.world.removeTroop(troopId); + } for (const log of [...resolution.logs, ...progressionLogs]) { options.world.pushLog(log); } diff --git a/app/game-engine/src/turn/turnDaemon.ts b/app/game-engine/src/turn/turnDaemon.ts index ada29801..09d76b59 100644 --- a/app/game-engine/src/turn/turnDaemon.ts +++ b/app/game-engine/src/turn/turnDaemon.ts @@ -958,6 +958,7 @@ const createTurnDaemonRuntimeWithLease = async ( auctionFinalizer: auctionFinalizer ?? undefined, auctionBidder: auctionBidder ?? undefined, tournamentRewardFinalizer: tournamentRewardFinalizer ?? undefined, + loadArchivedNationMaxId: (serverId) => loadArchivedNationMaxId(options.databaseUrl, serverId), }); const defaultBudget: TurnRunBudget = options.defaultBudget ?? { diff --git a/app/game-engine/src/turn/unificationHandler.ts b/app/game-engine/src/turn/unificationHandler.ts index 6372ab07..f12b2e63 100644 --- a/app/game-engine/src/turn/unificationHandler.ts +++ b/app/game-engine/src/turn/unificationHandler.ts @@ -6,6 +6,11 @@ import type { PendingUnificationAuctionCancellation } from './types.js'; import { queueYearbookSnapshot } from './yearbookHandler.js'; const UNIFIER_POINT = 2000; +const INVADER_MESSAGE_OPTIONS = [ + { args: [-2, -1.2, 15_000, -1], difficulty: '어려움' }, + { args: [-2, -1.2, -1, -0.5], difficulty: '보통' }, + { args: [-1, -1, -0.8, 0], difficulty: '쉬움' }, +] as const; const buildUnificationLog = (nationName: string): LogEntryDraft => ({ scope: LogScope.SYSTEM, @@ -118,6 +123,47 @@ export const createUnificationHandler = (options: { } world.pushLog(buildUnificationLog(winner.name)); + if (cities.some((city) => city.level === 4)) { + const eligibleGenerals = world + .listGenerals() + .filter( + (general) => Boolean(general.userId) && general.nationId === winner.id && general.npcState < 2 + ) + .sort((left, right) => left.id - right.id); + const recipients: (typeof eligibleGenerals)[number][] = []; + for (let officerLevel = 12; officerLevel >= 5 && recipients.length < 2; officerLevel -= 1) { + const recipient = eligibleGenerals.find((general) => general.officerLevel === officerLevel); + if (recipient) recipients.push(recipient); + } + for (const recipient of recipients) { + for (const invader of INVADER_MESSAGE_OPTIONS) { + world.queueMessage({ + msgType: 'private', + src: { + generalId: 0, + generalName: '', + nationId: 0, + nationName: 'System', + color: '#000000', + icon: '', + }, + dest: { + generalId: recipient.id, + generalName: recipient.name, + nationId: winner.id, + nationName: winner.name, + color: winner.color, + icon: recipient.picture ?? '', + }, + text: `이벤트 게임으로 이민족[${invader.difficulty}]을 소환`, + time: context.turnTime, + validUntil: new Date('9999-12-31T00:00:00.000Z'), + option: { action: 'raiseInvader', args: [...invader.args], used: false }, + }); + } + } + } + queueYearbookSnapshot(world, options.profileName, context.currentYear, context.currentMonth); world.queueUnificationFinalization({ generationKey: `unification:${serverId}`, diff --git a/app/game-engine/src/turn/worldCommandHandler.ts b/app/game-engine/src/turn/worldCommandHandler.ts index cf45cfc1..54e24035 100644 --- a/app/game-engine/src/turn/worldCommandHandler.ts +++ b/app/game-engine/src/turn/worldCommandHandler.ts @@ -60,6 +60,7 @@ import { import { createGeneralFromJoin, JoinCreateGeneralError } from './joinCreateGeneralService.js'; import { NpcPossessionError, possessNpcGeneral } from './npcPossessionService.js'; import { buildPrestartDeleteAfter, formatPrestartDeleteAfter, readPrestartDeleteAfter } from './prestartDeletion.js'; +import { respondToActionableMessage } from './actionableMessageResponse.js'; let itemRegistryPromise: Promise> | null = null; @@ -136,6 +137,8 @@ interface CommandHandlerContext { auctionBidder?: AuctionBidder; tournamentRewardFinalizer?: TournamentRewardFinalizer; getImmediateGeneralActionExecutor?: () => Promise; + reservedTurns?: InMemoryReservedTurnStore; + loadArchivedNationMaxId?: (serverId: string) => Promise; } const requireCommandDatabase = (ctx: CommandHandlerContext): DatabaseClient => { @@ -1604,6 +1607,35 @@ async function handleInstantRetreat( }; } +async function handleMessageRespond( + ctx: CommandHandlerContext, + command: Extract +): Promise { + if (!ctx.getImmediateGeneralActionExecutor) { + throw new Error('Immediate general action runtime is not configured.'); + } + const result = await respondToActionableMessage({ + db: requireCommandDatabase(ctx) as GamePrisma.TransactionClient, + world: ctx.world, + reservedTurns: ctx.reservedTurns, + executor: await ctx.getImmediateGeneralActionExecutor(), + requestId: command.requestId, + userId: command.userId, + generalId: command.generalId, + messageId: command.messageId, + response: command.response, + loadArchivedNationMaxId: ctx.loadArchivedNationMaxId, + }); + return { + type: 'messageRespond', + ok: result.ok, + generalId: command.generalId, + messageId: command.messageId, + ...(result.action ? { action: result.action } : {}), + reason: result.reason, + }; +} + async function handleVacation( ctx: CommandHandlerContext, command: Extract @@ -2532,6 +2564,7 @@ export const createTurnDaemonCommandHandler = (options: { auctionFinalizer?: AuctionFinalizer; auctionBidder?: AuctionBidder; tournamentRewardFinalizer?: TournamentRewardFinalizer; + loadArchivedNationMaxId?: (serverId: string) => Promise; }): TurnDaemonCommandHandler => { let immediateGeneralActionExecutor: Promise | null = null; const ctx: CommandHandlerContext = { @@ -2539,6 +2572,8 @@ export const createTurnDaemonCommandHandler = (options: { auctionFinalizer: options.auctionFinalizer, auctionBidder: options.auctionBidder, tournamentRewardFinalizer: options.tournamentRewardFinalizer, + reservedTurns: options.reservedTurns, + loadArchivedNationMaxId: options.loadArchivedNationMaxId, getImmediateGeneralActionExecutor: () => { immediateGeneralActionExecutor ??= createImmediateGeneralActionExecutor({ world: options.world, @@ -2588,6 +2623,8 @@ export const createTurnDaemonCommandHandler = (options: { handleBuildNationCandidate(ctx, command as Extract), instantRetreat: (command) => handleInstantRetreat(ctx, command as Extract), + messageRespond: (command) => + handleMessageRespond(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/actionableMessageResponse.test.ts b/app/game-engine/test/actionableMessageResponse.test.ts new file mode 100644 index 00000000..1e3a5d58 --- /dev/null +++ b/app/game-engine/test/actionableMessageResponse.test.ts @@ -0,0 +1,212 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { GamePrisma } from '@sammo-ts/infra'; +import type { MessagePayload } from '@sammo-ts/logic'; + +import { respondToActionableMessage } from '../src/turn/actionableMessageResponse.js'; +import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js'; +import type { ImmediateGeneralActionExecutor } from '../src/turn/reservedTurnHandler.js'; +import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js'; + +const actor: TurnGeneral = { + id: 7, + userId: 'user-7', + name: '수신자', + nationId: 1, + cityId: 1, + troopId: 0, + stats: { leadership: 70, strength: 60, intelligence: 50 }, + turnTime: new Date('0200-01-01T00:10:00.000Z'), + role: { + items: { horse: null, weapon: null, book: null, item: null }, + personality: null, + specialDomestic: null, + specialWar: null, + }, + triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} }, + meta: { killturn: 24 }, + penalty: {}, + officerLevel: 1, + experience: 100, + dedication: 100, + injury: 0, + gold: 1_000, + rice: 1_000, + crew: 0, + crewTypeId: 0, + train: 0, + atmos: 0, + age: 30, + npcState: 0, +}; + +const buildWorld = (): InMemoryTurnWorld => { + const state: TurnWorldState = { + id: 1, + currentYear: 200, + currentMonth: 1, + tickSeconds: 600, + lastTurnTime: new Date('0200-01-01T00:00:00.000Z'), + meta: { hiddenSeed: 'actionable-message-test', isunited: 2 }, + }; + const snapshot: TurnWorldSnapshot = { + generals: [actor], + cities: [], + nations: [], + troops: [], + diplomacy: [], + events: [], + initialEvents: [], + scenarioConfig: { + stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 50, npcMin: 10, chiefMin: 70 }, + iconPath: '', + map: {}, + const: {}, + environment: { mapName: 'test', unitSet: 'default' }, + }, + map: { id: 'test', name: 'test', cities: [] }, + }; + return new InMemoryTurnWorld(state, snapshot, { + schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] }, + }); +}; + +const source = { + generalId: 8, + generalName: '제안자', + nationId: 2, + nationName: '촉', + color: '#000000', + icon: '', +}; +const destination = { + generalId: actor.id, + generalName: actor.name, + nationId: actor.nationId, + nationName: '위', + color: '#ffffff', + icon: '', +}; + +const buildRow = (action: 'scout' | 'raiseInvader', overrides: Partial = {}) => ({ + id: 29, + mailbox: actor.id, + type: 'private', + validUntil: new Date('9999-12-31T00:00:00.000Z'), + message: { + src: source, + dest: destination, + text: '응답할 메시지', + option: { action, used: false, ...(action === 'raiseInvader' ? { args: [-2, -1.2, -1, -0.5] } : {}) }, + ...overrides, + } satisfies MessagePayload, +}); + +const buildDb = (rows: unknown[][]) => { + const queryRaw = vi.fn(async () => rows.shift() ?? []); + const updateMany = vi.fn(async () => ({ count: 1 })); + return { + db: { $queryRaw: queryRaw, message: { updateMany } } as unknown as GamePrisma.TransactionClient, + queryRaw, + updateMany, + }; +}; + +const buildExecutor = (ok = true): ImmediateGeneralActionExecutor => ({ + execute: vi.fn(async () => (ok ? { ok: true } : { ok: false, reason: '등용 수락 불가.' })), +}); + +describe('actionable message response', () => { + it('accepts a recruitment letter, executes the legacy action, and invalidates linked prompts', async () => { + const world = buildWorld(); + const row = buildRow('scout'); + const { db, updateMany } = buildDb([[row], [{ id: 31 }]]); + const executor = buildExecutor(); + + const result = await respondToActionableMessage({ + db, + world, + executor, + userId: actor.userId!, + generalId: actor.id, + messageId: row.id, + response: true, + }); + + expect(result).toEqual({ ok: true, action: 'scout', reason: 'success' }); + expect(executor.execute).toHaveBeenCalledWith( + expect.objectContaining({ + actionKey: 'che_등용수락', + generalId: actor.id, + args: { destNationId: source.nationId, destGeneralId: source.generalId }, + }) + ); + expect(updateMany).toHaveBeenCalledWith(expect.objectContaining({ where: { id: { in: [row.id, 31] } } })); + expect(world.peekDirtyState().messages).toEqual([ + expect.objectContaining({ + msgType: 'private', + text: '촉으로 등용 제의 수락', + sendDestOnly: true, + option: { delete: row.id }, + }), + ]); + }); + + it('keeps a recruitment letter valid when the legacy accept constraints reject it', async () => { + const world = buildWorld(); + const row = buildRow('scout'); + const { db, updateMany } = buildDb([[row]]); + + const result = await respondToActionableMessage({ + db, + world, + executor: buildExecutor(false), + userId: actor.userId!, + generalId: actor.id, + messageId: row.id, + response: true, + }); + + expect(result).toEqual({ ok: true, action: 'scout', reason: '등용 수락 불가.' }); + expect(updateMany).not.toHaveBeenCalled(); + expect(world.peekDirtyState().messages).toHaveLength(0); + }); + + it('does not invalidate an invader prompt before validating its receiver', async () => { + const world = buildWorld(); + const row = { ...buildRow('raiseInvader'), mailbox: 99 }; + const { db, updateMany } = buildDb([[row]]); + + const result = await respondToActionableMessage({ + db, + world, + executor: buildExecutor(), + userId: actor.userId!, + generalId: actor.id, + messageId: row.id, + response: false, + }); + + expect(result).toEqual({ ok: false, action: 'raiseInvader', reason: '올바른 수신자가 아닙니다.' }); + expect(updateMany).not.toHaveBeenCalled(); + }); + + it('invalidates a valid invader prompt when it is declined', async () => { + const world = buildWorld(); + const row = buildRow('raiseInvader'); + const { db, updateMany } = buildDb([[row]]); + + const result = await respondToActionableMessage({ + db, + world, + executor: buildExecutor(), + userId: actor.userId!, + generalId: actor.id, + messageId: row.id, + response: false, + }); + + expect(result).toEqual({ ok: true, action: 'raiseInvader', reason: 'success' }); + expect(updateMany).toHaveBeenCalledOnce(); + }); +}); diff --git a/app/game-engine/test/inMemoryTurnProcessorInvader.test.ts b/app/game-engine/test/inMemoryTurnProcessorInvader.test.ts new file mode 100644 index 00000000..802bd93d --- /dev/null +++ b/app/game-engine/test/inMemoryTurnProcessorInvader.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from 'vitest'; + +import { InMemoryTurnProcessor } from '../src/turn/inMemoryTurnProcessor.js'; +import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js'; +import type { TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js'; + +const buildWorld = (isunited: number): InMemoryTurnWorld => { + const state: TurnWorldState = { + id: 1, + currentYear: 200, + currentMonth: 1, + tickSeconds: 600, + lastTurnTime: new Date('0200-01-01T00:00:00.000Z'), + meta: { isunited }, + }; + const snapshot: TurnWorldSnapshot = { + generals: [], + cities: [], + nations: [], + troops: [], + diplomacy: [], + events: [], + initialEvents: [], + scenarioConfig: { + stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 50, npcMin: 10, chiefMin: 70 }, + iconPath: '', + map: {}, + const: {}, + environment: { mapName: 'test', unitSet: 'default' }, + }, + map: { id: 'test', name: 'test', cities: [] }, + }; + return new InMemoryTurnWorld(state, snapshot, { + schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] }, + }); +}; + +describe('invader event-game month progression', () => { + it('continues monthly processing while the invader game is active', async () => { + const world = buildWorld(1); + const result = await new InMemoryTurnProcessor(world).run(new Date('0200-01-01T00:10:00.000Z'), { + budgetMs: 1_000, + maxGenerals: 10, + catchUpCap: 1, + }); + + expect(result.processedTurns).toBe(1); + expect(world.getState()).toMatchObject({ currentYear: 200, currentMonth: 2 }); + }); + + it.each([2, 3])('stops monthly processing at terminal united state %s', async (isunited) => { + const world = buildWorld(isunited); + const result = await new InMemoryTurnProcessor(world).run(new Date('0200-01-01T00:10:00.000Z'), { + budgetMs: 1_000, + maxGenerals: 10, + catchUpCap: 1, + }); + + expect(result.processedTurns).toBe(0); + expect(world.getState()).toMatchObject({ currentYear: 200, currentMonth: 1 }); + }); +}); diff --git a/app/game-engine/test/unificationHandler.test.ts b/app/game-engine/test/unificationHandler.test.ts index 84b6fdc0..5ca15663 100644 --- a/app/game-engine/test/unificationHandler.test.ts +++ b/app/game-engine/test/unificationHandler.test.ts @@ -73,7 +73,7 @@ const nation: Nation = { gold: 1000, rice: 2000, power: 3000, - level: 1, + level: 4, typeCode: 'test', meta: {}, }; @@ -82,7 +82,7 @@ const city: City = { id: 1, name: '통일도시', nationId: 1, - level: 1, + level: 4, state: 0, population: 1000, populationMax: 2000, @@ -202,6 +202,33 @@ describe('unification handler', () => { expect(world.peekDirtyState().pendingUnificationFinalizations).toEqual([ expect.objectContaining({ auctionCancellations: [auctionCancellation, legacyAuctionCancellation] }), ]); + expect( + world.peekDirtyState().messages.map((message) => ({ + text: message.text, + action: message.option?.action, + args: message.option?.args, + recipient: message.dest.generalId, + })) + ).toEqual([ + { + text: '이벤트 게임으로 이민족[어려움]을 소환', + action: 'raiseInvader', + args: [-2, -1.2, 15_000, -1], + recipient: 1, + }, + { + text: '이벤트 게임으로 이민족[보통]을 소환', + action: 'raiseInvader', + args: [-2, -1.2, -1, -0.5], + recipient: 1, + }, + { + text: '이벤트 게임으로 이민족[쉬움]을 소환', + action: 'raiseInvader', + args: [-1, -1, -0.8, 0], + recipient: 1, + }, + ]); const bid = vi.fn(); const commands = createTurnDaemonCommandHandler({ diff --git a/app/game-frontend/src/views/InheritView.vue b/app/game-frontend/src/views/InheritView.vue index 9684a5da..9c19c201 100644 --- a/app/game-frontend/src/views/InheritView.vue +++ b/app/game-frontend/src/views/InheritView.vue @@ -167,10 +167,7 @@ const resetStatErrors = computed(() => { }); const turnTimeLabel = computed(() => { - if (!turnTimeResult.value) { - return null; - } - return formatServerDateTime(turnTimeResult.value); + return turnTimeResult.value; }); const isUnited = computed(() => status.value?.isUnited ?? false); @@ -349,7 +346,7 @@ const resetTurnTime = async () => { } await runAction(async () => { const result = await trpc.inherit.resetTurnTime.mutate(); - turnTimeResult.value = result.nextTurnTime; + turnTimeResult.value = result.nextTurnTimeLabel; }); }; diff --git a/docs/architecture/game-api-daemon-procedure-inventory.md b/docs/architecture/game-api-daemon-procedure-inventory.md index e9d55553..6d29ed45 100644 --- a/docs/architecture/game-api-daemon-procedure-inventory.md +++ b/docs/architecture/game-api-daemon-procedure-inventory.md @@ -24,37 +24,38 @@ ENGINE 전환 route의 명시적 `requestId`는 기존 middleware가 만든 chil ## 이번에 ENGINE procedure로 전환 -| route | 이전 outer transaction | API-side 작업 | ENGINE 소유 근거 | -| --- | --- | --- | --- | -| `general.vacation`, `general.setMySetting`, `general.dropItem` | 있음 | session-owned general 조회만 수행 | route `app/game-api/src/router/general/index.ts:657-706`; ENGINE이 general 존재/현재 설정/보유 item을 다시 검사하고 변경 `app/game-engine/src/turn/worldCommandHandler.ts:1476`, `:1514`, `:1571` | -| `nation.appoint`, `nation.changePermission`, `nation.kick` | 있음 | session actor 조회만 수행 | route `app/game-api/src/router/nation/endpoints/appoint.ts:7`, `changePermission.ts:7`, `kick.ts:7`; ENGINE이 actor 직위, 국가, 대상/도시를 다시 검사 `app/game-engine/src/turn/worldCommandHandler.ts:1648`, `:1718`, `:1888` | -| `troop.create`, `troop.join`, `troop.exit`, `troop.kick`, `troop.rename` | 있음 | actor 및 조기 권한/대상 조회; API DB write 없음 | route `app/game-api/src/router/troop/index.ts:165-314`; 동일 membership/leader/nation/name 검증과 mutation은 ENGINE `app/game-engine/src/turn/worldCommandHandler.ts:924`, `:1012`, `:1079`, `:1128`, `:1180` | -| `auction.openBuyRice`, `auction.openSellRice`, `auction.openUnique`, `auction.bidBuyRice`, `auction.bidSellRice`, `auction.bidUnique` | 있음 | auction/general/world 조기 validation; commit 뒤 Redis timer index 갱신 | route `app/game-api/src/router/auction/index.ts:325-649`; auction open/bid DB mutation과 경합/resource 재검증은 ENGINE transaction `app/game-engine/src/turn/worldCommandHandler.ts:1613`, `app/game-engine/src/auction/bidder.ts:183-550`. Redis zset은 durable auction row에서 재구성 가능한 scheduler index이며 API DB transaction의 일부가 아니었다. | -| `inherit.openUniqueAuction` | 있음 | world/general/minimum bid 조기 validation; inheritance point 차감 없음 | route `app/game-api/src/router/inherit/index.ts:794-835`; 공통 `auctionOpen` ENGINE handler와 Redis timer index만 사용 | +| route | 이전 outer transaction | API-side 작업 | ENGINE 소유 근거 | +| ------------------------------------------------------------------------------------------------------------------------------------- | ---------------------- | ----------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `general.vacation`, `general.setMySetting`, `general.dropItem` | 있음 | session-owned general 조회만 수행 | route `app/game-api/src/router/general/index.ts:657-706`; ENGINE이 general 존재/현재 설정/보유 item을 다시 검사하고 변경 `app/game-engine/src/turn/worldCommandHandler.ts:1476`, `:1514`, `:1571` | +| `nation.appoint`, `nation.changePermission`, `nation.kick` | 있음 | session actor 조회만 수행 | route `app/game-api/src/router/nation/endpoints/appoint.ts:7`, `changePermission.ts:7`, `kick.ts:7`; ENGINE이 actor 직위, 국가, 대상/도시를 다시 검사 `app/game-engine/src/turn/worldCommandHandler.ts:1648`, `:1718`, `:1888` | +| `troop.create`, `troop.join`, `troop.exit`, `troop.kick`, `troop.rename` | 있음 | actor 및 조기 권한/대상 조회; API DB write 없음 | route `app/game-api/src/router/troop/index.ts:165-314`; 동일 membership/leader/nation/name 검증과 mutation은 ENGINE `app/game-engine/src/turn/worldCommandHandler.ts:924`, `:1012`, `:1079`, `:1128`, `:1180` | +| `auction.openBuyRice`, `auction.openSellRice`, `auction.openUnique`, `auction.bidBuyRice`, `auction.bidSellRice`, `auction.bidUnique` | 있음 | auction/general/world 조기 validation; commit 뒤 Redis timer index 갱신 | route `app/game-api/src/router/auction/index.ts:325-649`; auction open/bid DB mutation과 경합/resource 재검증은 ENGINE transaction `app/game-engine/src/turn/worldCommandHandler.ts:1613`, `app/game-engine/src/auction/bidder.ts:183-550`. Redis zset은 durable auction row에서 재구성 가능한 scheduler index이며 API DB transaction의 일부가 아니었다. | +| `inherit.openUniqueAuction` | 있음 | world/general/minimum bid 조기 validation; inheritance point 차감 없음 | route `app/game-api/src/router/inherit/index.ts:794-835`; 공통 `auctionOpen` ENGINE handler와 Redis timer index만 사용 | 합계 18개 route다. 모든 전환은 procedure 변경과 stable ENGINE request ID만 포함하며 ENGINE handler, DB schema, journal/publisher foundation은 변경하지 않았다. ## 혼합 또는 validation 이관이 먼저 필요한 route -| route | 현재 procedure / outer transaction | 보류 근거 | -| --- | --- | --- | -| `inherit.buyHiddenBuff`, `inherit.setNextSpecialWar`, `inherit.resetSpecialWar`, `inherit.resetTurnTime`, `inherit.resetStat`, `inherit.buyRandomUnique` | `authedProcedure`, 있음 (`app/game-api/src/router/inherit/index.ts:343`, `:405`, `:486`, `:542`, `:599`, `:746`) | ENGINE `patchGeneral` 뒤 API transaction이 inheritance point, inheritance log, 일부 user-state를 쓴다(`:388-402`, `:464-483`, `:523-539`, `:581-596`, `:700-742`, `:777-790`). 현재 outer transaction도 먼저 commit된 ENGINE 변경을 rollback하지 못한다. 한 ENGINE command로 합치거나 durable saga가 필요하다. | -| `nation.setNotice`, `nation.setScoutMsg`, `nation.setSecretLimit`, `nation.setRate`, `nation.setBlockWar`, `nation.setBill`, `nation.setBlockScout` | `authedProcedure`, 있음 (`app/game-api/src/router/nation/endpoints/setNotice.ts:11`, `setScoutMsg.ts:11`, `setSecretLimit.ts:10`, `setRate.ts:10`, `setBlockWar.ts:10`, `setBill.ts:10`, `setBlockScout.ts:10`) | API가 actor 권한과 nation meta를 읽어 full metadata patch를 합성한다. ENGINE `setNationMeta`는 `_updatedAt` CAS만 검사하고 actor 권한을 알지 못한다(`app/game-engine/src/turn/worldCommandHandler.ts:430-472`). actor/permission을 command와 ENGINE validation으로 옮긴 뒤 전환한다. | -| `npc.setNationPolicy`, `npc.setNationPriority`, `npc.setGeneralPriority` | `accessAuthedInputProcedure`, 있음 (`app/game-api/src/router/npc/index.ts:540`, `:703`, `:756`) | API가 nation/general/world를 읽어 권한, unit-set 기반 기본값과 full policy object를 합성한 뒤 같은 `setNationMeta` CAS를 사용한다(`:540-702`, `:703-755`, `:756-807`). ENGINE이 권한/합성 의미를 소유하지 않는다. | -| `tournament.join`, `tournament.placeBet` | `authedProcedure`, 있음 (`app/game-api/src/router/tournament/index.ts:376`, `:523`) | PostgreSQL ENGINE resource/meta 명령과 Redis-owned participants/bets를 결합하고 실패 시 보상 ENGINE 명령을 보낸다(`:376-463`, `:523-628`). 하나의 DB transaction이 아니며 durable saga/Redis atomic revision이 필요하다. | -| `vote.submitVote` | `authedProcedure`, 있음 (`app/game-api/src/router/vote/index.ts:349-528`) | API transaction이 vote row를 insert한 뒤 ENGINE `voteReward`를 기다리고 commit 뒤 front-status publish를 수행한다. vote/reward 단일 소유 command 또는 idempotent saga 없이는 분리할 수 없다. 이 작업에서는 vote journal/publisher를 수정하지 않았다. | +| route | 현재 procedure / outer transaction | 보류 근거 | +| -------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `messages.respond` | `authedProcedure`, action별 분기 (`app/game-api/src/router/messages/index.ts`) | `scout`와 `raiseInvader`는 stable `messageRespond` ENGINE command가 actor/message를 다시 잠그고 처리한다. `noAggression`/`cancelNA`/`stopWar`는 기존 API transaction의 `respondToDiplomaticMessage`가 처리하므로 route 전체를 ENGINE-owned로 분류하지 않는다. 두 경로 모두 commit journal을 남긴다. | +| `inherit.buyHiddenBuff`, `inherit.setNextSpecialWar`, `inherit.resetSpecialWar`, `inherit.resetTurnTime`, `inherit.resetStat`, `inherit.buyRandomUnique` | `authedProcedure`, 있음 (`app/game-api/src/router/inherit/index.ts:343`, `:405`, `:486`, `:542`, `:599`, `:746`) | ENGINE `patchGeneral` 뒤 API transaction이 inheritance point, inheritance log, 일부 user-state를 쓴다(`:388-402`, `:464-483`, `:523-539`, `:581-596`, `:700-742`, `:777-790`). 현재 outer transaction도 먼저 commit된 ENGINE 변경을 rollback하지 못한다. 한 ENGINE command로 합치거나 durable saga가 필요하다. | +| `nation.setNotice`, `nation.setScoutMsg`, `nation.setSecretLimit`, `nation.setRate`, `nation.setBlockWar`, `nation.setBill`, `nation.setBlockScout` | `authedProcedure`, 있음 (`app/game-api/src/router/nation/endpoints/setNotice.ts:11`, `setScoutMsg.ts:11`, `setSecretLimit.ts:10`, `setRate.ts:10`, `setBlockWar.ts:10`, `setBill.ts:10`, `setBlockScout.ts:10`) | API가 actor 권한과 nation meta를 읽어 full metadata patch를 합성한다. ENGINE `setNationMeta`는 `_updatedAt` CAS만 검사하고 actor 권한을 알지 못한다(`app/game-engine/src/turn/worldCommandHandler.ts:430-472`). actor/permission을 command와 ENGINE validation으로 옮긴 뒤 전환한다. | +| `npc.setNationPolicy`, `npc.setNationPriority`, `npc.setGeneralPriority` | `accessAuthedInputProcedure`, 있음 (`app/game-api/src/router/npc/index.ts:540`, `:703`, `:756`) | API가 nation/general/world를 읽어 권한, unit-set 기반 기본값과 full policy object를 합성한 뒤 같은 `setNationMeta` CAS를 사용한다(`:540-702`, `:703-755`, `:756-807`). ENGINE이 권한/합성 의미를 소유하지 않는다. | +| `tournament.join`, `tournament.placeBet` | `authedProcedure`, 있음 (`app/game-api/src/router/tournament/index.ts:376`, `:523`) | PostgreSQL ENGINE resource/meta 명령과 Redis-owned participants/bets를 결합하고 실패 시 보상 ENGINE 명령을 보낸다(`:376-463`, `:523-628`). 하나의 DB transaction이 아니며 durable saga/Redis atomic revision이 필요하다. | +| `vote.submitVote` | `authedProcedure`, 있음 (`app/game-api/src/router/vote/index.ts:349-528`) | API transaction이 vote row를 insert한 뒤 ENGINE `voteReward`를 기다리고 commit 뒤 front-status publish를 수행한다. vote/reward 단일 소유 command 또는 idempotent saga 없이는 분리할 수 없다. 이 작업에서는 vote journal/publisher를 수정하지 않았다. | -합계 19개 route다. 특히 inheritance/vote의 현재 outer transaction은 API 절반만 +합계 20개 route다. 특히 inheritance/vote의 현재 outer transaction은 API 절반만 rollback하므로 “원자적”이라고 간주하면 안 된다. ## 이미 API outer transaction이 없는 정상 route -| route | 근거 | -| --- | --- | -| `general.adjustIcon` | `engineAuthedProcedure`; `app/game-api/src/router/general/index.ts:584-610`. helper가 stable account-icon request ID로 ENGINE command를 보냄. | +| route | 근거 | +| ---------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `general.adjustIcon` | `engineAuthedProcedure`; `app/game-api/src/router/general/index.ts:584-610`. helper가 stable account-icon request ID로 ENGINE command를 보냄. | | `general.ensureDieOnPrestartStatus`, `general.dieOnPrestart`, `general.buildNationCandidate`, `general.instantRetreat` | `accessEngineAuthedProcedure`/`accessEngineAuthedInputProcedure`; `app/game-api/src/router/general/index.ts:612-656`. user/general 조회는 outer transaction 밖이고 command마다 stable request ID가 있다. | -| `join.selectPoolGeneral`, `join.reselectPoolGeneral`, `join.createGeneral`, `join.possessGeneral` | `engineAuthedProcedure`; `app/game-api/src/router/join/index.ts:390`, `:434`, `:462`, `:585`. client request ID가 있으면 user-scoped durable ENGINE identity를 사용한다. | +| `join.selectPoolGeneral`, `join.reselectPoolGeneral`, `join.createGeneral`, `join.possessGeneral` | `engineAuthedProcedure`; `app/game-api/src/router/join/index.ts:390`, `:434`, `:462`, `:585`. client request ID가 있으면 user-scoped durable ENGINE identity를 사용한다. | 합계 9개 route다. diff --git a/packages/common/src/turnDaemon/types.ts b/packages/common/src/turnDaemon/types.ts index 85cc6f1f..dcfdb6e4 100644 --- a/packages/common/src/turnDaemon/types.ts +++ b/packages/common/src/turnDaemon/types.ts @@ -93,6 +93,14 @@ export type TurnDaemonCommand = | { type: 'dieOnPrestart'; requestId?: string; userId: string; generalId: number } | { type: 'buildNationCandidate'; requestId?: string; userId: string; generalId: number } | { type: 'instantRetreat'; requestId?: string; userId: string; generalId: number } + | { + type: 'messageRespond'; + requestId?: string; + userId: string; + generalId: number; + messageId: number; + response: boolean; + } | { type: 'vacation'; requestId?: string; generalId: number } | { type: 'setMySetting'; @@ -441,6 +449,14 @@ export type TurnDaemonCommandResult = | { type: 'dieOnPrestart'; ok: boolean; generalId: number; reason?: string } | { type: 'buildNationCandidate'; ok: boolean; generalId: number; reason?: string } | { type: 'instantRetreat'; ok: boolean; generalId: number; reason?: string } + | { + type: 'messageRespond'; + ok: boolean; + generalId: number; + messageId: number; + action?: 'scout' | 'raiseInvader'; + 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/packages/logic/src/actions/turn/general/che_등용.ts b/packages/logic/src/actions/turn/general/che_등용.ts index 7a7069fa..98af1266 100644 --- a/packages/logic/src/actions/turn/general/che_등용.ts +++ b/packages/logic/src/actions/turn/general/che_등용.ts @@ -17,10 +17,11 @@ import type { GeneralActionResolver, GeneralActionEffect, } from '@sammo-ts/logic/actions/engine.js'; -import { createGeneralPatchEffect, createLogEffect } from '@sammo-ts/logic/actions/engine.js'; +import { createGeneralPatchEffect, createLogEffect, createMessageEffect } from '@sammo-ts/logic/actions/engine.js'; import { LogCategory, LogScope } from '@sammo-ts/logic/logging/types.js'; import { z } from 'zod'; import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js'; +import { JosaUtil } from '@sammo-ts/common'; import type { ActionContextBase, ActionContextOptions } from '@sammo-ts/logic/actions/turn/actionContext.js'; import { tryApplyUniqueLottery } from '@sammo-ts/logic/rewards/uniqueLottery.js'; import type { GeneralTurnCommandSpec } from './index.js'; @@ -31,6 +32,7 @@ export interface EmployResolveContext< > extends GeneralActionResolveContext { destGeneral?: General; env?: TurnCommandEnv; + messageTime: Date; } const ACTION_NAME = '등용'; @@ -131,6 +133,38 @@ export class ActionResolver< }); } + if (ctx.nation) { + const destNation = ctx.worldView + ?.listNations?.() + .find((candidate) => candidate.id === destGeneral.nationId); + const josaRo = JosaUtil.pick(ctx.nation.name, '로'); + effects.push( + createMessageEffect({ + msgType: 'private', + src: { + generalId: general.id, + generalName: general.name, + nationId: ctx.nation.id, + nationName: ctx.nation.name, + color: ctx.nation.color, + icon: '', + }, + dest: { + generalId: destGeneral.id, + generalName: destGeneral.name, + nationId: destGeneral.nationId, + nationName: destNation?.name ?? '재야', + color: destNation?.color ?? '#000000', + icon: '', + }, + text: `${ctx.nation.name}${josaRo} 망명 권유 서신`, + time: ctx.messageTime, + validUntil: new Date('9999-12-31T12:59:59.000Z'), + option: { action: 'scout' }, + }) + ); + } + effects.push( createLogEffect( `${general.name}(${ctx.nation?.name ?? '재야'})로 부터 등용 권유 서신이 도착했습니다.`, @@ -215,6 +249,10 @@ export const actionContextBuilder = (base: ActionContextBase, options: ActionCon ...base, destGeneral, env: options.scenarioConfig.const as unknown as TurnCommandEnv, + messageTime: + (base.general as General & { turnTime?: Date }).turnTime instanceof Date + ? (base.general as General & { turnTime: Date }).turnTime + : options.world.lastTurnTime, }; }; diff --git a/packages/logic/src/actions/turn/general/che_등용수락.ts b/packages/logic/src/actions/turn/general/che_등용수락.ts index 78b1acc5..285d1d5f 100644 --- a/packages/logic/src/actions/turn/general/che_등용수락.ts +++ b/packages/logic/src/actions/turn/general/che_등용수락.ts @@ -295,7 +295,17 @@ export class ActionResolver< format: LogFormat.YEAR_MONTH, }); - return { effects }; + const deletedTroopIds: number[] = []; + if (general.troopId === general.id) { + deletedTroopIds.push(general.id); + for (const member of context.worldView?.listGenerals() ?? []) { + if (member.id !== general.id && member.troopId === general.id) { + effects.push(createGeneralPatchEffect({ troopId: 0 }, member.id)); + } + } + } + + return { effects, deletedTroopIds }; } } diff --git a/packages/logic/src/messages/message.ts b/packages/logic/src/messages/message.ts index d858c3f4..2564e81b 100644 --- a/packages/logic/src/messages/message.ts +++ b/packages/logic/src/messages/message.ts @@ -22,6 +22,8 @@ export interface MessageDraft { time: Date; validUntil: Date; option?: MessageOption | null; + /** Ref Message::send(true): persist only the receiver copy. */ + sendDestOnly?: boolean; } export interface MessagePayload { @@ -139,7 +141,7 @@ export const sendMessage = async ( throw new Error('Failed to send receiver message.'); } - if (options.sendDestOnly) { + if (options.sendDestOnly ?? draft.sendDestOnly) { return { receiverId }; } diff --git a/packages/logic/test/scenarios/general/che_등용_message.test.ts b/packages/logic/test/scenarios/general/che_등용_message.test.ts new file mode 100644 index 00000000..23d9fb0a --- /dev/null +++ b/packages/logic/test/scenarios/general/che_등용_message.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from 'vitest'; + +import { ActionResolver } from '../../../src/actions/turn/general/che_등용.js'; + +describe('che_등용 recruitment message', () => { + it('queues the Ref scout prompt with sender and receiver snapshots', () => { + const logs: unknown[] = []; + const general = { + id: 1, + name: '등용자', + nationId: 1, + cityId: 1, + troopId: 0, + gold: 5_000, + experience: 100, + dedication: 100, + stats: { leadership: 70, strength: 60, intelligence: 50 }, + role: { items: {} }, + triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} }, + meta: {}, + }; + const destination = { + ...general, + id: 2, + name: '수신자', + nationId: 2, + experience: 300, + dedication: 200, + }; + const sourceNation = { id: 1, name: '위', color: '#ffffff' }; + const destinationNation = { id: 2, name: '촉', color: '#000000' }; + const messageTime = new Date('0200-01-01T00:10:00.000Z'); + + const result = new ActionResolver().resolve( + { + general, + nation: sourceNation, + destGeneral: destination, + messageTime, + env: { develCost: 100 }, + worldView: { listNations: () => [sourceNation, destinationNation] }, + addLog: (text: string, options: unknown) => logs.push({ text, options }), + } as never, + { destGeneralId: destination.id } + ); + + expect(result.effects).toContainEqual({ + type: 'message:add', + draft: { + msgType: 'private', + src: { + generalId: general.id, + generalName: general.name, + nationId: sourceNation.id, + nationName: sourceNation.name, + color: sourceNation.color, + icon: '', + }, + dest: { + generalId: destination.id, + generalName: destination.name, + nationId: destinationNation.id, + nationName: destinationNation.name, + color: destinationNation.color, + icon: '', + }, + text: '위로 망명 권유 서신', + time: messageTime, + validUntil: new Date('9999-12-31T12:59:59.000Z'), + option: { action: 'scout' }, + }, + }); + }); +}); diff --git a/tools/frontend-legacy-parity/ingame-message-parity.spec.ts b/tools/frontend-legacy-parity/ingame-message-parity.spec.ts index 27794520..30ded23f 100644 --- a/tools/frontend-legacy-parity/ingame-message-parity.spec.ts +++ b/tools/frontend-legacy-parity/ingame-message-parity.spec.ts @@ -41,31 +41,8 @@ const general = { const generalContext = { general, - city: { - id: 1, - name: '낙양', - level: 7, - nationId: 1, - population: 50000, - agriculture: 5000, - commerce: 5000, - security: 5000, - defence: 5000, - wall: 5000, - supplyState: 1, - frontState: 2, - }, - nation: { - id: 1, - name: '테스트국', - color: '#d32f2f', - level: 5, - gold: 10000, - rice: 10000, - tech: 1200, - typeCode: 'che_군벌', - capitalCityId: 1, - }, + city: null, + nation: null, settings: {}, penalties: {}, }; @@ -111,12 +88,30 @@ const buildMessages = (permission: number) => ({ { id: 103, msgType: 'private', - src: foreignTarget, + src: target(9, '상대일반', 2, '상대국', '#2457a6'), dest: ownTarget, text: '개인 메시지 본문', option: {}, time: messageTime, }, + { + id: 105, + msgType: 'private', + src: foreignTarget, + dest: ownTarget, + text: '상대국으로 망명 권유 서신', + option: { action: 'scout', used: false }, + time: messageTime, + }, + { + id: 106, + msgType: 'private', + src: target(0, '', 0, 'System', '#000000'), + dest: ownTarget, + text: '이벤트 게임으로 이민족[보통]을 소환', + option: { action: 'raiseInvader', args: [-2, -1.2, -1, -0.5], used: false }, + time: messageTime, + }, ], diplomacy: [ { @@ -192,12 +187,40 @@ const installFixture = async ( await page.route('**/che/api/trpc/**', async (route) => { const body = route.request().postDataJSON(); const results = operationNames(route).map((operation) => { + if (operation === 'dashboard.getContextBundleDelta') { + return response({ + context: { + kind: 'snapshot', + revision: 'AAAAAAAAAAAAAAAAAAAAAA', + data: generalContext, + }, + commandTable: { + kind: 'snapshot', + revision: 'BBBBBBBBBBBBBBBBBBBBBB', + data: { general: [], nation: [] }, + }, + boardAccess: { + kind: 'snapshot', + revision: 'CCCCCCCCCCCCCCCCCCCCCC', + data: { canMeeting: true, canSecret: true, permission: options.permission }, + }, + }); + } if (operation === 'auth.status') return response({ userId: 'frontend-parity-user' }); if (operation === 'lobby.info') { return response({ ...fixture.game.lobby, myGeneral: general }); } if (operation === 'general.me') return response(generalContext); if (operation === 'world.getMapLayout') return response(fixture.game.mapLayout); + if (operation === 'world.getState') { + return response({ + currentYear: 197, + currentMonth: 7, + tickSeconds: 3600, + config: { npcMode: 0, const: {}, environment: {} }, + meta: {}, + }); + } if (operation === 'world.getMap') { return response({ ...fixture.game.map, myCity: 1, myNation: 1 }); } @@ -251,7 +274,10 @@ const openMessages = async (page: Page, viewport: { width: number; height: numbe await page.goto(`http://127.0.0.1:${gamePort}/che/`); await expect(page.getByRole('heading', { name: '전장 현황' })).toBeVisible(); if (viewport.width <= 1024) { - await page.getByRole('button', { name: '메시지', exact: true }).click(); + const mobileMessageButton = page.getByRole('button', { name: '메시지', exact: true }); + if ((await mobileMessageButton.count()) > 0) { + await mobileMessageButton.click(); + } } await expect(page.locator('.MessagePanel')).toBeVisible(); }; @@ -359,8 +385,8 @@ test('exposes ambassador targets, reply, read, delete, and successful send inter await expect(select.locator('option[value="8"]')).toBeDisabled(); await expect(select.locator('option[value="9"]')).toBeEnabled(); - await page.locator('.PrivateTalk .msg-target').filter({ hasText: '상대장수' }).click(); - await expect(select).toHaveValue('8'); + await page.locator('.PrivateTalk .msg-target').filter({ hasText: '상대일반' }).click(); + await expect(select).toHaveValue('9'); await page.locator('.PrivateTalk').getByRole('button', { name: '모두 읽음' }).click(); await expect.poll(() => mutations.filter((entry) => entry.operation === 'messages.readLatest').length).toBe(1); @@ -377,6 +403,37 @@ test('exposes ambassador targets, reply, read, delete, and successful send inter await expect.poll(() => mutations.filter((entry) => entry.operation === 'messages.send').length).toBe(1); }); +test('accepts recruitment and declines invader prompts through private-message controls', async ({ page }) => { + const mutations = await installFixture(page, { permission: 4 }); + await openMessages(page, { width: 500, height: 900 }); + + const recruitment = page.locator('.PrivateTalk .msg-plate').filter({ hasText: '망명 권유 서신' }); + const invader = page.locator('.PrivateTalk .msg-plate').filter({ hasText: '이민족[보통]을 소환' }); + await expect(recruitment.getByRole('button', { name: '수락' })).toBeVisible(); + await expect(invader.getByRole('button', { name: '거절' })).toBeVisible(); + + page.once('dialog', async (dialog) => { + expect(dialog.message()).toBe('수락하시겠습니까?'); + await dialog.accept(); + }); + await recruitment.getByRole('button', { name: '수락' }).click(); + await expect.poll(() => mutations.filter((entry) => entry.operation === 'messages.respond').length).toBe(1); + + page.once('dialog', async (dialog) => { + expect(dialog.message()).toBe('거절하시겠습니까?'); + await dialog.accept(); + }); + await invader.getByRole('button', { name: '거절' }).click(); + await expect.poll(() => mutations.filter((entry) => entry.operation === 'messages.respond').length).toBe(2); + + const responses = mutations.filter((entry) => entry.operation === 'messages.respond'); + expect(responses).toHaveLength(2); + expect(JSON.stringify(responses[0]!.body)).toContain('"messageId":105'); + expect(JSON.stringify(responses[0]!.body)).toContain('"response":true'); + expect(JSON.stringify(responses[1]!.body)).toContain('"messageId":106'); + expect(JSON.stringify(responses[1]!.body)).toContain('"response":false'); +}); + test('redacts diplomacy for a low-permission general and preserves the failed-send error flow', async ({ page }) => { const mutations = await installFixture(page, { permission: 2, diff --git a/tools/frontend-legacy-parity/inheritance-management.spec.ts b/tools/frontend-legacy-parity/inheritance-management.spec.ts index 96022578..3758c333 100644 --- a/tools/frontend-legacy-parity/inheritance-management.spec.ts +++ b/tools/frontend-legacy-parity/inheritance-management.spec.ts @@ -97,6 +97,7 @@ const statusFixture = { const installFixture = async (page: Page, options: { failBuff?: boolean } = {}) => { let buffMutationCount = 0; + let resetTurnMutationCount = 0; await installImages(page); await page.addInitScript(() => { window.localStorage.setItem('sammo-game-token', 'ga_inherit-visual-token'); @@ -140,6 +141,10 @@ const installFixture = async (page: Page, options: { failBuff?: boolean } = {}) buffMutationCount += 1; return response({ ok: true, remainPoint: 11_800 }); } + if (name === 'inherit.resetTurnTime') { + resetTurnMutationCount += 1; + return response({ ok: true, nextTurnTimeBase: 302.5143852464758, nextTurnTimeLabel: '00:05' }); + } throw new Error(`Unhandled inheritance fixture operation: ${name}`); }); await route.fulfill({ @@ -148,10 +153,32 @@ const installFixture = async (page: Page, options: { failBuff?: boolean } = {}) body: JSON.stringify(result), }); }); - return { buffMutationCount: () => buffMutationCount }; + return { + buffMutationCount: () => buffMutationCount, + resetTurnMutationCount: () => resetTurnMutationCount, + }; }; test.describe('inheritance management legacy parity', () => { + test('confirms and displays the Ref-compatible pending turn-time base', async ({ page }) => { + const fixture = await installFixture(page); + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto(gameUrl); + await expect(page.locator('#container')).toBeVisible(); + + const item = page.locator('.simple-item').filter({ hasText: '랜덤 턴 초기화' }); + const button = item.getByRole('button', { name: '구입' }); + await expect(button).toBeEnabled(); + page.once('dialog', async (dialog) => { + expect(dialog.message()).toBe('턴 시간을 1000 포인트로 초기화하시겠습니까?'); + await dialog.accept(); + }); + await button.click(); + + await expect(item).toContainText('적용 시간: 00:05'); + expect(fixture.resetTurnMutationCount()).toBe(1); + }); + test('matches the ref 1000px grid and computed styles on desktop and mobile', async ({ page }) => { await installFixture(page); await page.setViewportSize({ width: 1280, height: 900 }); @@ -185,7 +212,7 @@ test.describe('inheritance management legacy parity', () => { expect(desktop.container.width).toBe(1000); expect(desktop.container.x).toBe(140); - expect(desktop.firstPoint.width).toBeCloseTo(327.3, 0); + expect(Math.abs(desktop.firstPoint.width - 327.3)).toBeLessThanOrEqual(1); expect(desktop.fontFamily).toContain('Pretendard'); expect(desktop.fontSize).toBe('14px'); expect(desktop.backgroundImage).toContain('back_walnut.jpg'); @@ -220,7 +247,7 @@ test.describe('inheritance management legacy parity', () => { await page.keyboard.press('Tab'); await page.keyboard.press('Shift+Tab'); await expect(buyButton).toBeFocused(); - await expect.poll(() => buyButton.evaluate((element) => getComputedStyle(element).outlineStyle)).toBe('solid'); + await expect.poll(() => buyButton.evaluate((element) => getComputedStyle(element).boxShadow)).not.toBe('none'); await buyButton.evaluate((element) => element.setAttribute('disabled', '')); await expect.poll(() => buyButton.evaluate((element) => getComputedStyle(element).opacity)).toBe('0.65'); @@ -245,7 +272,7 @@ test.describe('inheritance management legacy parity', () => { }; }); expect(mobile.containerWidth).toBe(500); - expect(mobile.firstWidth).toBeCloseTo(482, 0); + expect(mobile.firstWidth).toBeCloseTo(484, 0); expect(mobile.stacked).toBe(true); if (artifactRoot) { diff --git a/tools/frontend-legacy-parity/instant-diplomacy-message.spec.ts b/tools/frontend-legacy-parity/instant-diplomacy-message.spec.ts index fbff2cd8..1f9283ce 100644 --- a/tools/frontend-legacy-parity/instant-diplomacy-message.spec.ts +++ b/tools/frontend-legacy-parity/instant-diplomacy-message.spec.ts @@ -37,31 +37,8 @@ const general = { const generalContext = { general, - city: { - id: 1, - name: '낙양', - level: 7, - nationId: 1, - population: 50000, - agriculture: 5000, - commerce: 5000, - security: 5000, - defence: 5000, - wall: 5000, - supplyState: 1, - frontState: 2, - }, - nation: { - id: 1, - name: '수락국', - color: '#d32f2f', - level: 5, - gold: 10000, - rice: 10000, - tech: 1200, - typeCode: 'che_군벌', - capitalCityId: 1, - }, + city: null, + nation: null, settings: {}, penalties: {}, }; @@ -69,8 +46,22 @@ const generalContext = { const diplomacyMessage = { id: 701, msgType: 'diplomacy', - src: { generalId: 2, generalName: '제안장수', nationId: 2, nationName: '제안국' }, - dest: { generalId: 1, generalName: '수락장수', nationId: 1, nationName: '수락국' }, + src: { + generalId: 2, + generalName: '제안장수', + nationId: 2, + nationName: '제안국', + color: '#2457a6', + icon: '', + }, + dest: { + generalId: 1, + generalName: '수락장수', + nationId: 1, + nationName: '수락국', + color: '#d32f2f', + icon: '', + }, text: '제안국에서 191년 2월까지 불가침을 제안했습니다.', option: { action: 'noAggression', @@ -118,12 +109,44 @@ const installFixture = async ( const operations = operationNames(route); const requestBody = route.request().postDataJSON(); const results = operations.map((operation) => { + if (operation === 'dashboard.getContextBundleDelta') { + return response({ + context: { + kind: 'snapshot', + revision: 'AAAAAAAAAAAAAAAAAAAAAA', + data: generalContext, + }, + commandTable: { + kind: 'snapshot', + revision: 'BBBBBBBBBBBBBBBBBBBBBB', + data: { general: [], nation: [] }, + }, + boardAccess: { + kind: 'snapshot', + revision: 'CCCCCCCCCCCCCCCCCCCCCC', + data: { + canMeeting: true, + canSecret: true, + permission: options.canRespondDiplomacy === false ? 2 : 4, + }, + }, + }); + } if (operation === 'auth.status') return response({ userId: 'frontend-parity-user' }); if (operation === 'lobby.info') { return response({ ...fixture.game.lobby, myGeneral: general }); } if (operation === 'general.me') return response(generalContext); if (operation === 'world.getMapLayout') return response(fixture.game.mapLayout); + if (operation === 'world.getState') { + return response({ + currentYear: 190, + currentMonth: 3, + tickSeconds: 3600, + config: { npcMode: 0, const: {}, environment: {} }, + meta: {}, + }); + } if (operation === 'world.getMap') { return response({ ...fixture.game.map, myCity: 1, myNation: 1 }); } @@ -256,15 +279,15 @@ test.describe('instant diplomacy response UI', () => { await page.setViewportSize({ width: 390, height: 844 }); await page.goto(`http://127.0.0.1:${gamePort}/che/`); await expect(page.getByRole('heading', { name: '전장 현황' })).toBeVisible(); - await page.getByRole('button', { name: '메시지', exact: true }).click(); + const mobileMessageButton = page.getByRole('button', { name: '메시지', exact: true }); + if ((await mobileMessageButton.count()) > 0) await mobileMessageButton.click(); const responseRow = page.locator('.message-response'); await expect(responseRow).toBeVisible(); const itemWidth = await page .locator('.DiplomacyTalk .msg-plate') .evaluate((element) => element.getBoundingClientRect().width); - expect(itemWidth).toBeGreaterThanOrEqual(389); - expect(itemWidth).toBeLessThanOrEqual(390); + expect(itemWidth).toBeCloseTo(500, 0); page.once('dialog', async (dialog) => { expect(dialog.message()).toBe('거절하시겠습니까?'); @@ -295,7 +318,8 @@ test.describe('instant diplomacy response UI', () => { await page.setViewportSize({ width: 390, height: 844 }); await page.goto(`http://127.0.0.1:${gamePort}/che/`); await expect(page.getByRole('heading', { name: '전장 현황' })).toBeVisible(); - await page.getByRole('button', { name: '메시지', exact: true }).click(); + const mobileMessageButton = page.getByRole('button', { name: '메시지', exact: true }); + if ((await mobileMessageButton.count()) > 0) await mobileMessageButton.click(); const accept = page.locator('.message-response').getByRole('button', { name: '수락' }); await expect(accept).toBeDisabled();