diff --git a/app/game-api/src/maps/mapDefinition.ts b/app/game-api/src/maps/mapDefinition.ts new file mode 100644 index 0000000..41a27a3 --- /dev/null +++ b/app/game-api/src/maps/mapDefinition.ts @@ -0,0 +1,37 @@ +import fs from 'node:fs/promises'; +import { existsSync } from 'node:fs'; +import path from 'node:path'; + +import { MapDefinitionSchema, type MapDefinition } from '@sammo-ts/logic'; + +const resolveWorkspaceRoot = (): string => { + let current = path.resolve(process.cwd()); + for (let depth = 0; depth <= 6; depth += 1) { + if (existsSync(path.join(current, 'pnpm-workspace.yaml'))) { + return current; + } + const parent = path.dirname(current); + if (parent === current) { + break; + } + current = parent; + } + return path.resolve(process.cwd()); +}; + +const RESOURCE_MAP_ROOT = path.resolve(resolveWorkspaceRoot(), 'resources/map'); +const mapCache = new Map(); + +export const loadMapDefinitionByName = async (mapName: string): Promise => { + if (!/^[a-zA-Z0-9_-]+$/.test(mapName)) { + throw new Error(`Invalid map name: ${mapName}`); + } + const cached = mapCache.get(mapName); + if (cached) { + return cached; + } + const raw = await fs.readFile(path.join(RESOURCE_MAP_ROOT, `map_${mapName}.json`), 'utf-8'); + const map = MapDefinitionSchema.parse(JSON.parse(raw)); + mapCache.set(mapName, map); + return map; +}; diff --git a/app/game-api/src/messages/diplomaticResponse.ts b/app/game-api/src/messages/diplomaticResponse.ts new file mode 100644 index 0000000..9e2f11d --- /dev/null +++ b/app/game-api/src/messages/diplomaticResponse.ts @@ -0,0 +1,418 @@ +import { TRPCError } from '@trpc/server'; + +import { asRecord, JosaUtil } from '@sammo-ts/common'; +import { + ActionLogger, + buildNationFrontStatePatches, + finalizeLogEntry, + LogFormat, + MESSAGE_MAILBOX_NATIONAL_BASE, + resolveInstantDiplomacyResponse, + sendMessage, + type GeneralActionEffect, + type InstantDiplomacyResponseAction, + type LogEntryDraft, + type MessageDraft, + type MessageRecordDraft, + type Nation as LogicNation, +} from '@sammo-ts/logic'; + +import type { DatabaseClient, GeneralRow, InputJsonValue, NationRow } from '../context.js'; +import { loadMapDefinitionByName } from '../maps/mapDefinition.js'; +import { resolveNationPermission } from '../router/nation/shared.js'; +import { fetchMessageByIdForUpdate, insertMessage, invalidateMessages } from './store.js'; + +const ACTION_NAMES: Record = { + noAggression: '불가침', + cancelNA: '불가침 파기', + stopWar: '종전', +}; + +const toLogicNation = (nation: NationRow): LogicNation => { + const meta = asRecord(nation.meta); + const power = typeof meta.power === 'number' && Number.isFinite(meta.power) ? meta.power : 0; + return { + id: nation.id, + name: nation.name, + color: nation.color, + capitalCityId: nation.capitalCityId, + chiefGeneralId: nation.chiefGeneralId, + gold: nation.gold, + rice: nation.rice, + power, + level: nation.level, + typeCode: nation.typeCode, + meta: meta as LogicNation['meta'], + }; +}; + +const parseAction = (value: unknown): InstantDiplomacyResponseAction | null => + value === 'noAggression' || value === 'cancelNA' || value === 'stopWar' ? value : null; + +const parseInteger = (value: unknown): number | null => + typeof value === 'number' && Number.isInteger(value) ? value : null; + +const persistLogs = async ( + db: DatabaseClient, + logs: LogEntryDraft[], + year: number, + month: number, + at: Date +): Promise => { + const data = logs.flatMap((entry) => { + const record = finalizeLogEntry(entry, { year, month, at }); + if (!record) { + return []; + } + return [ + { + scope: record.scope, + category: record.category, + subType: record.subType ?? null, + year: record.year, + month: record.month, + text: record.text, + generalId: record.generalId ?? null, + nationId: record.nationId ?? null, + userId: record.userId ?? null, + meta: (record.meta ?? {}) as InputJsonValue, + createdAt: record.createdAt ?? at, + }, + ]; + }); + if (data.length > 0) { + await db.logEntry.createMany({ data }); + } +}; + +const persistEffects = async ( + db: DatabaseClient, + effects: GeneralActionEffect[], + year: number, + month: number, + at: Date +): Promise => { + const logs: LogEntryDraft[] = []; + for (const effect of effects) { + if (effect.type === 'diplomacy:patch') { + await db.diplomacy.update({ + where: { + srcNationId_destNationId: { + srcNationId: effect.srcNationId, + destNationId: effect.destNationId, + }, + }, + data: { + ...(effect.patch.state !== undefined ? { stateCode: effect.patch.state } : {}), + ...(effect.patch.term !== undefined ? { term: effect.patch.term } : {}), + ...(effect.patch.meta !== undefined ? { meta: effect.patch.meta as InputJsonValue } : {}), + }, + }); + } else if (effect.type === 'nation:patch' && effect.targetId !== undefined) { + const patch = effect.patch; + await db.nation.update({ + where: { id: effect.targetId }, + data: { + ...(patch.meta !== undefined ? { meta: patch.meta as InputJsonValue } : {}), + }, + }); + } else if (effect.type === 'log') { + logs.push(effect.entry); + } + } + await persistLogs(db, logs, year, month, at); +}; + +const refreshFrontStates = async (db: DatabaseClient, mapName: string, nationIds: number[]): Promise => { + const [map, cities, diplomacy] = await Promise.all([ + loadMapDefinitionByName(mapName), + db.city.findMany({ + select: { id: true, nationId: true, frontState: true }, + }), + db.diplomacy.findMany({ + select: { srcNationId: true, destNationId: true, stateCode: true, term: true }, + }), + ]); + const connections = new Map(map.cities.map((city) => [city.id, city.connections])); + const patches = buildNationFrontStatePatches({ + cities, + diplomacy: diplomacy.map((entry) => ({ + fromNationId: entry.srcNationId, + toNationId: entry.destNationId, + state: entry.stateCode, + term: entry.term, + })), + connections, + nationIds, + }); + for (const patch of patches) { + await db.city.update({ + where: { id: patch.id }, + data: { frontState: patch.frontState }, + }); + } +}; + +const buildFailureLog = (generalId: number, reason: string, actionName: string, response: boolean): LogEntryDraft[] => { + const logger = new ActionLogger({ generalId }); + logger.pushGeneralActionLog( + response ? `${reason} ${actionName} 실패` : `${reason} ${actionName} 거절 불가.`, + LogFormat.PLAIN + ); + return logger.flush(); +}; + +export interface DiplomaticMessageResponseResult { + result: boolean; + reason: string; + affectedMailboxes: number[]; +} + +export const respondToDiplomaticMessage = async (options: { + db: DatabaseClient; + actor: GeneralRow; + messageId: number; + response: boolean; +}): Promise => { + const { db, actor, messageId, response } = options; + const message = await fetchMessageByIdForUpdate(db, messageId); + if (!message) { + throw new TRPCError({ code: 'NOT_FOUND', message: '존재하지 않는 메시지입니다.' }); + } + + const world = await db.worldState.findFirst({ + select: { currentYear: true, currentMonth: true, config: true }, + }); + if (!world) { + throw new TRPCError({ code: 'PRECONDITION_FAILED', message: '게임 상태가 없습니다.' }); + } + const now = new Date(); + const action = parseAction(message.payload.option?.action); + if (message.msgType !== 'diplomacy' || !action || message.payload.option?.used) { + throw new TRPCError({ code: 'BAD_REQUEST', message: '응답할 수 없는 메시지입니다.' }); + } + const actionName = ACTION_NAMES[action]; + const fail = async (reason: string): Promise => { + await persistLogs( + db, + buildFailureLog(actor.id, reason, actionName, response), + world.currentYear, + world.currentMonth, + now + ); + return { result: false, reason, affectedMailboxes: [] }; + }; + + const actorNationId = actor.nationId; + if ( + actorNationId <= 0 || + message.mailbox !== MESSAGE_MAILBOX_NATIONAL_BASE + actorNationId || + message.payload.dest.nationId !== actorNationId + ) { + return fail('송신자가 외교서신을 처리할 수 없습니다.'); + } + + const proposerNationId = message.payload.src.nationId; + const proposerGeneralId = message.payload.src.generalId; + if ( + proposerNationId <= 0 || + proposerNationId === actorNationId || + proposerGeneralId <= 0 || + proposerGeneralId === actor.id + ) { + return fail('유효하지 않은 외교서신입니다.'); + } + + await db.$queryRaw` + SELECT id + FROM nation + WHERE id = ${actorNationId} + FOR UPDATE + `; + const actorNation = await db.nation.findUnique({ where: { id: actorNationId } }); + if (!actorNation) { + return fail('해당 국가의 외교권자가 아닙니다.'); + } + if (actor.officerLevel <= 4) { + return fail('수뇌가 아닙니다.'); + } + if (resolveNationPermission(actor, actorNation.meta, false) < 4) { + return fail('해당 국가의 외교권자가 아닙니다.'); + } + + if (!response) { + const actorLogger = new ActionLogger({ generalId: actor.id }); + const proposerLogger = new ActionLogger({ generalId: proposerGeneralId }); + const receiverNationName = message.payload.dest.nationName; + const receiverJosaYi = JosaUtil.pick(receiverNationName, '이'); + actorLogger.pushGeneralActionLog( + `${message.payload.src.nationName}의 ${actionName} 제안을 거절했습니다.`, + LogFormat.PLAIN + ); + proposerLogger.pushGeneralActionLog( + `${receiverNationName}${receiverJosaYi} ${actionName} 제안을 거절했습니다.`, + LogFormat.PLAIN + ); + await persistLogs( + db, + [...actorLogger.flush(), ...proposerLogger.flush()], + world.currentYear, + world.currentMonth, + now + ); + await invalidateMessages(db, [message.id]); + return { + result: true, + reason: 'success', + affectedMailboxes: [MESSAGE_MAILBOX_NATIONAL_BASE + actorNationId], + }; + } + + await db.$queryRaw` + SELECT id + FROM nation + WHERE id = ${proposerNationId} + FOR UPDATE + `; + await db.$queryRaw` + SELECT id + FROM diplomacy + WHERE (src_nation_id = ${actorNationId} AND dest_nation_id = ${proposerNationId}) + OR (src_nation_id = ${proposerNationId} AND dest_nation_id = ${actorNationId}) + ORDER BY id + FOR UPDATE + `; + + const [proposerNation, proposer, actorCity, actorDiplomacy, reverseDiplomacy] = await Promise.all([ + db.nation.findUnique({ where: { id: proposerNationId } }), + db.general.findUnique({ where: { id: proposerGeneralId } }), + db.city.findUnique({ where: { id: actor.cityId } }), + db.diplomacy.findUnique({ + where: { + srcNationId_destNationId: { + srcNationId: actorNationId, + destNationId: proposerNationId, + }, + }, + }), + db.diplomacy.findUnique({ + where: { + srcNationId_destNationId: { + srcNationId: proposerNationId, + destNationId: actorNationId, + }, + }, + }), + ]); + + if ( + !proposerNation || + !proposer || + proposer.nationId !== proposerNationId || + !actorDiplomacy || + !reverseDiplomacy + ) { + return fail('제의 장수가 국가 소속이 아닙니다'); + } + + const invalidStateReason = + action === 'noAggression' + ? actorDiplomacy.stateCode === 0 + ? '아국과 이미 교전중입니다.' + : actorDiplomacy.stateCode === 1 + ? '아국과 이미 선포중입니다.' + : null + : action === 'cancelNA' + ? actorDiplomacy.stateCode === 7 + ? null + : '불가침 중인 상대국에게만 가능합니다.' + : actorDiplomacy.stateCode === 0 || actorDiplomacy.stateCode === 1 + ? null + : '상대국과 선포, 전쟁중이지 않습니다.'; + if (invalidStateReason) { + return fail(invalidStateReason); + } + + const treatyYear = parseInteger(message.payload.option?.year); + const treatyMonth = parseInteger(message.payload.option?.month); + if (action === 'noAggression') { + if (!actorCity || actorCity.nationId !== actorNationId) { + return fail('아국이 아닙니다.'); + } + if (!actorCity.supplyState) { + return fail('고립된 도시입니다.'); + } + if ( + treatyYear === null || + treatyMonth === null || + treatyMonth < 1 || + treatyMonth > 12 || + treatyYear * 12 + treatyMonth <= world.currentYear * 12 + world.currentMonth - 1 + ) { + return fail('이미 기한이 지났습니다.'); + } + } + + const resolution = resolveInstantDiplomacyResponse( + { + actor: { id: actor.id, name: actor.name, nationId: actorNationId }, + actorNation: toLogicNation(actorNation), + proposer: { id: proposer.id, name: proposer.name, nationId: proposerNationId }, + proposerNation: toLogicNation(proposerNation), + currentYear: world.currentYear, + currentMonth: world.currentMonth, + }, + { + action, + ...(action === 'noAggression' ? { treatyYear: treatyYear!, treatyMonth: treatyMonth! } : {}), + } + ); + await persistEffects(db, resolution.effects, world.currentYear, world.currentMonth, now); + if (resolution.refreshFront) { + const worldConfig = asRecord(world.config); + const environment = asRecord(worldConfig.environment); + const mapName = typeof environment.mapName === 'string' ? environment.mapName : 'che'; + await refreshFrontStates(db, mapName, [actorNationId, proposerNationId]); + } + + const proposerMessageNationName = message.payload.src.nationName; + const actorMessageNationName = message.payload.dest.nationName; + const proposerJosaYi = JosaUtil.pick(proposerMessageNationName, '이'); + const resultText = + `【외교】${world.currentYear}년 ${world.currentMonth}월: ` + + `${proposerMessageNationName}${proposerJosaYi} ${actorMessageNationName}에게 제안한 ${resolution.diplomacyDetail}`; + const actorTarget = { + ...message.payload.dest, + generalId: actor.id, + generalName: actor.name, + }; + const proposerTarget = message.payload.src; + const draftBase = { + src: actorTarget, + dest: proposerTarget, + text: resultText, + time: now, + validUntil: new Date('9999-12-31T00:00:00Z'), + option: { + delete: message.id, + silence: true, + deletable: false, + }, + }; + + await invalidateMessages(db, [message.id]); + const store = { + insertMessage: (draft: MessageRecordDraft) => insertMessage(db, draft), + }; + await sendMessage(store, { ...draftBase, msgType: 'national' } satisfies MessageDraft); + await sendMessage(store, { ...draftBase, msgType: 'diplomacy' } satisfies MessageDraft); + + return { + result: true, + reason: 'success', + affectedMailboxes: [ + MESSAGE_MAILBOX_NATIONAL_BASE + actorNationId, + MESSAGE_MAILBOX_NATIONAL_BASE + proposerNationId, + ], + }; +}; diff --git a/app/game-api/src/messages/store.ts b/app/game-api/src/messages/store.ts index 171cb11..98aec9b 100644 --- a/app/game-api/src/messages/store.ts +++ b/app/game-api/src/messages/store.ts @@ -140,6 +140,25 @@ export const fetchMessageById = async (db: DatabaseClient, id: number): Promise< }; }; +export const fetchMessageByIdForUpdate = async (db: DatabaseClient, id: number): Promise => { + const rows = await db.$queryRaw` + SELECT id, mailbox, type, src, dest, time, valid_until, message + FROM message + WHERE id = ${id} AND valid_until > NOW() + LIMIT 1 + FOR UPDATE + `; + const row = rows[0]; + if (!row) return null; + return { + id: row.id, + mailbox: row.mailbox, + msgType: row.type, + time: new Date(row.time), + payload: parsePayload(row.message), + }; +}; + export const invalidateMessages = async (db: DatabaseClient, ids: number[]): Promise => { const uniqueIds = Array.from(new Set(ids.filter((id) => Number.isInteger(id) && id > 0))); if (uniqueIds.length === 0) return; diff --git a/app/game-api/src/router/messages/index.ts b/app/game-api/src/router/messages/index.ts index a8d82b6..16a3dcf 100644 --- a/app/game-api/src/router/messages/index.ts +++ b/app/game-api/src/router/messages/index.ts @@ -22,6 +22,7 @@ import { import { publishRealtimeEvent } from '../../realtime/publisher.js'; import { getOwnedGeneral } from '../shared/general.js'; import { resolveNationPermission } from '../nation/shared.js'; +import { respondToDiplomaticMessage } from '../../messages/diplomaticResponse.js'; const zMessageType = z.enum(['private', 'public', 'national', 'diplomacy']); @@ -45,8 +46,8 @@ export const messagesRouter = router({ diplomacy: MESSAGE_MAILBOX_NATIONAL_BASE + nationId, } satisfies Record; - const [privateMessages, publicMessages, nationalMessages, diplomacyMessages, readState] = await Promise.all( - [ + const [privateMessages, publicMessages, nationalMessages, diplomacyMessages, readState, nation] = + await Promise.all([ fetchMessagesFromMailbox({ db: ctx.db, mailbox: mailboxes.private, @@ -76,8 +77,13 @@ export const messagesRouter = router({ fromSeq: sequence, }), ctx.db.messageReadState.findUnique({ where: { generalId: general.id } }), - ] - ); + nationId > 0 + ? ctx.db.nation.findUnique({ + where: { id: nationId }, + select: { meta: true }, + }) + : null, + ]); const messageBuckets: Record = { private: privateMessages, @@ -122,6 +128,10 @@ export const messagesRouter = router({ sequence: nextSequence, nationId: nationId, generalName: general.name, + canRespondDiplomacy: + general.officerLevel > 4 && + nation !== null && + resolveNationPermission(general, nation.meta, false) >= 4, latestRead: { diplomacy: readState?.latestDiplomacyMessage ?? 0, private: readState?.latestPrivateMessage ?? 0, @@ -235,6 +245,40 @@ export const messagesRouter = router({ await invalidateMessages(ctx.db, ids); return { ok: true, deletedIds: ids }; }), + respond: authedProcedure + .input( + z.object({ + generalId: z.number().int().positive(), + messageId: z.number().int().positive(), + response: z.boolean(), + }) + ) + .mutation(async ({ ctx, input }) => { + const general = await getOwnedGeneral(ctx, input.generalId); + const result = await respondToDiplomaticMessage({ + db: ctx.db, + actor: general, + messageId: input.messageId, + response: input.response, + }); + if (result.result) { + for (const mailbox of result.affectedMailboxes) { + try { + await publishRealtimeEvent(ctx.redis, ctx.profile.name, { + type: 'messageCreated', + at: new Date().toISOString(), + mailbox, + msgType: 'diplomacy', + messageId: input.messageId, + senderId: general.id, + }); + } catch { + // 실시간 알림 실패는 외교 응답 실패로 취급하지 않는다. + } + } + } + return { result: result.result, reason: result.reason }; + }), getOld: authedProcedure .input( z.object({ diff --git a/app/game-api/test/messagesRouter.test.ts b/app/game-api/test/messagesRouter.test.ts index 55fda7c..9ba2940 100644 --- a/app/game-api/test/messagesRouter.test.ts +++ b/app/game-api/test/messagesRouter.test.ts @@ -78,6 +78,25 @@ describe('messages router missing-flow compatibility', () => { const result = await caller.messages.getRecent({ generalId: general.id }); expect(result.latestRead).toEqual({ private: 11, diplomacy: 13 }); + expect(result.canRespondDiplomacy).toBe(false); + }); + + it('marks the current ruler as able to answer diplomacy prompts', async () => { + const ruler = { ...general, officerLevel: 12 }; + const { caller } = buildContext({ + general: { + findUnique: vi.fn(async () => ruler), + findMany: vi.fn(async () => []), + }, + nation: { + findMany: vi.fn(async () => []), + findUnique: vi.fn(async () => ({ meta: {} })), + }, + }); + + const result = await caller.messages.getRecent({ generalId: ruler.id }); + + expect(result.canRespondDiplomacy).toBe(true); }); it('persists latest-read updates through the monotonic upsert', async () => { @@ -166,4 +185,336 @@ describe('messages router missing-flow compatibility', () => { code: 'FORBIDDEN', }); }); + + const buildDiplomaticContext = (options?: { + action?: 'noAggression' | 'cancelNA' | 'stopWar'; + actorNationId?: number; + mailboxNationId?: number; + proposerNationId?: number; + proposerCurrentNationId?: number; + diplomacyState?: number; + response?: boolean; + cities?: Array<{ id: number; nationId: number; frontState: number }>; + }) => { + const action = options?.action ?? 'noAggression'; + const actorNationId = options?.actorNationId ?? 1; + const mailboxNationId = options?.mailboxNationId ?? actorNationId; + const proposerNationId = options?.proposerNationId ?? 2; + const actor = { + ...general, + cityId: 10, + nationId: actorNationId, + officerLevel: 12, + meta: {}, + penalty: {}, + } as GeneralRow; + const proposer = { + ...general, + id: 8, + userId: 'user-8', + name: '제안자', + cityId: 20, + nationId: options?.proposerCurrentNationId ?? proposerNationId, + officerLevel: 12, + meta: {}, + penalty: {}, + } as GeneralRow; + const messageRow = { + id: 31, + mailbox: 9000 + mailboxNationId, + type: 'diplomacy', + src: 9000 + proposerNationId, + dest: 9000 + actorNationId, + time: new Date(), + valid_until: new Date('9999-12-31T00:00:00Z'), + message: { + src: { + generalId: proposer.id, + generalName: proposer.name, + nationId: proposerNationId, + nationName: '촉', + color: '#000', + icon: '', + }, + dest: { + generalId: actor.id, + generalName: actor.name, + nationId: actorNationId, + nationName: '위', + color: '#fff', + icon: '', + }, + text: '외교 제안', + option: { + action, + ...(action === 'noAggression' ? { year: 201, month: 2 } : {}), + }, + }, + }; + let rawCall = 0; + let insertedId = 100; + const queryRaw = vi.fn(async () => { + rawCall += 1; + if (rawCall === 1) { + return [messageRow]; + } + if (rawCall >= 4) { + insertedId += 1; + return [{ id: insertedId }]; + } + return []; + }); + const diplomacyState = options?.diplomacyState ?? (action === 'cancelNA' ? 7 : action === 'stopWar' ? 0 : 2); + const diplomacyRows = [ + { + id: 1, + srcNationId: actorNationId, + destNationId: proposerNationId, + stateCode: diplomacyState, + term: 6, + isDead: false, + isShowing: true, + meta: {}, + createdAt: new Date(), + }, + { + id: 2, + srcNationId: proposerNationId, + destNationId: actorNationId, + stateCode: diplomacyState, + term: 6, + isDead: false, + isShowing: true, + meta: {}, + createdAt: new Date(), + }, + ]; + const diplomacyUpdate = vi.fn( + async ({ + where, + data, + }: { + where: { srcNationId_destNationId: { srcNationId: number; destNationId: number } }; + data: { stateCode?: number; term?: number }; + }) => { + const row = diplomacyRows.find( + (entry) => + entry.srcNationId === where.srcNationId_destNationId.srcNationId && + entry.destNationId === where.srcNationId_destNationId.destNationId + ); + if (row) { + if (data.stateCode !== undefined) row.stateCode = data.stateCode; + if (data.term !== undefined) row.term = data.term; + } + return {}; + } + ); + const nationUpdate = vi.fn(async () => ({})); + const logCreateMany = vi.fn(async () => ({ count: 1 })); + const messageUpdateMany = vi.fn(async () => ({ count: 1 })); + const cityUpdate = 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, + }); + return { + caller, + actor, + proposer, + queryRaw, + diplomacyUpdate, + nationUpdate, + logCreateMany, + messageUpdateMany, + cityUpdate, + }; + }; + + it('accepts a diplomatic prompt atomically and applies the legacy non-aggression effects', async () => { + const setup = buildDiplomaticContext(); + + const result = await setup.caller.messages.respond({ + generalId: setup.actor.id, + messageId: 31, + response: true, + }); + + expect(result).toEqual({ result: true, reason: 'success' }); + expect(setup.diplomacyUpdate).toHaveBeenCalledTimes(2); + expect(setup.nationUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: 2 }, + data: { + meta: { + recv_assist: { n1: [1, 50] }, + resp_assist: { n1: [1, 50] }, + }, + }, + }) + ); + expect(setup.messageUpdateMany).toHaveBeenCalledWith({ + where: { id: { in: [31] } }, + data: { validUntil: expect.any(Date) }, + }); + expect(setup.queryRaw).toHaveBeenCalledTimes(8); + }); + + it('declines a diplomatic prompt without changing diplomacy', async () => { + const setup = buildDiplomaticContext({ action: 'stopWar' }); + + const result = await setup.caller.messages.respond({ + generalId: setup.actor.id, + messageId: 31, + response: false, + }); + + expect(result).toEqual({ result: true, reason: 'success' }); + expect(setup.diplomacyUpdate).not.toHaveBeenCalled(); + expect(setup.messageUpdateMany).toHaveBeenCalledOnce(); + expect(setup.logCreateMany).toHaveBeenCalledOnce(); + }); + + it.each([ + ['cancelNA' as const, 7], + ['stopWar' as const, 0], + ])('accepts the %s prompt through the same runtime path', async (action, diplomacyState) => { + const setup = buildDiplomaticContext({ + action, + diplomacyState, + ...(action === 'stopWar' + ? { + cities: [ + { id: 1, nationId: 1, frontState: 3 }, + { id: 9, nationId: 2, frontState: 3 }, + ], + } + : {}), + }); + + const result = await setup.caller.messages.respond({ + generalId: setup.actor.id, + messageId: 31, + response: true, + }); + + expect(result).toEqual({ result: true, reason: 'success' }); + expect(setup.diplomacyUpdate).toHaveBeenCalledTimes(2); + if (action === 'stopWar') { + expect(setup.cityUpdate).toHaveBeenCalledTimes(2); + expect(setup.cityUpdate).toHaveBeenCalledWith({ + where: { id: 1 }, + data: { frontState: 0 }, + }); + expect(setup.cityUpdate).toHaveBeenCalledWith({ + where: { id: 9 }, + data: { frontState: 0 }, + }); + } + }); + + it('rejects a prompt when the proposer has left the proposing nation', async () => { + const setup = buildDiplomaticContext({ proposerCurrentNationId: 3 }); + + const result = await setup.caller.messages.respond({ + generalId: setup.actor.id, + messageId: 31, + response: true, + }); + + expect(result).toEqual({ result: false, reason: '제의 장수가 국가 소속이 아닙니다' }); + expect(setup.diplomacyUpdate).not.toHaveBeenCalled(); + expect(setup.messageUpdateMany).not.toHaveBeenCalled(); + expect(setup.logCreateMany).toHaveBeenCalledOnce(); + }); + + it('does not let another nation process the diplomatic inbox row', async () => { + const setup = buildDiplomaticContext({ mailboxNationId: 2 }); + + const result = await setup.caller.messages.respond({ + generalId: setup.actor.id, + messageId: 31, + response: true, + }); + + expect(result).toEqual({ + result: false, + reason: '송신자가 외교서신을 처리할 수 없습니다.', + }); + expect(setup.diplomacyUpdate).not.toHaveBeenCalled(); + expect(setup.messageUpdateMany).not.toHaveBeenCalled(); + }); }); diff --git a/app/game-engine/src/turn/frontStateHandler.ts b/app/game-engine/src/turn/frontStateHandler.ts index 485f6d1..4ffaeaa 100644 --- a/app/game-engine/src/turn/frontStateHandler.ts +++ b/app/game-engine/src/turn/frontStateHandler.ts @@ -1,4 +1,4 @@ -import type { City, MapDefinition, Nation } from '@sammo-ts/logic'; +import { buildNationFrontStatePatches, type City, type MapDefinition, type Nation } from '@sammo-ts/logic'; import type { InMemoryTurnWorld, TurnCalendarHandler } from './inMemoryWorld.js'; import type { TurnDiplomacy } from './types.js'; @@ -12,92 +12,6 @@ const buildConnectionMap = (map: MapDefinition): ConnectionMap => { return connectionMap; }; -const collectAdjacentCities = (cityIds: number[], connectionMap: ConnectionMap, bucket: Set): void => { - for (const cityId of cityIds) { - const neighbors = connectionMap.get(cityId) ?? []; - for (const neighborId of neighbors) { - bucket.add(neighborId); - } - } -}; - -const collectNationCityIds = (cities: City[]): Map => { - const map = new Map(); - for (const city of cities) { - const list = map.get(city.nationId) ?? []; - list.push(city.id); - map.set(city.nationId, list); - } - return map; -}; - -const collectDiplomacyTargets = (entries: TurnDiplomacy[], nationId: number): { - warTargets: number[]; - declareTargets: number[]; -} => { - const warTargets: number[] = []; - const declareTargets: number[] = []; - for (const entry of entries) { - if (entry.fromNationId !== nationId) { - continue; - } - if (entry.state === 0) { - warTargets.push(entry.toNationId); - } else if (entry.state === 1 && entry.term <= 5) { - declareTargets.push(entry.toNationId); - } - } - return { warTargets, declareTargets }; -}; - -const resolveNationFrontStates = ( - nationId: number, - connectionMap: ConnectionMap, - cityIdsByNation: Map, - diplomacy: TurnDiplomacy[] -): Map => { - const frontStates = new Map(); - if (nationId <= 0) { - return frontStates; - } - - const { warTargets, declareTargets } = collectDiplomacyTargets(diplomacy, nationId); - const adj3 = new Set(); - const adj2 = new Set(); - const adj1 = new Set(); - - for (const targetNationId of warTargets) { - const targetCities = cityIdsByNation.get(targetNationId) ?? []; - collectAdjacentCities(targetCities, connectionMap, adj3); - } - - for (const targetNationId of declareTargets) { - const targetCities = cityIdsByNation.get(targetNationId) ?? []; - collectAdjacentCities(targetCities, connectionMap, adj1); - } - - if (adj3.size === 0 && adj1.size === 0) { - const neutralCities = cityIdsByNation.get(0) ?? []; - collectAdjacentCities(neutralCities, connectionMap, adj2); - } - - const nationCityIds = cityIdsByNation.get(nationId) ?? []; - for (const cityId of nationCityIds) { - let nextFrontState = 0; - if (adj1.has(cityId)) { - nextFrontState = 1; - } - if (adj2.has(cityId)) { - nextFrontState = 2; - } - if (adj3.has(cityId)) { - nextFrontState = 3; - } - frontStates.set(cityId, nextFrontState); - } - return frontStates; -}; - export const buildFrontStatePatches = (options: { worldView: { listCities(): City[]; @@ -114,32 +28,19 @@ export const buildFrontStatePatches = (options: { if (connectionMap.size === 0) { return []; } - const cities = options.worldView.listCities(); - const cityIdsByNation = collectNationCityIds(cities); - const diplomacy = options.worldView.listDiplomacy(); - const cityMap = new Map(); - for (const city of cities) { - cityMap.set(city.id, city); - } - const nationList = options.nationIds - ? options.worldView - .listNations() - .filter((nation) => options.nationIds?.includes(nation.id)) + ? options.worldView.listNations().filter((nation) => options.nationIds?.includes(nation.id)) : options.worldView.listNations(); - const nations = nationList.filter((nation) => nation.level > 0); - const patches: Array<{ id: number; patch: Partial }> = []; - for (const nation of nations) { - const frontStates = resolveNationFrontStates(nation.id, connectionMap, cityIdsByNation, diplomacy); - for (const [cityId, nextFrontState] of frontStates) { - const city = cityMap.get(cityId); - if (!city || city.frontState === nextFrontState) { - continue; - } - patches.push({ id: cityId, patch: { frontState: nextFrontState } }); - } - } - return patches; + const nationIds = nationList.filter((nation) => nation.level > 0).map((nation) => nation.id); + return buildNationFrontStatePatches({ + cities: options.worldView.listCities(), + diplomacy: options.worldView.listDiplomacy(), + connections: connectionMap, + nationIds, + }).map((patch) => ({ + id: patch.id, + patch: { frontState: patch.frontState }, + })); }; export const createFrontStateHandler = (options: { diff --git a/app/game-frontend/src/components/main/MessagePanel.vue b/app/game-frontend/src/components/main/MessagePanel.vue index ebd8355..d97f9cd 100644 --- a/app/game-frontend/src/components/main/MessagePanel.vue +++ b/app/game-frontend/src/components/main/MessagePanel.vue @@ -8,6 +8,7 @@ interface MessageEntry { text: string; time: string; msgType: MessageType; + option?: Record | null; } interface MessageBucket { @@ -23,6 +24,7 @@ const props = defineProps<{ targetMailbox: number; draftText: string; mailboxOptions: Array<{ label: string; value: number; disabled?: boolean }>; + canRespondDiplomacy: boolean; }>(); const emit = defineEmits<{ @@ -31,6 +33,7 @@ const emit = defineEmits<{ (event: 'send'): void; (event: 'refresh'): void; (event: 'load-older', type: MessageType): void; + (event: 'respond', messageId: number, response: boolean): void; }>(); const messageTabs: Array<{ key: MessageType; label: string }> = [ @@ -53,6 +56,19 @@ const setMailbox = (value: string) => { const parsed = Number(value); emit('update:targetMailbox', Number.isFinite(parsed) ? parsed : 0); }; + +const isDiplomacyPrompt = (message: MessageEntry): boolean => + message.msgType === 'diplomacy' && + (message.option?.action === 'noAggression' || + message.option?.action === 'cancelNA' || + message.option?.action === 'stopWar'); + +const respond = (messageId: number, response: boolean) => { + if (!window.confirm(response ? '수락하시겠습니까?' : '거절하시겠습니까?')) { + return; + } + emit('respond', messageId, response); +};