Complete instant diplomacy response parity

This commit is contained in:
2026-07-26 03:10:41 +00:00
parent b3f4734e80
commit 9885bf6905
23 changed files with 2072 additions and 305 deletions
+37
View File
@@ -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<string, MapDefinition>();
export const loadMapDefinitionByName = async (mapName: string): Promise<MapDefinition> => {
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;
};
@@ -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<InstantDiplomacyResponseAction, string> = {
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<void> => {
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<void> => {
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<void> => {
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<number, readonly number[]>(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<DiplomaticMessageResponseResult> => {
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<DiplomaticMessageResponseResult> => {
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(
`<D>${message.payload.src.nationName}</>의 ${actionName} 제안을 거절했습니다.`,
LogFormat.PLAIN
);
proposerLogger.pushGeneralActionLog(
`<Y>${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,
],
};
};
+19
View File
@@ -140,6 +140,25 @@ export const fetchMessageById = async (db: DatabaseClient, id: number): Promise<
};
};
export const fetchMessageByIdForUpdate = async (db: DatabaseClient, id: number): Promise<StoredMessage | null> => {
const rows = await db.$queryRaw<MessageRow[]>`
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<void> => {
const uniqueIds = Array.from(new Set(ids.filter((id) => Number.isInteger(id) && id > 0)));
if (uniqueIds.length === 0) return;
+48 -4
View File
@@ -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<MessageType, number>;
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<MessageType, MessageView[]> = {
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({