Complete instant diplomacy response parity
This commit is contained in:
@@ -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,
|
||||||
|
],
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -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> => {
|
export const invalidateMessages = async (db: DatabaseClient, ids: number[]): Promise<void> => {
|
||||||
const uniqueIds = Array.from(new Set(ids.filter((id) => Number.isInteger(id) && id > 0)));
|
const uniqueIds = Array.from(new Set(ids.filter((id) => Number.isInteger(id) && id > 0)));
|
||||||
if (uniqueIds.length === 0) return;
|
if (uniqueIds.length === 0) return;
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
import { publishRealtimeEvent } from '../../realtime/publisher.js';
|
import { publishRealtimeEvent } from '../../realtime/publisher.js';
|
||||||
import { getOwnedGeneral } from '../shared/general.js';
|
import { getOwnedGeneral } from '../shared/general.js';
|
||||||
import { resolveNationPermission } from '../nation/shared.js';
|
import { resolveNationPermission } from '../nation/shared.js';
|
||||||
|
import { respondToDiplomaticMessage } from '../../messages/diplomaticResponse.js';
|
||||||
|
|
||||||
const zMessageType = z.enum(['private', 'public', 'national', 'diplomacy']);
|
const zMessageType = z.enum(['private', 'public', 'national', 'diplomacy']);
|
||||||
|
|
||||||
@@ -45,8 +46,8 @@ export const messagesRouter = router({
|
|||||||
diplomacy: MESSAGE_MAILBOX_NATIONAL_BASE + nationId,
|
diplomacy: MESSAGE_MAILBOX_NATIONAL_BASE + nationId,
|
||||||
} satisfies Record<MessageType, number>;
|
} satisfies Record<MessageType, number>;
|
||||||
|
|
||||||
const [privateMessages, publicMessages, nationalMessages, diplomacyMessages, readState] = await Promise.all(
|
const [privateMessages, publicMessages, nationalMessages, diplomacyMessages, readState, nation] =
|
||||||
[
|
await Promise.all([
|
||||||
fetchMessagesFromMailbox({
|
fetchMessagesFromMailbox({
|
||||||
db: ctx.db,
|
db: ctx.db,
|
||||||
mailbox: mailboxes.private,
|
mailbox: mailboxes.private,
|
||||||
@@ -76,8 +77,13 @@ export const messagesRouter = router({
|
|||||||
fromSeq: sequence,
|
fromSeq: sequence,
|
||||||
}),
|
}),
|
||||||
ctx.db.messageReadState.findUnique({ where: { generalId: general.id } }),
|
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[]> = {
|
const messageBuckets: Record<MessageType, MessageView[]> = {
|
||||||
private: privateMessages,
|
private: privateMessages,
|
||||||
@@ -122,6 +128,10 @@ export const messagesRouter = router({
|
|||||||
sequence: nextSequence,
|
sequence: nextSequence,
|
||||||
nationId: nationId,
|
nationId: nationId,
|
||||||
generalName: general.name,
|
generalName: general.name,
|
||||||
|
canRespondDiplomacy:
|
||||||
|
general.officerLevel > 4 &&
|
||||||
|
nation !== null &&
|
||||||
|
resolveNationPermission(general, nation.meta, false) >= 4,
|
||||||
latestRead: {
|
latestRead: {
|
||||||
diplomacy: readState?.latestDiplomacyMessage ?? 0,
|
diplomacy: readState?.latestDiplomacyMessage ?? 0,
|
||||||
private: readState?.latestPrivateMessage ?? 0,
|
private: readState?.latestPrivateMessage ?? 0,
|
||||||
@@ -235,6 +245,40 @@ export const messagesRouter = router({
|
|||||||
await invalidateMessages(ctx.db, ids);
|
await invalidateMessages(ctx.db, ids);
|
||||||
return { ok: true, deletedIds: 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
|
getOld: authedProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
|
|||||||
@@ -78,6 +78,25 @@ describe('messages router missing-flow compatibility', () => {
|
|||||||
const result = await caller.messages.getRecent({ generalId: general.id });
|
const result = await caller.messages.getRecent({ generalId: general.id });
|
||||||
|
|
||||||
expect(result.latestRead).toEqual({ private: 11, diplomacy: 13 });
|
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 () => {
|
it('persists latest-read updates through the monotonic upsert', async () => {
|
||||||
@@ -166,4 +185,336 @@ describe('messages router missing-flow compatibility', () => {
|
|||||||
code: 'FORBIDDEN',
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 { InMemoryTurnWorld, TurnCalendarHandler } from './inMemoryWorld.js';
|
||||||
import type { TurnDiplomacy } from './types.js';
|
import type { TurnDiplomacy } from './types.js';
|
||||||
|
|
||||||
@@ -12,92 +12,6 @@ const buildConnectionMap = (map: MapDefinition): ConnectionMap => {
|
|||||||
return connectionMap;
|
return connectionMap;
|
||||||
};
|
};
|
||||||
|
|
||||||
const collectAdjacentCities = (cityIds: number[], connectionMap: ConnectionMap, bucket: Set<number>): void => {
|
|
||||||
for (const cityId of cityIds) {
|
|
||||||
const neighbors = connectionMap.get(cityId) ?? [];
|
|
||||||
for (const neighborId of neighbors) {
|
|
||||||
bucket.add(neighborId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const collectNationCityIds = (cities: City[]): Map<number, number[]> => {
|
|
||||||
const map = new Map<number, number[]>();
|
|
||||||
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<number, number[]>,
|
|
||||||
diplomacy: TurnDiplomacy[]
|
|
||||||
): Map<number, number> => {
|
|
||||||
const frontStates = new Map<number, number>();
|
|
||||||
if (nationId <= 0) {
|
|
||||||
return frontStates;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { warTargets, declareTargets } = collectDiplomacyTargets(diplomacy, nationId);
|
|
||||||
const adj3 = new Set<number>();
|
|
||||||
const adj2 = new Set<number>();
|
|
||||||
const adj1 = new Set<number>();
|
|
||||||
|
|
||||||
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: {
|
export const buildFrontStatePatches = (options: {
|
||||||
worldView: {
|
worldView: {
|
||||||
listCities(): City[];
|
listCities(): City[];
|
||||||
@@ -114,32 +28,19 @@ export const buildFrontStatePatches = (options: {
|
|||||||
if (connectionMap.size === 0) {
|
if (connectionMap.size === 0) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
const cities = options.worldView.listCities();
|
|
||||||
const cityIdsByNation = collectNationCityIds(cities);
|
|
||||||
const diplomacy = options.worldView.listDiplomacy();
|
|
||||||
const cityMap = new Map<number, City>();
|
|
||||||
for (const city of cities) {
|
|
||||||
cityMap.set(city.id, city);
|
|
||||||
}
|
|
||||||
|
|
||||||
const nationList = options.nationIds
|
const nationList = options.nationIds
|
||||||
? options.worldView
|
? options.worldView.listNations().filter((nation) => options.nationIds?.includes(nation.id))
|
||||||
.listNations()
|
|
||||||
.filter((nation) => options.nationIds?.includes(nation.id))
|
|
||||||
: options.worldView.listNations();
|
: options.worldView.listNations();
|
||||||
const nations = nationList.filter((nation) => nation.level > 0);
|
const nationIds = nationList.filter((nation) => nation.level > 0).map((nation) => nation.id);
|
||||||
const patches: Array<{ id: number; patch: Partial<City> }> = [];
|
return buildNationFrontStatePatches({
|
||||||
for (const nation of nations) {
|
cities: options.worldView.listCities(),
|
||||||
const frontStates = resolveNationFrontStates(nation.id, connectionMap, cityIdsByNation, diplomacy);
|
diplomacy: options.worldView.listDiplomacy(),
|
||||||
for (const [cityId, nextFrontState] of frontStates) {
|
connections: connectionMap,
|
||||||
const city = cityMap.get(cityId);
|
nationIds,
|
||||||
if (!city || city.frontState === nextFrontState) {
|
}).map((patch) => ({
|
||||||
continue;
|
id: patch.id,
|
||||||
}
|
patch: { frontState: patch.frontState },
|
||||||
patches.push({ id: cityId, patch: { frontState: nextFrontState } });
|
}));
|
||||||
}
|
|
||||||
}
|
|
||||||
return patches;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const createFrontStateHandler = (options: {
|
export const createFrontStateHandler = (options: {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ interface MessageEntry {
|
|||||||
text: string;
|
text: string;
|
||||||
time: string;
|
time: string;
|
||||||
msgType: MessageType;
|
msgType: MessageType;
|
||||||
|
option?: Record<string, unknown> | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MessageBucket {
|
interface MessageBucket {
|
||||||
@@ -23,6 +24,7 @@ const props = defineProps<{
|
|||||||
targetMailbox: number;
|
targetMailbox: number;
|
||||||
draftText: string;
|
draftText: string;
|
||||||
mailboxOptions: Array<{ label: string; value: number; disabled?: boolean }>;
|
mailboxOptions: Array<{ label: string; value: number; disabled?: boolean }>;
|
||||||
|
canRespondDiplomacy: boolean;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -31,6 +33,7 @@ const emit = defineEmits<{
|
|||||||
(event: 'send'): void;
|
(event: 'send'): void;
|
||||||
(event: 'refresh'): void;
|
(event: 'refresh'): void;
|
||||||
(event: 'load-older', type: MessageType): void;
|
(event: 'load-older', type: MessageType): void;
|
||||||
|
(event: 'respond', messageId: number, response: boolean): void;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const messageTabs: Array<{ key: MessageType; label: string }> = [
|
const messageTabs: Array<{ key: MessageType; label: string }> = [
|
||||||
@@ -53,6 +56,19 @@ const setMailbox = (value: string) => {
|
|||||||
const parsed = Number(value);
|
const parsed = Number(value);
|
||||||
emit('update:targetMailbox', Number.isFinite(parsed) ? parsed : 0);
|
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);
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -99,14 +115,20 @@ const setMailbox = (value: string) => {
|
|||||||
<div v-if="props.loading">
|
<div v-if="props.loading">
|
||||||
<SkeletonLines :lines="4" />
|
<SkeletonLines :lines="4" />
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="!props.messages" class="empty">
|
<div v-else-if="!props.messages" class="empty">메시지를 불러오지 못했습니다.</div>
|
||||||
메시지를 불러오지 못했습니다.
|
|
||||||
</div>
|
|
||||||
<div v-else class="message-list">
|
<div v-else class="message-list">
|
||||||
<div v-if="activeMessages.length === 0" class="empty">메시지가 없습니다.</div>
|
<div v-if="activeMessages.length === 0" class="empty">메시지가 없습니다.</div>
|
||||||
<div v-else>
|
<div v-else>
|
||||||
<div v-for="message in activeMessages" :key="message.id" class="message-item">
|
<div v-for="message in activeMessages" :key="message.id" class="message-item">
|
||||||
<div class="text">{{ message.text }}</div>
|
<div class="text">{{ message.text }}</div>
|
||||||
|
<div v-if="isDiplomacyPrompt(message)" class="message-response">
|
||||||
|
<button class="accept" :disabled="!canRespondDiplomacy" @click="respond(message.id, true)">
|
||||||
|
수락
|
||||||
|
</button>
|
||||||
|
<button class="decline" :disabled="!canRespondDiplomacy" @click="respond(message.id, false)">
|
||||||
|
거절
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<div class="time">{{ message.time }}</div>
|
<div class="time">{{ message.time }}</div>
|
||||||
</div>
|
</div>
|
||||||
<button class="load-older" @click="emit('load-older', activeTab)">이전 메시지</button>
|
<button class="load-older" @click="emit('load-older', activeTab)">이전 메시지</button>
|
||||||
@@ -183,6 +205,34 @@ const setMailbox = (value: string) => {
|
|||||||
color: rgba(232, 221, 196, 0.6);
|
color: rgba(232, 221, 196, 0.6);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.message-response {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 4px;
|
||||||
|
margin-top: 5px;
|
||||||
|
margin-right: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-response button {
|
||||||
|
border: 1px solid rgba(201, 164, 90, 0.4);
|
||||||
|
padding: 3px 10px;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-response .accept {
|
||||||
|
color: #8fd18f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-response .decline {
|
||||||
|
color: #e09a9a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-response button:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
.load-older {
|
.load-older {
|
||||||
border: 1px dashed rgba(201, 164, 90, 0.3);
|
border: 1px dashed rgba(201, 164, 90, 0.3);
|
||||||
padding: 6px;
|
padding: 6px;
|
||||||
|
|||||||
@@ -235,6 +235,26 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const respondToMessage = async (messageId: number, response: boolean) => {
|
||||||
|
const id = generalId.value;
|
||||||
|
if (!id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const result = await trpc.messages.respond.mutate({
|
||||||
|
generalId: id,
|
||||||
|
messageId,
|
||||||
|
response,
|
||||||
|
});
|
||||||
|
if (!result.result) {
|
||||||
|
error.value = result.reason;
|
||||||
|
}
|
||||||
|
await refreshMessages();
|
||||||
|
} catch (err) {
|
||||||
|
error.value = resolveErrorMessage(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const setGeneralTurn = async (turnIndex: number, action: string) => {
|
const setGeneralTurn = async (turnIndex: number, action: string) => {
|
||||||
const id = generalId.value;
|
const id = generalId.value;
|
||||||
if (!id) {
|
if (!id) {
|
||||||
@@ -311,7 +331,6 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
let realtimeSource: EventSource | null = null;
|
let realtimeSource: EventSource | null = null;
|
||||||
let realtimeToken: string | null = null;
|
let realtimeToken: string | null = null;
|
||||||
|
|
||||||
@@ -472,6 +491,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
|||||||
refreshMessages,
|
refreshMessages,
|
||||||
sendMessage,
|
sendMessage,
|
||||||
loadOlderMessages,
|
loadOlderMessages,
|
||||||
|
respondToMessage,
|
||||||
setGeneralTurn,
|
setGeneralTurn,
|
||||||
shiftGeneralTurns,
|
shiftGeneralTurns,
|
||||||
setNationTurn,
|
setNationTurn,
|
||||||
|
|||||||
@@ -207,11 +207,13 @@ watch(
|
|||||||
:target-mailbox="targetMailbox"
|
:target-mailbox="targetMailbox"
|
||||||
:draft-text="messageDraftText"
|
:draft-text="messageDraftText"
|
||||||
:mailbox-options="mailboxOptions"
|
:mailbox-options="mailboxOptions"
|
||||||
|
:can-respond-diplomacy="messages?.canRespondDiplomacy ?? false"
|
||||||
@update:target-mailbox="targetMailbox = $event"
|
@update:target-mailbox="targetMailbox = $event"
|
||||||
@update:draft-text="messageDraftText = $event"
|
@update:draft-text="messageDraftText = $event"
|
||||||
@send="dashboard.sendMessage"
|
@send="dashboard.sendMessage"
|
||||||
@load-older="dashboard.loadOlderMessages"
|
@load-older="dashboard.loadOlderMessages"
|
||||||
@refresh="dashboard.refreshMessages"
|
@refresh="dashboard.refreshMessages"
|
||||||
|
@respond="dashboard.respondToMessage"
|
||||||
/>
|
/>
|
||||||
</PanelCard>
|
</PanelCard>
|
||||||
</div>
|
</div>
|
||||||
@@ -240,11 +242,13 @@ watch(
|
|||||||
:target-mailbox="targetMailbox"
|
:target-mailbox="targetMailbox"
|
||||||
:draft-text="messageDraftText"
|
:draft-text="messageDraftText"
|
||||||
:mailbox-options="mailboxOptions"
|
:mailbox-options="mailboxOptions"
|
||||||
|
:can-respond-diplomacy="messages?.canRespondDiplomacy ?? false"
|
||||||
@update:target-mailbox="targetMailbox = $event"
|
@update:target-mailbox="targetMailbox = $event"
|
||||||
@update:draft-text="messageDraftText = $event"
|
@update:draft-text="messageDraftText = $event"
|
||||||
@send="dashboard.sendMessage"
|
@send="dashboard.sendMessage"
|
||||||
@load-older="dashboard.loadOlderMessages"
|
@load-older="dashboard.loadOlderMessages"
|
||||||
@refresh="dashboard.refreshMessages"
|
@refresh="dashboard.refreshMessages"
|
||||||
|
@respond="dashboard.respondToMessage"
|
||||||
/>
|
/>
|
||||||
</PanelCard>
|
</PanelCard>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js';
|
import type { General, GeneralTriggerState, Nation } from '@sammo-ts/logic/domain/entities.js';
|
||||||
import type { Constraint, ConstraintContext } from '@sammo-ts/logic/constraints/types.js';
|
import type { Constraint, ConstraintContext } from '@sammo-ts/logic/constraints/types.js';
|
||||||
import {
|
import {
|
||||||
beChief,
|
beChief,
|
||||||
@@ -7,15 +7,15 @@ import {
|
|||||||
existsDestNation,
|
existsDestNation,
|
||||||
notBeNeutral,
|
notBeNeutral,
|
||||||
occupiedCity,
|
occupiedCity,
|
||||||
reqDestNationValue,
|
|
||||||
suppliedCity,
|
suppliedCity,
|
||||||
} from '@sammo-ts/logic/constraints/presets.js';
|
} from '@sammo-ts/logic/constraints/presets.js';
|
||||||
import { allow, unknownOrDeny } from '@sammo-ts/logic/constraints/helpers.js';
|
import { allow, unknownOrDeny } from '@sammo-ts/logic/constraints/helpers.js';
|
||||||
import type { GeneralActionDefinition } from '@sammo-ts/logic/actions/definition.js';
|
import type { GeneralActionDefinition } from '@sammo-ts/logic/actions/definition.js';
|
||||||
import type { GeneralActionOutcome, GeneralActionResolveContext } from '@sammo-ts/logic/actions/engine.js';
|
import type { GeneralActionOutcome, GeneralActionResolveContext } from '@sammo-ts/logic/actions/engine.js';
|
||||||
import { createDiplomacyPatchEffect, createLogEffect } from '@sammo-ts/logic/actions/engine.js';
|
import {
|
||||||
import { LogCategory, LogFormat, LogScope } from '@sammo-ts/logic/logging/types.js';
|
destGeneralBelongsToDestNation,
|
||||||
import { JosaUtil } from '@sammo-ts/common';
|
resolveInstantDiplomacyResponse,
|
||||||
|
} from '@sammo-ts/logic/diplomacy/instantResponse.js';
|
||||||
|
|
||||||
export interface NonAggressionAcceptArgs {
|
export interface NonAggressionAcceptArgs {
|
||||||
destNationId: number;
|
destNationId: number;
|
||||||
@@ -29,11 +29,11 @@ export interface NonAggressionAcceptContext<
|
|||||||
> extends GeneralActionResolveContext<TriggerState> {
|
> extends GeneralActionResolveContext<TriggerState> {
|
||||||
currentYear: number;
|
currentYear: number;
|
||||||
currentMonth: number;
|
currentMonth: number;
|
||||||
|
destNation: Nation;
|
||||||
|
destGeneral: General<TriggerState>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ACTION_NAME = '불가침 수락';
|
const ACTION_NAME = '불가침 수락';
|
||||||
const DIPLOMACY_NON_AGGRESSION = 7;
|
|
||||||
|
|
||||||
const parseNationId = (raw: unknown): number | null => {
|
const parseNationId = (raw: unknown): number | null => {
|
||||||
if (typeof raw !== 'number' || !Number.isFinite(raw)) {
|
if (typeof raw !== 'number' || !Number.isFinite(raw)) {
|
||||||
return null;
|
return null;
|
||||||
@@ -147,7 +147,7 @@ export class ActionDefinition<
|
|||||||
suppliedCity(),
|
suppliedCity(),
|
||||||
existsDestNation(),
|
existsDestNation(),
|
||||||
existsDestGeneral(),
|
existsDestGeneral(),
|
||||||
reqDestNationValue('level', '국가규모', '>', 0, '상대국 정보가 없습니다.'),
|
destGeneralBelongsToDestNation(),
|
||||||
reqFutureTreatyTerm(),
|
reqFutureTreatyTerm(),
|
||||||
disallowDiplomacyBetweenStatus({
|
disallowDiplomacyBetweenStatus({
|
||||||
0: '아국과 이미 교전중입니다.',
|
0: '아국과 이미 교전중입니다.',
|
||||||
@@ -160,44 +160,29 @@ export class ActionDefinition<
|
|||||||
context: NonAggressionAcceptContext<TriggerState>,
|
context: NonAggressionAcceptContext<TriggerState>,
|
||||||
args: NonAggressionAcceptArgs
|
args: NonAggressionAcceptArgs
|
||||||
): GeneralActionOutcome<TriggerState> {
|
): GeneralActionOutcome<TriggerState> {
|
||||||
const nationId = context.nation?.id;
|
const nation = context.nation;
|
||||||
if (nationId === undefined || nationId <= 0) {
|
if (!nation) {
|
||||||
return {
|
return {
|
||||||
effects: [
|
effects: [],
|
||||||
createLogEffect(`${ACTION_NAME}을 준비했지만 국가 정보가 없습니다.`, {
|
|
||||||
scope: LogScope.GENERAL,
|
|
||||||
category: LogCategory.ACTION,
|
|
||||||
format: LogFormat.MONTH,
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
const resolution = resolveInstantDiplomacyResponse<TriggerState>(
|
||||||
const currentMonth = resolveMonthIndex(context.currentYear, context.currentMonth);
|
{
|
||||||
const targetMonth = args.year * 12 + args.month;
|
actor: context.general,
|
||||||
const term = Math.max(0, targetMonth - currentMonth);
|
actorNation: nation,
|
||||||
const destNationName = String(args.destNationId);
|
proposer: context.destGeneral,
|
||||||
const josaWa = JosaUtil.pick(destNationName, '와');
|
proposerNation: context.destNation,
|
||||||
|
currentYear: context.currentYear,
|
||||||
|
currentMonth: context.currentMonth,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
action: 'noAggression',
|
||||||
|
treatyYear: args.year,
|
||||||
|
treatyMonth: args.month,
|
||||||
|
}
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
effects: [
|
effects: resolution.effects,
|
||||||
createDiplomacyPatchEffect(nationId, args.destNationId, {
|
|
||||||
state: DIPLOMACY_NON_AGGRESSION,
|
|
||||||
term,
|
|
||||||
}),
|
|
||||||
createDiplomacyPatchEffect(args.destNationId, nationId, {
|
|
||||||
state: DIPLOMACY_NON_AGGRESSION,
|
|
||||||
term,
|
|
||||||
}),
|
|
||||||
createLogEffect(
|
|
||||||
`<D><b>${destNationName}</b></>${josaWa} <C>${args.year}</>년 <C>${args.month}</>월까지 불가침에 성공했습니다.`,
|
|
||||||
{
|
|
||||||
scope: LogScope.GENERAL,
|
|
||||||
category: LogCategory.ACTION,
|
|
||||||
format: LogFormat.MONTH,
|
|
||||||
}
|
|
||||||
),
|
|
||||||
],
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js';
|
import type { General, GeneralTriggerState, Nation } from '@sammo-ts/logic/domain/entities.js';
|
||||||
import type { Constraint, ConstraintContext } from '@sammo-ts/logic/constraints/types.js';
|
import type { Constraint, ConstraintContext } from '@sammo-ts/logic/constraints/types.js';
|
||||||
import {
|
import {
|
||||||
allowDiplomacyBetweenStatus,
|
allowDiplomacyBetweenStatus,
|
||||||
@@ -6,21 +6,29 @@ import {
|
|||||||
existsDestGeneral,
|
existsDestGeneral,
|
||||||
existsDestNation,
|
existsDestNation,
|
||||||
notBeNeutral,
|
notBeNeutral,
|
||||||
reqDestNationValue,
|
|
||||||
} from '@sammo-ts/logic/constraints/presets.js';
|
} from '@sammo-ts/logic/constraints/presets.js';
|
||||||
import type { GeneralActionDefinition } from '@sammo-ts/logic/actions/definition.js';
|
import type { GeneralActionDefinition } from '@sammo-ts/logic/actions/definition.js';
|
||||||
import type { GeneralActionOutcome, GeneralActionResolveContext } from '@sammo-ts/logic/actions/engine.js';
|
import type { GeneralActionOutcome, GeneralActionResolveContext } from '@sammo-ts/logic/actions/engine.js';
|
||||||
import { createDiplomacyPatchEffect, createLogEffect } from '@sammo-ts/logic/actions/engine.js';
|
import {
|
||||||
import { LogCategory, LogFormat, LogScope } from '@sammo-ts/logic/logging/types.js';
|
destGeneralBelongsToDestNation,
|
||||||
import { JosaUtil } from '@sammo-ts/common';
|
resolveInstantDiplomacyResponse,
|
||||||
|
} from '@sammo-ts/logic/diplomacy/instantResponse.js';
|
||||||
|
|
||||||
export interface NonAggressionCancelAcceptArgs {
|
export interface NonAggressionCancelAcceptArgs {
|
||||||
destNationId: number;
|
destNationId: number;
|
||||||
destGeneralId: number;
|
destGeneralId: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface NonAggressionCancelAcceptContext<
|
||||||
|
TriggerState extends GeneralTriggerState = GeneralTriggerState,
|
||||||
|
> extends GeneralActionResolveContext<TriggerState> {
|
||||||
|
destNation: Nation;
|
||||||
|
destGeneral: General<TriggerState>;
|
||||||
|
currentYear: number;
|
||||||
|
currentMonth: number;
|
||||||
|
}
|
||||||
|
|
||||||
const ACTION_NAME = '불가침 파기 수락';
|
const ACTION_NAME = '불가침 파기 수락';
|
||||||
const DIPLOMACY_NEUTRAL = 2;
|
|
||||||
const DIPLOMACY_NON_AGGRESSION = 7;
|
const DIPLOMACY_NON_AGGRESSION = 7;
|
||||||
|
|
||||||
const parseNationId = (raw: unknown): number | null => {
|
const parseNationId = (raw: unknown): number | null => {
|
||||||
@@ -40,7 +48,11 @@ const parseGeneralId = (raw: unknown): number | null => {
|
|||||||
// 불가침 파기 수락은 메시지와 연결되는 즉시 국가 커맨드로 사용한다.
|
// 불가침 파기 수락은 메시지와 연결되는 즉시 국가 커맨드로 사용한다.
|
||||||
export class ActionDefinition<
|
export class ActionDefinition<
|
||||||
TriggerState extends GeneralTriggerState = GeneralTriggerState,
|
TriggerState extends GeneralTriggerState = GeneralTriggerState,
|
||||||
> implements GeneralActionDefinition<TriggerState, NonAggressionCancelAcceptArgs> {
|
> implements GeneralActionDefinition<
|
||||||
|
TriggerState,
|
||||||
|
NonAggressionCancelAcceptArgs,
|
||||||
|
NonAggressionCancelAcceptContext<TriggerState>
|
||||||
|
> {
|
||||||
public readonly key = 'che_불가침파기수락';
|
public readonly key = 'che_불가침파기수락';
|
||||||
public readonly name = ACTION_NAME;
|
public readonly name = ACTION_NAME;
|
||||||
|
|
||||||
@@ -60,47 +72,32 @@ export class ActionDefinition<
|
|||||||
notBeNeutral(),
|
notBeNeutral(),
|
||||||
existsDestNation(),
|
existsDestNation(),
|
||||||
existsDestGeneral(),
|
existsDestGeneral(),
|
||||||
reqDestNationValue('level', '국가규모', '>', 0, '상대국 정보가 없습니다.'),
|
destGeneralBelongsToDestNation(),
|
||||||
allowDiplomacyBetweenStatus([DIPLOMACY_NON_AGGRESSION], '불가침 중인 상대국에게만 가능합니다.'),
|
allowDiplomacyBetweenStatus([DIPLOMACY_NON_AGGRESSION], '불가침 중인 상대국에게만 가능합니다.'),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
resolve(
|
resolve(
|
||||||
context: GeneralActionResolveContext<TriggerState>,
|
context: NonAggressionCancelAcceptContext<TriggerState>,
|
||||||
args: NonAggressionCancelAcceptArgs
|
_args: NonAggressionCancelAcceptArgs
|
||||||
): GeneralActionOutcome<TriggerState> {
|
): GeneralActionOutcome<TriggerState> {
|
||||||
const nationId = context.nation?.id;
|
const nation = context.nation;
|
||||||
if (nationId === undefined || nationId <= 0) {
|
if (!nation) {
|
||||||
return {
|
return { effects: [] };
|
||||||
effects: [
|
|
||||||
createLogEffect(`${ACTION_NAME}을 준비했지만 국가 정보가 없습니다.`, {
|
|
||||||
scope: LogScope.GENERAL,
|
|
||||||
category: LogCategory.ACTION,
|
|
||||||
format: LogFormat.MONTH,
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
const resolution = resolveInstantDiplomacyResponse<TriggerState>(
|
||||||
const destNationName = String(args.destNationId);
|
{
|
||||||
const josaWa = JosaUtil.pick(destNationName, '와');
|
actor: context.general,
|
||||||
|
actorNation: nation,
|
||||||
|
proposer: context.destGeneral,
|
||||||
|
proposerNation: context.destNation,
|
||||||
|
currentYear: context.currentYear,
|
||||||
|
currentMonth: context.currentMonth,
|
||||||
|
},
|
||||||
|
{ action: 'cancelNA' }
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
effects: [
|
effects: resolution.effects,
|
||||||
createDiplomacyPatchEffect(nationId, args.destNationId, {
|
|
||||||
state: DIPLOMACY_NEUTRAL,
|
|
||||||
term: 0,
|
|
||||||
}),
|
|
||||||
createDiplomacyPatchEffect(args.destNationId, nationId, {
|
|
||||||
state: DIPLOMACY_NEUTRAL,
|
|
||||||
term: 0,
|
|
||||||
}),
|
|
||||||
createLogEffect(`<D><b>${destNationName}</b></>${josaWa}의 불가침을 파기했습니다.`, {
|
|
||||||
scope: LogScope.GENERAL,
|
|
||||||
category: LogCategory.ACTION,
|
|
||||||
format: LogFormat.MONTH,
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js';
|
import type { General, GeneralTriggerState, Nation } from '@sammo-ts/logic/domain/entities.js';
|
||||||
import type { Constraint, ConstraintContext } from '@sammo-ts/logic/constraints/types.js';
|
import type { Constraint, ConstraintContext } from '@sammo-ts/logic/constraints/types.js';
|
||||||
import {
|
import {
|
||||||
allowDiplomacyBetweenStatus,
|
allowDiplomacyBetweenStatus,
|
||||||
@@ -6,22 +6,29 @@ import {
|
|||||||
existsDestGeneral,
|
existsDestGeneral,
|
||||||
existsDestNation,
|
existsDestNation,
|
||||||
notBeNeutral,
|
notBeNeutral,
|
||||||
reqDestNationValue,
|
|
||||||
} from '@sammo-ts/logic/constraints/presets.js';
|
} from '@sammo-ts/logic/constraints/presets.js';
|
||||||
import type { GeneralActionDefinition } from '@sammo-ts/logic/actions/definition.js';
|
import type { GeneralActionDefinition } from '@sammo-ts/logic/actions/definition.js';
|
||||||
import type { GeneralActionOutcome, GeneralActionResolveContext } from '@sammo-ts/logic/actions/engine.js';
|
import type { GeneralActionOutcome, GeneralActionResolveContext } from '@sammo-ts/logic/actions/engine.js';
|
||||||
import { createDiplomacyPatchEffect, createLogEffect } from '@sammo-ts/logic/actions/engine.js';
|
import {
|
||||||
import { LogCategory, LogFormat, LogScope } from '@sammo-ts/logic/logging/types.js';
|
destGeneralBelongsToDestNation,
|
||||||
import { JosaUtil } from '@sammo-ts/common';
|
resolveInstantDiplomacyResponse,
|
||||||
|
} from '@sammo-ts/logic/diplomacy/instantResponse.js';
|
||||||
|
|
||||||
export interface StopWarAcceptArgs {
|
export interface StopWarAcceptArgs {
|
||||||
destNationId: number;
|
destNationId: number;
|
||||||
destGeneralId: number;
|
destGeneralId: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ACTION_NAME = '종전 수락';
|
export interface StopWarAcceptContext<
|
||||||
const DIPLOMACY_NEUTRAL = 2;
|
TriggerState extends GeneralTriggerState = GeneralTriggerState,
|
||||||
|
> extends GeneralActionResolveContext<TriggerState> {
|
||||||
|
destNation: Nation;
|
||||||
|
destGeneral: General<TriggerState>;
|
||||||
|
currentYear: number;
|
||||||
|
currentMonth: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ACTION_NAME = '종전 수락';
|
||||||
const parseNationId = (raw: unknown): number | null => {
|
const parseNationId = (raw: unknown): number | null => {
|
||||||
if (typeof raw !== 'number' || !Number.isFinite(raw)) {
|
if (typeof raw !== 'number' || !Number.isFinite(raw)) {
|
||||||
return null;
|
return null;
|
||||||
@@ -39,7 +46,7 @@ const parseGeneralId = (raw: unknown): number | null => {
|
|||||||
// 종전 수락은 메시지와 연결되는 즉시 국가 커맨드로 사용한다.
|
// 종전 수락은 메시지와 연결되는 즉시 국가 커맨드로 사용한다.
|
||||||
export class ActionDefinition<
|
export class ActionDefinition<
|
||||||
TriggerState extends GeneralTriggerState = GeneralTriggerState,
|
TriggerState extends GeneralTriggerState = GeneralTriggerState,
|
||||||
> implements GeneralActionDefinition<TriggerState, StopWarAcceptArgs> {
|
> implements GeneralActionDefinition<TriggerState, StopWarAcceptArgs, StopWarAcceptContext<TriggerState>> {
|
||||||
public readonly key = 'che_종전수락';
|
public readonly key = 'che_종전수락';
|
||||||
public readonly name = ACTION_NAME;
|
public readonly name = ACTION_NAME;
|
||||||
|
|
||||||
@@ -59,97 +66,29 @@ export class ActionDefinition<
|
|||||||
notBeNeutral(),
|
notBeNeutral(),
|
||||||
existsDestNation(),
|
existsDestNation(),
|
||||||
existsDestGeneral(),
|
existsDestGeneral(),
|
||||||
reqDestNationValue('level', '국가규모', '>', 0, '상대국 정보가 없습니다.'),
|
destGeneralBelongsToDestNation(),
|
||||||
allowDiplomacyBetweenStatus([0, 1], '상대국과 선포, 전쟁중이지 않습니다.'),
|
allowDiplomacyBetweenStatus([0, 1], '상대국과 선포, 전쟁중이지 않습니다.'),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
resolve(
|
resolve(context: StopWarAcceptContext<TriggerState>, _args: StopWarAcceptArgs): GeneralActionOutcome<TriggerState> {
|
||||||
context: GeneralActionResolveContext<TriggerState>,
|
const nation = context.nation;
|
||||||
args: StopWarAcceptArgs
|
if (!nation) {
|
||||||
): GeneralActionOutcome<TriggerState> {
|
return { effects: [] };
|
||||||
const nationId = context.nation?.id;
|
|
||||||
if (nationId === undefined || nationId <= 0) {
|
|
||||||
return {
|
|
||||||
effects: [
|
|
||||||
createLogEffect(`${ACTION_NAME}을 준비했지만 국가 정보가 없습니다.`, {
|
|
||||||
scope: LogScope.GENERAL,
|
|
||||||
category: LogCategory.ACTION,
|
|
||||||
format: LogFormat.MONTH,
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
const resolution = resolveInstantDiplomacyResponse<TriggerState>(
|
||||||
const nationName = context.nation?.name ?? `국가${nationId}`;
|
{
|
||||||
const destNationName =
|
actor: context.general,
|
||||||
(context as { destNation?: { name?: string } }).destNation?.name ?? `국가${args.destNationId}`;
|
actorNation: nation,
|
||||||
const generalName = context.general.name;
|
proposer: context.destGeneral,
|
||||||
const josaYiGeneral = JosaUtil.pick(generalName, '이');
|
proposerNation: context.destNation,
|
||||||
const josaYiNation = JosaUtil.pick(nationName, '이');
|
currentYear: context.currentYear,
|
||||||
const josaWa = JosaUtil.pick(destNationName, '와');
|
currentMonth: context.currentMonth,
|
||||||
|
},
|
||||||
|
{ action: 'stopWar' }
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
effects: [
|
effects: resolution.effects,
|
||||||
createDiplomacyPatchEffect(nationId, args.destNationId, {
|
|
||||||
state: DIPLOMACY_NEUTRAL,
|
|
||||||
term: 0,
|
|
||||||
}),
|
|
||||||
createDiplomacyPatchEffect(args.destNationId, nationId, {
|
|
||||||
state: DIPLOMACY_NEUTRAL,
|
|
||||||
term: 0,
|
|
||||||
}),
|
|
||||||
createLogEffect(`<D><b>${destNationName}</b></>${josaWa} 종전에 합의했습니다.`, {
|
|
||||||
scope: LogScope.GENERAL,
|
|
||||||
category: LogCategory.ACTION,
|
|
||||||
format: LogFormat.PLAIN,
|
|
||||||
}),
|
|
||||||
createLogEffect(`<D><b>${destNationName}</b></>${josaWa} 종전 수락`, {
|
|
||||||
scope: LogScope.GENERAL,
|
|
||||||
category: LogCategory.HISTORY,
|
|
||||||
format: LogFormat.YEAR_MONTH,
|
|
||||||
}),
|
|
||||||
createLogEffect(
|
|
||||||
`<Y>${generalName}</>${josaYiGeneral} <D><b>${destNationName}</b></>${josaWa} <M>종전 합의</> 하였습니다.`,
|
|
||||||
{
|
|
||||||
scope: LogScope.SYSTEM,
|
|
||||||
category: LogCategory.ACTION,
|
|
||||||
format: LogFormat.PLAIN,
|
|
||||||
}
|
|
||||||
),
|
|
||||||
createLogEffect(
|
|
||||||
`<Y><b>【종전】</b></><D><b>${nationName}</b></>${josaYiNation} <D><b>${destNationName}</b></>${josaWa} <M>종전 합의</> 하였습니다.`,
|
|
||||||
{
|
|
||||||
scope: LogScope.SYSTEM,
|
|
||||||
category: LogCategory.HISTORY,
|
|
||||||
format: LogFormat.YEAR_MONTH,
|
|
||||||
}
|
|
||||||
),
|
|
||||||
createLogEffect(`<D><b>${destNationName}</b></>${josaWa} 종전`, {
|
|
||||||
scope: LogScope.NATION,
|
|
||||||
nationId,
|
|
||||||
category: LogCategory.HISTORY,
|
|
||||||
format: LogFormat.YEAR_MONTH,
|
|
||||||
}),
|
|
||||||
createLogEffect(`<D><b>${nationName}</b></>${josaWa} 종전에 성공했습니다.`, {
|
|
||||||
scope: LogScope.GENERAL,
|
|
||||||
generalId: args.destGeneralId,
|
|
||||||
category: LogCategory.ACTION,
|
|
||||||
format: LogFormat.PLAIN,
|
|
||||||
}),
|
|
||||||
createLogEffect(`<D><b>${nationName}</b></>${josaWa} 종전 성공`, {
|
|
||||||
scope: LogScope.GENERAL,
|
|
||||||
generalId: args.destGeneralId,
|
|
||||||
category: LogCategory.HISTORY,
|
|
||||||
format: LogFormat.YEAR_MONTH,
|
|
||||||
}),
|
|
||||||
createLogEffect(`<D><b>${nationName}</b></>${josaWa} 종전`, {
|
|
||||||
scope: LogScope.NATION,
|
|
||||||
nationId: args.destNationId,
|
|
||||||
category: LogCategory.HISTORY,
|
|
||||||
format: LogFormat.YEAR_MONTH,
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,8 +6,10 @@ export {
|
|||||||
export {
|
export {
|
||||||
ActionDefinition as NonAggressionCancelAcceptActionDefinition,
|
ActionDefinition as NonAggressionCancelAcceptActionDefinition,
|
||||||
type NonAggressionCancelAcceptArgs,
|
type NonAggressionCancelAcceptArgs,
|
||||||
|
type NonAggressionCancelAcceptContext,
|
||||||
} from './che_불가침파기수락.js';
|
} from './che_불가침파기수락.js';
|
||||||
export {
|
export {
|
||||||
ActionDefinition as StopWarAcceptActionDefinition,
|
ActionDefinition as StopWarAcceptActionDefinition,
|
||||||
type StopWarAcceptArgs,
|
type StopWarAcceptArgs,
|
||||||
|
type StopWarAcceptContext,
|
||||||
} from './che_종전수락.js';
|
} from './che_종전수락.js';
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
// Legacy diplomacy state codes.
|
||||||
|
export const DIPLOMACY_STATE = {
|
||||||
|
WAR: 0,
|
||||||
|
DECLARATION: 1,
|
||||||
|
TRADE: 2,
|
||||||
|
NON_AGGRESSION: 7,
|
||||||
|
} as const;
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
export interface FrontStateCity {
|
||||||
|
id: number;
|
||||||
|
nationId: number;
|
||||||
|
frontState: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FrontStateDiplomacy {
|
||||||
|
fromNationId: number;
|
||||||
|
toNationId: number;
|
||||||
|
state: number;
|
||||||
|
term: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const collectAdjacentCities = (
|
||||||
|
cityIds: number[],
|
||||||
|
connections: ReadonlyMap<number, readonly number[]>,
|
||||||
|
bucket: Set<number>
|
||||||
|
): void => {
|
||||||
|
for (const cityId of cityIds) {
|
||||||
|
for (const neighborId of connections.get(cityId) ?? []) {
|
||||||
|
bucket.add(neighborId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const buildNationFrontStatePatches = (options: {
|
||||||
|
cities: FrontStateCity[];
|
||||||
|
diplomacy: FrontStateDiplomacy[];
|
||||||
|
connections: ReadonlyMap<number, readonly number[]>;
|
||||||
|
nationIds: number[];
|
||||||
|
}): Array<{ id: number; frontState: number }> => {
|
||||||
|
const cityIdsByNation = new Map<number, number[]>();
|
||||||
|
for (const city of options.cities) {
|
||||||
|
const list = cityIdsByNation.get(city.nationId) ?? [];
|
||||||
|
list.push(city.id);
|
||||||
|
cityIdsByNation.set(city.nationId, list);
|
||||||
|
}
|
||||||
|
const cityById = new Map(options.cities.map((city) => [city.id, city]));
|
||||||
|
const patches: Array<{ id: number; frontState: number }> = [];
|
||||||
|
|
||||||
|
for (const nationId of new Set(options.nationIds.filter((id) => id > 0))) {
|
||||||
|
const adjWar = new Set<number>();
|
||||||
|
const adjNeutral = new Set<number>();
|
||||||
|
const adjDeclaration = new Set<number>();
|
||||||
|
|
||||||
|
for (const entry of options.diplomacy) {
|
||||||
|
if (entry.fromNationId !== nationId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const targetCities = cityIdsByNation.get(entry.toNationId) ?? [];
|
||||||
|
if (entry.state === 0) {
|
||||||
|
collectAdjacentCities(targetCities, options.connections, adjWar);
|
||||||
|
} else if (entry.state === 1 && entry.term <= 5) {
|
||||||
|
collectAdjacentCities(targetCities, options.connections, adjDeclaration);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (adjWar.size === 0 && adjDeclaration.size === 0) {
|
||||||
|
collectAdjacentCities(cityIdsByNation.get(0) ?? [], options.connections, adjNeutral);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const cityId of cityIdsByNation.get(nationId) ?? []) {
|
||||||
|
const city = cityById.get(cityId);
|
||||||
|
if (!city) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let frontState = 0;
|
||||||
|
if (adjDeclaration.has(cityId)) {
|
||||||
|
frontState = 1;
|
||||||
|
}
|
||||||
|
if (adjNeutral.has(cityId)) {
|
||||||
|
frontState = 2;
|
||||||
|
}
|
||||||
|
if (adjWar.has(cityId)) {
|
||||||
|
frontState = 3;
|
||||||
|
}
|
||||||
|
if (city.frontState !== frontState) {
|
||||||
|
patches.push({ id: cityId, frontState });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return patches;
|
||||||
|
};
|
||||||
@@ -1,17 +1,12 @@
|
|||||||
import { clamp } from 'es-toolkit';
|
import { clamp } from 'es-toolkit';
|
||||||
|
|
||||||
// 외교 상태 코드 (legacy 기준).
|
import { DIPLOMACY_STATE } from './constants.js';
|
||||||
export const DIPLOMACY_STATE = {
|
|
||||||
WAR: 0,
|
export { DIPLOMACY_STATE } from './constants.js';
|
||||||
DECLARATION: 1,
|
|
||||||
TRADE: 2,
|
|
||||||
NON_AGGRESSION: 7,
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
export const DEFAULT_DECLARE_WAR_TERM = 24;
|
export const DEFAULT_DECLARE_WAR_TERM = 24;
|
||||||
export const DEFAULT_WAR_TERM = 6;
|
export const DEFAULT_WAR_TERM = 6;
|
||||||
|
|
||||||
// TODO: 불가침 제의/수락 등 외교 커맨드 전환 규칙을 이 모듈에 추가 예정.
|
|
||||||
const MAX_WAR_TERM = 13;
|
const MAX_WAR_TERM = 13;
|
||||||
|
|
||||||
export interface DiplomacyEntry {
|
export interface DiplomacyEntry {
|
||||||
@@ -141,3 +136,6 @@ export const processDiplomacyMonth = (
|
|||||||
|
|
||||||
return next;
|
return next;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export * from './frontState.js';
|
||||||
|
export * from './instantResponse.js';
|
||||||
|
|||||||
@@ -0,0 +1,354 @@
|
|||||||
|
import { JosaUtil } from '@sammo-ts/common';
|
||||||
|
|
||||||
|
import {
|
||||||
|
createDiplomacyPatchEffect,
|
||||||
|
createLogEffect,
|
||||||
|
createNationPatchEffect,
|
||||||
|
type GeneralActionEffect,
|
||||||
|
} from '../actions/engine.js';
|
||||||
|
import { allow, unknownOrDeny } from '../constraints/helpers.js';
|
||||||
|
import type { Constraint } from '../constraints/types.js';
|
||||||
|
import type { GeneralTriggerState, Nation, TriggerValue } from '../domain/entities.js';
|
||||||
|
import { LogCategory, LogFormat, LogScope } from '../logging/types.js';
|
||||||
|
import { DIPLOMACY_STATE } from './constants.js';
|
||||||
|
|
||||||
|
export type InstantDiplomacyResponseAction = 'noAggression' | 'cancelNA' | 'stopWar';
|
||||||
|
|
||||||
|
export interface InstantDiplomacyResponseContext {
|
||||||
|
actor: {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
nationId: number;
|
||||||
|
};
|
||||||
|
actorNation: Nation;
|
||||||
|
proposer: {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
nationId: number;
|
||||||
|
};
|
||||||
|
proposerNation: Nation;
|
||||||
|
currentYear: number;
|
||||||
|
currentMonth: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InstantDiplomacyResponseArgs {
|
||||||
|
action: InstantDiplomacyResponseAction;
|
||||||
|
treatyYear?: number;
|
||||||
|
treatyMonth?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const destGeneralBelongsToDestNation = (): Constraint => ({
|
||||||
|
name: 'destGeneralBelongsToDestNation',
|
||||||
|
requires: (ctx) => {
|
||||||
|
const requirements = [];
|
||||||
|
if (ctx.destGeneralId !== undefined) {
|
||||||
|
requirements.push({ kind: 'destGeneral', id: ctx.destGeneralId } as const);
|
||||||
|
}
|
||||||
|
if (ctx.destNationId !== undefined) {
|
||||||
|
requirements.push({ kind: 'destNation', id: ctx.destNationId } as const);
|
||||||
|
}
|
||||||
|
return requirements;
|
||||||
|
},
|
||||||
|
test: (ctx, view) => {
|
||||||
|
if (ctx.destGeneralId === undefined || ctx.destNationId === undefined) {
|
||||||
|
return unknownOrDeny(ctx, [], '제의 장수가 국가 소속이 아닙니다');
|
||||||
|
}
|
||||||
|
const generalRequirement = { kind: 'destGeneral', id: ctx.destGeneralId } as const;
|
||||||
|
const nationRequirement = { kind: 'destNation', id: ctx.destNationId } as const;
|
||||||
|
if (!view.has(generalRequirement) || !view.has(nationRequirement)) {
|
||||||
|
return unknownOrDeny(
|
||||||
|
ctx,
|
||||||
|
[generalRequirement, nationRequirement].filter((requirement) => !view.has(requirement)),
|
||||||
|
'제의 장수가 국가 소속이 아닙니다'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const destGeneral = view.get(generalRequirement) as { nationId?: unknown } | null;
|
||||||
|
return destGeneral?.nationId === ctx.destNationId
|
||||||
|
? allow()
|
||||||
|
: { kind: 'deny', reason: '제의 장수가 국가 소속이 아닙니다' };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export interface InstantDiplomacyResponseResolution<TriggerState extends GeneralTriggerState = GeneralTriggerState> {
|
||||||
|
actionKey: 'che_불가침수락' | 'che_불가침파기수락' | 'che_종전수락';
|
||||||
|
diplomacyDetail: string;
|
||||||
|
effects: GeneralActionEffect<TriggerState>[];
|
||||||
|
refreshFront: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolveMonthIndex = (year: number, month: number): number => year * 12 + month - 1;
|
||||||
|
|
||||||
|
const buildNonAggressionEffects = <TriggerState extends GeneralTriggerState>(
|
||||||
|
context: InstantDiplomacyResponseContext,
|
||||||
|
treatyYear: number,
|
||||||
|
treatyMonth: number
|
||||||
|
): GeneralActionEffect<TriggerState>[] => {
|
||||||
|
const actorNation = context.actorNation;
|
||||||
|
const proposerNation = context.proposerNation;
|
||||||
|
const actorNationName = actorNation.name;
|
||||||
|
const proposerNationName = proposerNation.name;
|
||||||
|
const actorJosaWa = JosaUtil.pick(actorNationName, '와');
|
||||||
|
const proposerJosaWa = JosaUtil.pick(proposerNationName, '와');
|
||||||
|
const proposerMeta = { ...proposerNation.meta };
|
||||||
|
const recvAssist =
|
||||||
|
proposerMeta.recv_assist &&
|
||||||
|
typeof proposerMeta.recv_assist === 'object' &&
|
||||||
|
!Array.isArray(proposerMeta.recv_assist)
|
||||||
|
? (proposerMeta.recv_assist as Record<string, TriggerValue>)
|
||||||
|
: {};
|
||||||
|
const respAssist =
|
||||||
|
proposerMeta.resp_assist &&
|
||||||
|
typeof proposerMeta.resp_assist === 'object' &&
|
||||||
|
!Array.isArray(proposerMeta.resp_assist)
|
||||||
|
? { ...(proposerMeta.resp_assist as Record<string, TriggerValue>) }
|
||||||
|
: ({} as Record<string, TriggerValue>);
|
||||||
|
const assistKey = `n${actorNation.id}`;
|
||||||
|
const recvEntry = recvAssist[assistKey];
|
||||||
|
const recvAmount =
|
||||||
|
Array.isArray(recvEntry) && typeof recvEntry[1] === 'number' && Number.isFinite(recvEntry[1])
|
||||||
|
? recvEntry[1]
|
||||||
|
: 0;
|
||||||
|
respAssist[assistKey] = [actorNation.id, recvAmount];
|
||||||
|
|
||||||
|
const currentMonth = resolveMonthIndex(context.currentYear, context.currentMonth);
|
||||||
|
const targetMonth = treatyYear * 12 + treatyMonth;
|
||||||
|
const term = targetMonth - currentMonth;
|
||||||
|
|
||||||
|
return [
|
||||||
|
createDiplomacyPatchEffect(actorNation.id, proposerNation.id, {
|
||||||
|
state: DIPLOMACY_STATE.NON_AGGRESSION,
|
||||||
|
term,
|
||||||
|
}),
|
||||||
|
createDiplomacyPatchEffect(proposerNation.id, actorNation.id, {
|
||||||
|
state: DIPLOMACY_STATE.NON_AGGRESSION,
|
||||||
|
term,
|
||||||
|
}),
|
||||||
|
createNationPatchEffect(
|
||||||
|
{
|
||||||
|
meta: {
|
||||||
|
...proposerMeta,
|
||||||
|
resp_assist: respAssist,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
proposerNation.id
|
||||||
|
),
|
||||||
|
createLogEffect(
|
||||||
|
`<D><b>${proposerNationName}</b></>${proposerJosaWa} ${treatyYear}년 ${treatyMonth}월까지 불가침 성공`,
|
||||||
|
{
|
||||||
|
scope: LogScope.GENERAL,
|
||||||
|
category: LogCategory.HISTORY,
|
||||||
|
generalId: context.actor.id,
|
||||||
|
format: LogFormat.YEAR_MONTH,
|
||||||
|
}
|
||||||
|
),
|
||||||
|
createLogEffect(
|
||||||
|
`<D><b>${proposerNationName}</b></>${proposerJosaWa} <C>${treatyYear}</>년 <C>${treatyMonth}</>월까지 불가침에 성공했습니다.`,
|
||||||
|
{
|
||||||
|
scope: LogScope.GENERAL,
|
||||||
|
category: LogCategory.ACTION,
|
||||||
|
generalId: context.actor.id,
|
||||||
|
format: LogFormat.PLAIN,
|
||||||
|
}
|
||||||
|
),
|
||||||
|
createLogEffect(
|
||||||
|
`<D><b>${actorNationName}</b></>${actorJosaWa} ${treatyYear}년 ${treatyMonth}월까지 불가침 성공`,
|
||||||
|
{
|
||||||
|
scope: LogScope.GENERAL,
|
||||||
|
category: LogCategory.HISTORY,
|
||||||
|
generalId: context.proposer.id,
|
||||||
|
format: LogFormat.YEAR_MONTH,
|
||||||
|
}
|
||||||
|
),
|
||||||
|
createLogEffect(
|
||||||
|
`<D><b>${actorNationName}</b></>${actorJosaWa} <C>${treatyYear}</>년 <C>${treatyMonth}</>월까지 불가침에 성공했습니다.`,
|
||||||
|
{
|
||||||
|
scope: LogScope.GENERAL,
|
||||||
|
category: LogCategory.ACTION,
|
||||||
|
generalId: context.proposer.id,
|
||||||
|
format: LogFormat.PLAIN,
|
||||||
|
}
|
||||||
|
),
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildCancelNonAggressionEffects = <TriggerState extends GeneralTriggerState>(
|
||||||
|
context: InstantDiplomacyResponseContext
|
||||||
|
): GeneralActionEffect<TriggerState>[] => {
|
||||||
|
const actorNation = context.actorNation;
|
||||||
|
const proposerNation = context.proposerNation;
|
||||||
|
const actorNationName = actorNation.name;
|
||||||
|
const proposerNationName = proposerNation.name;
|
||||||
|
const actorName = context.actor.name;
|
||||||
|
const actorJosaYi = JosaUtil.pick(actorName, '이');
|
||||||
|
const actorNationJosaYi = JosaUtil.pick(actorNationName, '이');
|
||||||
|
const actorNationJosaWa = JosaUtil.pick(actorNationName, '와');
|
||||||
|
const proposerJosaWa = JosaUtil.pick(proposerNationName, '와');
|
||||||
|
|
||||||
|
return [
|
||||||
|
createDiplomacyPatchEffect(actorNation.id, proposerNation.id, {
|
||||||
|
state: DIPLOMACY_STATE.TRADE,
|
||||||
|
term: 0,
|
||||||
|
}),
|
||||||
|
createDiplomacyPatchEffect(proposerNation.id, actorNation.id, {
|
||||||
|
state: DIPLOMACY_STATE.TRADE,
|
||||||
|
term: 0,
|
||||||
|
}),
|
||||||
|
createLogEffect(`<D><b>${proposerNationName}</b></>${proposerJosaWa}의 불가침 파기 수락`, {
|
||||||
|
scope: LogScope.GENERAL,
|
||||||
|
category: LogCategory.HISTORY,
|
||||||
|
generalId: context.actor.id,
|
||||||
|
format: LogFormat.YEAR_MONTH,
|
||||||
|
}),
|
||||||
|
createLogEffect(`<D><b>${proposerNationName}</b></>${proposerJosaWa}의 불가침을 파기했습니다.`, {
|
||||||
|
scope: LogScope.GENERAL,
|
||||||
|
category: LogCategory.ACTION,
|
||||||
|
generalId: context.actor.id,
|
||||||
|
format: LogFormat.PLAIN,
|
||||||
|
}),
|
||||||
|
createLogEffect(
|
||||||
|
`<Y><b>【파기】</b></><D><b>${actorNationName}</b></>${actorNationJosaYi} <D><b>${proposerNationName}</b></>${proposerJosaWa}의 불가침 조약을 <M>파기</> 하였습니다.`,
|
||||||
|
{
|
||||||
|
scope: LogScope.SYSTEM,
|
||||||
|
category: LogCategory.HISTORY,
|
||||||
|
format: LogFormat.YEAR_MONTH,
|
||||||
|
}
|
||||||
|
),
|
||||||
|
createLogEffect(
|
||||||
|
`<Y>${actorName}</>${actorJosaYi} <D><b>${proposerNationName}</b></>${proposerJosaWa}의 불가침 조약을 <M>파기</> 하였습니다.`,
|
||||||
|
{
|
||||||
|
scope: LogScope.SYSTEM,
|
||||||
|
category: LogCategory.SUMMARY,
|
||||||
|
format: LogFormat.MONTH,
|
||||||
|
}
|
||||||
|
),
|
||||||
|
createLogEffect(`<D><b>${actorNationName}</b></>${actorNationJosaWa}의 불가침 파기 성공`, {
|
||||||
|
scope: LogScope.GENERAL,
|
||||||
|
category: LogCategory.HISTORY,
|
||||||
|
generalId: context.proposer.id,
|
||||||
|
format: LogFormat.YEAR_MONTH,
|
||||||
|
}),
|
||||||
|
createLogEffect(`<D><b>${actorNationName}</b></>${actorNationJosaWa}의 불가침 파기에 성공했습니다.`, {
|
||||||
|
scope: LogScope.GENERAL,
|
||||||
|
category: LogCategory.ACTION,
|
||||||
|
generalId: context.proposer.id,
|
||||||
|
format: LogFormat.PLAIN,
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildStopWarEffects = <TriggerState extends GeneralTriggerState>(
|
||||||
|
context: InstantDiplomacyResponseContext
|
||||||
|
): GeneralActionEffect<TriggerState>[] => {
|
||||||
|
const actorNation = context.actorNation;
|
||||||
|
const proposerNation = context.proposerNation;
|
||||||
|
const actorNationName = actorNation.name;
|
||||||
|
const proposerNationName = proposerNation.name;
|
||||||
|
const actorName = context.actor.name;
|
||||||
|
const actorJosaYi = JosaUtil.pick(actorName, '이');
|
||||||
|
const actorNationJosaYi = JosaUtil.pick(actorNationName, '이');
|
||||||
|
const actorNationJosaWa = JosaUtil.pick(actorNationName, '와');
|
||||||
|
const proposerJosaWa = JosaUtil.pick(proposerNationName, '와');
|
||||||
|
|
||||||
|
return [
|
||||||
|
createDiplomacyPatchEffect(actorNation.id, proposerNation.id, {
|
||||||
|
state: DIPLOMACY_STATE.TRADE,
|
||||||
|
term: 0,
|
||||||
|
}),
|
||||||
|
createDiplomacyPatchEffect(proposerNation.id, actorNation.id, {
|
||||||
|
state: DIPLOMACY_STATE.TRADE,
|
||||||
|
term: 0,
|
||||||
|
}),
|
||||||
|
createLogEffect(`<D><b>${proposerNationName}</b></>${proposerJosaWa} 종전 수락`, {
|
||||||
|
scope: LogScope.GENERAL,
|
||||||
|
category: LogCategory.HISTORY,
|
||||||
|
generalId: context.actor.id,
|
||||||
|
format: LogFormat.YEAR_MONTH,
|
||||||
|
}),
|
||||||
|
createLogEffect(`<D><b>${proposerNationName}</b></>${proposerJosaWa} 종전에 합의했습니다.`, {
|
||||||
|
scope: LogScope.GENERAL,
|
||||||
|
category: LogCategory.ACTION,
|
||||||
|
generalId: context.actor.id,
|
||||||
|
format: LogFormat.PLAIN,
|
||||||
|
}),
|
||||||
|
createLogEffect(`<D><b>${proposerNationName}</b></>${proposerJosaWa} 종전`, {
|
||||||
|
scope: LogScope.NATION,
|
||||||
|
nationId: actorNation.id,
|
||||||
|
category: LogCategory.HISTORY,
|
||||||
|
format: LogFormat.YEAR_MONTH,
|
||||||
|
}),
|
||||||
|
createLogEffect(
|
||||||
|
`<Y><b>【종전】</b></><D><b>${actorNationName}</b></>${actorNationJosaYi} <D><b>${proposerNationName}</b></>${proposerJosaWa} <M>종전 합의</> 하였습니다.`,
|
||||||
|
{
|
||||||
|
scope: LogScope.SYSTEM,
|
||||||
|
category: LogCategory.HISTORY,
|
||||||
|
format: LogFormat.YEAR_MONTH,
|
||||||
|
}
|
||||||
|
),
|
||||||
|
createLogEffect(
|
||||||
|
`<Y>${actorName}</>${actorJosaYi} <D><b>${proposerNationName}</b></>${proposerJosaWa} <M>종전 합의</> 하였습니다.`,
|
||||||
|
{
|
||||||
|
scope: LogScope.SYSTEM,
|
||||||
|
category: LogCategory.SUMMARY,
|
||||||
|
format: LogFormat.MONTH,
|
||||||
|
}
|
||||||
|
),
|
||||||
|
createLogEffect(`<D><b>${actorNationName}</b></>${actorNationJosaWa} 종전 성공`, {
|
||||||
|
scope: LogScope.GENERAL,
|
||||||
|
generalId: context.proposer.id,
|
||||||
|
category: LogCategory.HISTORY,
|
||||||
|
format: LogFormat.YEAR_MONTH,
|
||||||
|
}),
|
||||||
|
createLogEffect(`<D><b>${actorNationName}</b></>${actorNationJosaWa} 종전에 성공했습니다.`, {
|
||||||
|
scope: LogScope.GENERAL,
|
||||||
|
generalId: context.proposer.id,
|
||||||
|
category: LogCategory.ACTION,
|
||||||
|
format: LogFormat.PLAIN,
|
||||||
|
}),
|
||||||
|
createLogEffect(`<D><b>${actorNationName}</b></>${actorNationJosaWa} 종전`, {
|
||||||
|
scope: LogScope.NATION,
|
||||||
|
nationId: proposerNation.id,
|
||||||
|
category: LogCategory.HISTORY,
|
||||||
|
format: LogFormat.YEAR_MONTH,
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const resolveInstantDiplomacyResponse = <TriggerState extends GeneralTriggerState = GeneralTriggerState>(
|
||||||
|
context: InstantDiplomacyResponseContext,
|
||||||
|
args: InstantDiplomacyResponseArgs
|
||||||
|
): InstantDiplomacyResponseResolution<TriggerState> => {
|
||||||
|
if (args.action === 'noAggression') {
|
||||||
|
if (
|
||||||
|
typeof args.treatyYear !== 'number' ||
|
||||||
|
!Number.isInteger(args.treatyYear) ||
|
||||||
|
typeof args.treatyMonth !== 'number' ||
|
||||||
|
!Number.isInteger(args.treatyMonth) ||
|
||||||
|
args.treatyMonth < 1 ||
|
||||||
|
args.treatyMonth > 12
|
||||||
|
) {
|
||||||
|
throw new Error('Invalid non-aggression treaty term.');
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
actionKey: 'che_불가침수락',
|
||||||
|
diplomacyDetail: `${args.treatyYear}년 ${args.treatyMonth}월까지 불가침 합의`,
|
||||||
|
effects: buildNonAggressionEffects<TriggerState>(context, args.treatyYear, args.treatyMonth),
|
||||||
|
refreshFront: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (args.action === 'cancelNA') {
|
||||||
|
return {
|
||||||
|
actionKey: 'che_불가침파기수락',
|
||||||
|
diplomacyDetail: `${context.proposerNation.name}국과 불가침 파기 합의`,
|
||||||
|
effects: buildCancelNonAggressionEffects<TriggerState>(context),
|
||||||
|
refreshFront: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
actionKey: 'che_종전수락',
|
||||||
|
diplomacyDetail: `${context.proposerNation.name}국과 종전 합의`,
|
||||||
|
effects: buildStopWarEffects<TriggerState>(context),
|
||||||
|
refreshFront: true,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -181,6 +181,10 @@ describe('Nation Instant Actions', () => {
|
|||||||
general,
|
general,
|
||||||
city,
|
city,
|
||||||
nation,
|
nation,
|
||||||
|
destNation,
|
||||||
|
destGeneral: buildGeneral(2, 2, 2, 'Dest'),
|
||||||
|
currentYear: 200,
|
||||||
|
currentMonth: 1,
|
||||||
rng: {} as any,
|
rng: {} as any,
|
||||||
addLog: () => {},
|
addLog: () => {},
|
||||||
} as any,
|
} as any,
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import type { Nation } from '../src/domain/entities.js';
|
||||||
|
import { buildNationFrontStatePatches, resolveInstantDiplomacyResponse } from '../src/diplomacy/index.js';
|
||||||
|
import { LogCategory, LogScope } from '../src/logging/types.js';
|
||||||
|
|
||||||
|
const nation = (id: number, name: string, meta: Nation['meta'] = {}): Nation => ({
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
color: '#000000',
|
||||||
|
capitalCityId: id,
|
||||||
|
chiefGeneralId: id,
|
||||||
|
gold: 1000,
|
||||||
|
rice: 1000,
|
||||||
|
power: 0,
|
||||||
|
level: 1,
|
||||||
|
typeCode: 'test',
|
||||||
|
meta,
|
||||||
|
});
|
||||||
|
|
||||||
|
const context = {
|
||||||
|
actor: { id: 11, name: '수락자', nationId: 1 },
|
||||||
|
actorNation: nation(1, '위'),
|
||||||
|
proposer: { id: 22, name: '제안자', nationId: 2 },
|
||||||
|
proposerNation: nation(2, '촉', {
|
||||||
|
recv_assist: { n1: [1, 345] },
|
||||||
|
resp_assist: { n3: [3, 100] },
|
||||||
|
}),
|
||||||
|
currentYear: 200,
|
||||||
|
currentMonth: 3,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('instant diplomatic response parity', () => {
|
||||||
|
it('creates the legacy non-aggression term, assistance response, and both general logs', () => {
|
||||||
|
const result = resolveInstantDiplomacyResponse(context, {
|
||||||
|
action: 'noAggression',
|
||||||
|
treatyYear: 201,
|
||||||
|
treatyMonth: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.actionKey).toBe('che_불가침수락');
|
||||||
|
expect(result.diplomacyDetail).toBe('201년 2월까지 불가침 합의');
|
||||||
|
expect(result.effects.filter((effect) => effect.type === 'diplomacy:patch')).toEqual([
|
||||||
|
{
|
||||||
|
type: 'diplomacy:patch',
|
||||||
|
srcNationId: 1,
|
||||||
|
destNationId: 2,
|
||||||
|
patch: { state: 7, term: 12 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'diplomacy:patch',
|
||||||
|
srcNationId: 2,
|
||||||
|
destNationId: 1,
|
||||||
|
patch: { state: 7, term: 12 },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(result.effects).toContainEqual({
|
||||||
|
type: 'nation:patch',
|
||||||
|
targetId: 2,
|
||||||
|
patch: {
|
||||||
|
meta: {
|
||||||
|
recv_assist: { n1: [1, 345] },
|
||||||
|
resp_assist: { n3: [3, 100], n1: [1, 345] },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const logs = result.effects.filter((effect) => effect.type === 'log');
|
||||||
|
expect(logs).toHaveLength(4);
|
||||||
|
expect(logs.map((effect) => effect.entry.generalId)).toEqual([11, 11, 22, 22]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates the full legacy cancellation logs without refreshing fronts', () => {
|
||||||
|
const result = resolveInstantDiplomacyResponse(context, { action: 'cancelNA' });
|
||||||
|
const logs = result.effects.filter((effect) => effect.type === 'log');
|
||||||
|
|
||||||
|
expect(result.actionKey).toBe('che_불가침파기수락');
|
||||||
|
expect(result.refreshFront).toBe(false);
|
||||||
|
expect(logs).toHaveLength(6);
|
||||||
|
expect(logs).toContainEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
entry: expect.objectContaining({
|
||||||
|
scope: LogScope.SYSTEM,
|
||||||
|
category: LogCategory.SUMMARY,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates both nation histories and requests front refresh for stop-war', () => {
|
||||||
|
const result = resolveInstantDiplomacyResponse(context, { action: 'stopWar' });
|
||||||
|
const nationLogIds = result.effects.flatMap((effect) =>
|
||||||
|
effect.type === 'log' && effect.entry.scope === LogScope.NATION ? [effect.entry.nationId] : []
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.actionKey).toBe('che_종전수락');
|
||||||
|
expect(result.refreshFront).toBe(true);
|
||||||
|
expect(nationLogIds).toEqual([1, 2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('recomputes only requested nation fronts with legacy priority', () => {
|
||||||
|
const patches = buildNationFrontStatePatches({
|
||||||
|
cities: [
|
||||||
|
{ id: 1, nationId: 1, frontState: 3 },
|
||||||
|
{ id: 2, nationId: 2, frontState: 3 },
|
||||||
|
{ id: 3, nationId: 0, frontState: 0 },
|
||||||
|
],
|
||||||
|
diplomacy: [
|
||||||
|
{ fromNationId: 1, toNationId: 2, state: 2, term: 0 },
|
||||||
|
{ fromNationId: 2, toNationId: 1, state: 2, term: 0 },
|
||||||
|
],
|
||||||
|
connections: new Map([
|
||||||
|
[1, [2, 3]],
|
||||||
|
[2, [1]],
|
||||||
|
[3, [1]],
|
||||||
|
]),
|
||||||
|
nationIds: [1, 2],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(patches).toEqual([
|
||||||
|
{ id: 1, frontState: 2 },
|
||||||
|
{ id: 2, frontState: 0 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,291 @@
|
|||||||
|
import { expect, test, type Page, type Route } from '@playwright/test';
|
||||||
|
import { mkdir } from 'node:fs/promises';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
|
||||||
|
import { canonicalFrontendFixture as fixture } from './fixtures/canonical';
|
||||||
|
|
||||||
|
const artifactRoot = process.env.FRONTEND_PARITY_ARTIFACT_DIR;
|
||||||
|
const response = (data: unknown) => ({ result: { data } });
|
||||||
|
|
||||||
|
const operationNames = (route: Route): string[] => {
|
||||||
|
const pathname = new URL(route.request().url()).pathname;
|
||||||
|
return decodeURIComponent(pathname.slice(pathname.lastIndexOf('/trpc/') + 6)).split(',');
|
||||||
|
};
|
||||||
|
|
||||||
|
const general = {
|
||||||
|
id: 1,
|
||||||
|
name: '수락장수',
|
||||||
|
npcState: 0,
|
||||||
|
nationId: 1,
|
||||||
|
cityId: 1,
|
||||||
|
troopId: 0,
|
||||||
|
picture: null,
|
||||||
|
imageServer: 0,
|
||||||
|
officerLevel: 5,
|
||||||
|
stats: { leadership: 80, strength: 70, intelligence: 90 },
|
||||||
|
gold: 1000,
|
||||||
|
rice: 1000,
|
||||||
|
crew: 500,
|
||||||
|
train: 100,
|
||||||
|
atmos: 100,
|
||||||
|
injury: 0,
|
||||||
|
experience: 1200,
|
||||||
|
dedication: 900,
|
||||||
|
items: { horse: null, weapon: null, book: null, item: null },
|
||||||
|
};
|
||||||
|
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
settings: {},
|
||||||
|
penalties: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const diplomacyMessage = {
|
||||||
|
id: 701,
|
||||||
|
msgType: 'diplomacy',
|
||||||
|
src: { generalId: 2, generalName: '제안장수', nationId: 2, nationName: '제안국' },
|
||||||
|
dest: { generalId: 1, generalName: '수락장수', nationId: 1, nationName: '수락국' },
|
||||||
|
text: '제안국에서 191년 2월까지 불가침을 제안했습니다.',
|
||||||
|
option: {
|
||||||
|
action: 'noAggression',
|
||||||
|
year: 191,
|
||||||
|
month: 2,
|
||||||
|
used: false,
|
||||||
|
deletable: false,
|
||||||
|
},
|
||||||
|
time: '0190-03-01 00:00:00',
|
||||||
|
};
|
||||||
|
|
||||||
|
const messageBundle = (visible: boolean, canRespondDiplomacy = true) => ({
|
||||||
|
result: true,
|
||||||
|
private: [],
|
||||||
|
public: [],
|
||||||
|
national: [],
|
||||||
|
diplomacy: visible ? [diplomacyMessage] : [],
|
||||||
|
sequence: visible ? diplomacyMessage.id : -1,
|
||||||
|
nationId: 1,
|
||||||
|
generalName: general.name,
|
||||||
|
canRespondDiplomacy,
|
||||||
|
latestRead: { diplomacy: 0, private: 0 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const installFixture = async (
|
||||||
|
page: Page,
|
||||||
|
options: { acceptResponse: boolean; canRespondDiplomacy?: boolean }
|
||||||
|
): Promise<Array<{ operation: string; body: unknown }>> => {
|
||||||
|
let visible = true;
|
||||||
|
const mutations: Array<{ operation: string; body: unknown }> = [];
|
||||||
|
await page.addInitScript(
|
||||||
|
({ gameToken, profile }) => {
|
||||||
|
window.localStorage.setItem('sammo-game-token', gameToken);
|
||||||
|
window.localStorage.setItem('sammo-game-profile', profile);
|
||||||
|
},
|
||||||
|
{
|
||||||
|
gameToken: fixture.game.session.gameToken,
|
||||||
|
profile: fixture.game.session.profile,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
await page.route('**/image/**', (route) => route.fulfill({ status: 204, body: '' }));
|
||||||
|
await page.route('**/che/api/events**', (route) => route.abort());
|
||||||
|
await page.route('**/che/api/trpc/**', async (route) => {
|
||||||
|
const operations = operationNames(route);
|
||||||
|
const requestBody = route.request().postDataJSON();
|
||||||
|
const results = operations.map((operation) => {
|
||||||
|
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.getMap') {
|
||||||
|
return response({ ...fixture.game.map, myCity: 1, myNation: 1 });
|
||||||
|
}
|
||||||
|
if (operation === 'turns.getCommandTable') return response({ general: [], nation: [] });
|
||||||
|
if (operation === 'turns.reserved.getGeneral' || operation === 'turns.reserved.getNation') {
|
||||||
|
return response([]);
|
||||||
|
}
|
||||||
|
if (operation === 'messages.getRecent') {
|
||||||
|
return response(messageBundle(visible, options.canRespondDiplomacy));
|
||||||
|
}
|
||||||
|
if (operation === 'messages.respond') {
|
||||||
|
mutations.push({ operation, body: requestBody });
|
||||||
|
if (options.acceptResponse) {
|
||||||
|
visible = false;
|
||||||
|
return response({ result: true, reason: '불가침 제의를 수락했습니다.' });
|
||||||
|
}
|
||||||
|
return response({ result: false, reason: '현재 외교 상태에서는 수락할 수 없습니다.' });
|
||||||
|
}
|
||||||
|
throw new Error(`Unhandled instant diplomacy fixture operation: ${operation}`);
|
||||||
|
});
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify(results),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return mutations;
|
||||||
|
};
|
||||||
|
|
||||||
|
const openDiplomacyTab = async (page: Page) => {
|
||||||
|
await page.goto('http://127.0.0.1:15102/che/');
|
||||||
|
await expect(page.getByRole('heading', { name: '전장 현황' })).toBeVisible();
|
||||||
|
await page.getByRole('button', { name: '외교', exact: true }).last().click();
|
||||||
|
await expect(page.getByText(diplomacyMessage.text)).toBeVisible();
|
||||||
|
};
|
||||||
|
|
||||||
|
test.describe('instant diplomacy response UI', () => {
|
||||||
|
test('renders and accepts the actionable message in desktop Chromium', async ({ page }) => {
|
||||||
|
const mutations = await installFixture(page, { acceptResponse: true });
|
||||||
|
await page.setViewportSize({ width: 1365, height: 900 });
|
||||||
|
await openDiplomacyTab(page);
|
||||||
|
|
||||||
|
const responseRow = page.locator('.message-response');
|
||||||
|
const accept = responseRow.getByRole('button', { name: '수락' });
|
||||||
|
const decline = responseRow.getByRole('button', { name: '거절' });
|
||||||
|
const geometry = await responseRow.evaluate((element) => {
|
||||||
|
const row = element.getBoundingClientRect();
|
||||||
|
const buttons = Array.from(element.querySelectorAll('button')).map((button) => {
|
||||||
|
const rect = button.getBoundingClientRect();
|
||||||
|
const style = getComputedStyle(button);
|
||||||
|
return {
|
||||||
|
x: rect.x,
|
||||||
|
y: rect.y,
|
||||||
|
width: rect.width,
|
||||||
|
height: rect.height,
|
||||||
|
color: style.color,
|
||||||
|
fontSize: style.fontSize,
|
||||||
|
borderWidth: style.borderWidth,
|
||||||
|
cursor: style.cursor,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return { row: { x: row.x, y: row.y, width: row.width, height: row.height }, buttons };
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(geometry.buttons).toHaveLength(2);
|
||||||
|
expect(geometry.buttons[1]!.x - (geometry.buttons[0]!.x + geometry.buttons[0]!.width)).toBeCloseTo(4, 0);
|
||||||
|
expect(geometry.buttons[0]).toMatchObject({
|
||||||
|
color: 'rgb(143, 209, 143)',
|
||||||
|
fontSize: '11.2px',
|
||||||
|
borderWidth: '1px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
});
|
||||||
|
expect(geometry.buttons[1]).toMatchObject({
|
||||||
|
color: 'rgb(224, 154, 154)',
|
||||||
|
fontSize: '11.2px',
|
||||||
|
borderWidth: '1px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
});
|
||||||
|
expect(geometry.buttons.every((button) => button.height >= 22 && button.height <= 26)).toBe(true);
|
||||||
|
|
||||||
|
await decline.hover();
|
||||||
|
expect(await decline.evaluate((element) => getComputedStyle(element).cursor)).toBe('pointer');
|
||||||
|
await accept.focus();
|
||||||
|
await expect(accept).toBeFocused();
|
||||||
|
if (artifactRoot) {
|
||||||
|
await mkdir(artifactRoot, { recursive: true });
|
||||||
|
await responseRow.screenshot({
|
||||||
|
path: resolve(artifactRoot, 'instant-diplomacy-response-core-desktop.png'),
|
||||||
|
animations: 'disabled',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
page.once('dialog', async (dialog) => {
|
||||||
|
expect(dialog.message()).toBe('수락하시겠습니까?');
|
||||||
|
await dialog.dismiss();
|
||||||
|
});
|
||||||
|
await accept.click();
|
||||||
|
expect(mutations).toHaveLength(0);
|
||||||
|
await expect(page.getByText(diplomacyMessage.text)).toBeVisible();
|
||||||
|
|
||||||
|
page.once('dialog', async (dialog) => {
|
||||||
|
expect(dialog.message()).toBe('수락하시겠습니까?');
|
||||||
|
await dialog.accept();
|
||||||
|
});
|
||||||
|
await accept.click();
|
||||||
|
await expect(page.getByText(diplomacyMessage.text)).toHaveCount(0);
|
||||||
|
expect(mutations).toHaveLength(1);
|
||||||
|
expect(JSON.stringify(mutations[0]!.body)).toContain('"response":true');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keeps the message and exposes a rejected response on mobile Chromium', async ({ page }) => {
|
||||||
|
const mutations = await installFixture(page, { acceptResponse: false });
|
||||||
|
await page.setViewportSize({ width: 390, height: 844 });
|
||||||
|
await page.goto('http://127.0.0.1:15102/che/');
|
||||||
|
await expect(page.getByRole('heading', { name: '전장 현황' })).toBeVisible();
|
||||||
|
await page.getByRole('button', { name: '메시지', exact: true }).click();
|
||||||
|
await page.getByRole('button', { name: '외교', exact: true }).click();
|
||||||
|
|
||||||
|
const responseRow = page.locator('.message-response');
|
||||||
|
await expect(responseRow).toBeVisible();
|
||||||
|
const itemWidth = await page
|
||||||
|
.locator('.message-item')
|
||||||
|
.evaluate((element) => element.getBoundingClientRect().width);
|
||||||
|
expect(itemWidth).toBeGreaterThan(320);
|
||||||
|
expect(itemWidth).toBeLessThanOrEqual(342);
|
||||||
|
|
||||||
|
page.once('dialog', async (dialog) => {
|
||||||
|
expect(dialog.message()).toBe('거절하시겠습니까?');
|
||||||
|
await dialog.accept();
|
||||||
|
});
|
||||||
|
await responseRow.getByRole('button', { name: '거절' }).click();
|
||||||
|
await expect(page.locator('.error')).toHaveText('현재 외교 상태에서는 수락할 수 없습니다.');
|
||||||
|
await expect(page.getByText(diplomacyMessage.text)).toBeVisible();
|
||||||
|
expect(mutations).toHaveLength(1);
|
||||||
|
expect(JSON.stringify(mutations[0]!.body)).toContain('"response":false');
|
||||||
|
|
||||||
|
if (artifactRoot) {
|
||||||
|
await mkdir(artifactRoot, { recursive: true });
|
||||||
|
await page.locator('.mobile-panel').screenshot({
|
||||||
|
path: resolve(artifactRoot, 'instant-diplomacy-response-error-core-mobile.png'),
|
||||||
|
animations: 'disabled',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shows but disables legacy response controls without diplomacy authority', async ({ page }) => {
|
||||||
|
const mutations = await installFixture(page, {
|
||||||
|
acceptResponse: true,
|
||||||
|
canRespondDiplomacy: false,
|
||||||
|
});
|
||||||
|
await page.setViewportSize({ width: 390, height: 844 });
|
||||||
|
await page.goto('http://127.0.0.1:15102/che/');
|
||||||
|
await expect(page.getByRole('heading', { name: '전장 현황' })).toBeVisible();
|
||||||
|
await page.getByRole('button', { name: '메시지', exact: true }).click();
|
||||||
|
await page.getByRole('button', { name: '외교', exact: true }).click();
|
||||||
|
|
||||||
|
const accept = page.locator('.message-response').getByRole('button', { name: '수락' });
|
||||||
|
await expect(accept).toBeDisabled();
|
||||||
|
expect(
|
||||||
|
await accept.evaluate((element) => {
|
||||||
|
const style = getComputedStyle(element);
|
||||||
|
return { cursor: style.cursor, opacity: style.opacity };
|
||||||
|
})
|
||||||
|
).toEqual({ cursor: 'not-allowed', opacity: '0.5' });
|
||||||
|
await accept.click({ force: true });
|
||||||
|
expect(mutations).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -6,7 +6,7 @@ const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../..')
|
|||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
testDir: '.',
|
testDir: '.',
|
||||||
testMatch: ['visual-parity.spec.ts', 'public-gaps.spec.ts'],
|
testMatch: ['visual-parity.spec.ts', 'public-gaps.spec.ts', 'instant-diplomacy-message.spec.ts'],
|
||||||
fullyParallel: false,
|
fullyParallel: false,
|
||||||
workers: 1,
|
workers: 1,
|
||||||
timeout: 30_000,
|
timeout: 30_000,
|
||||||
|
|||||||
@@ -30,11 +30,11 @@ export interface CanonicalTurnCommandTrace {
|
|||||||
schemaVersion: 1;
|
schemaVersion: 1;
|
||||||
engine: CanonicalEngine;
|
engine: CanonicalEngine;
|
||||||
execution: {
|
execution: {
|
||||||
kind: 'general' | 'nation';
|
kind: 'general' | 'nation' | 'instantNation';
|
||||||
actorGeneralId: number;
|
actorGeneralId: number;
|
||||||
action: string;
|
action: string;
|
||||||
args: unknown;
|
args: unknown;
|
||||||
seedDomain: 'generalCommand' | 'nationCommand';
|
seedDomain: 'generalCommand' | 'nationCommand' | 'none';
|
||||||
outcome?: unknown;
|
outcome?: unknown;
|
||||||
};
|
};
|
||||||
before: CanonicalTurnSnapshot;
|
before: CanonicalTurnSnapshot;
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
findTurnDifferentialWorkspaceRoot,
|
||||||
|
runReferenceTurnCommandTraceRequest,
|
||||||
|
} from '../src/turn-differential/referenceSnapshot.js';
|
||||||
|
|
||||||
|
const configuredWorkspaceRoot = process.env.TURN_DIFFERENTIAL_WORKSPACE_ROOT;
|
||||||
|
const workspaceRoot = configuredWorkspaceRoot ?? findTurnDifferentialWorkspaceRoot(process.cwd());
|
||||||
|
const integration = describe.skipIf(!workspaceRoot || process.env.TURN_DIFFERENTIAL_REFERENCE !== '1');
|
||||||
|
|
||||||
|
const baseSetup = (state: number, term: number) => ({
|
||||||
|
isolateWorld: true,
|
||||||
|
world: { year: 190, month: 3 },
|
||||||
|
nations: [
|
||||||
|
{ id: 1, name: '수락국', capitalCityId: 1 },
|
||||||
|
{ id: 2, name: '제안국', capitalCityId: 2 },
|
||||||
|
],
|
||||||
|
cities: [
|
||||||
|
{ id: 1, nationId: 1, supplyState: 1, frontState: 1 },
|
||||||
|
{ id: 2, nationId: 2, supplyState: 1, frontState: 1 },
|
||||||
|
],
|
||||||
|
generals: [
|
||||||
|
{ id: 1, name: '수락장수', nationId: 1, cityId: 1, officerLevel: 5 },
|
||||||
|
{ id: 2, name: '제안장수', nationId: 2, cityId: 2, officerLevel: 5 },
|
||||||
|
],
|
||||||
|
diplomacy: [
|
||||||
|
{ fromNationId: 1, toNationId: 2, state, term },
|
||||||
|
{ fromNationId: 2, toNationId: 1, state, term },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const observe = {
|
||||||
|
generalIds: [1, 2],
|
||||||
|
nationIds: [1, 2],
|
||||||
|
cityIds: [1, 2],
|
||||||
|
logAfterId: 0,
|
||||||
|
messageAfterId: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const addedLogs = (trace: ReturnType<typeof runReferenceTurnCommandTraceRequest>) =>
|
||||||
|
trace.after.logs.filter((log) => Number(log.id) > trace.before.watermarks.logId);
|
||||||
|
|
||||||
|
integration('legacy instant diplomacy responses', () => {
|
||||||
|
it('accepts non-aggression without RNG and copies received assistance', () => {
|
||||||
|
const setup = baseSetup(2, 0);
|
||||||
|
setup.nations[1] = {
|
||||||
|
...setup.nations[1],
|
||||||
|
nationEnv: { recv_assist: { n1: [1, 37] } },
|
||||||
|
} as (typeof setup.nations)[number];
|
||||||
|
const trace = runReferenceTurnCommandTraceRequest(workspaceRoot!, {
|
||||||
|
kind: 'instantNation',
|
||||||
|
actorGeneralId: 1,
|
||||||
|
action: 'che_불가침수락',
|
||||||
|
args: { destNationID: 2, destGeneralID: 2, year: 191, month: 2 },
|
||||||
|
setup,
|
||||||
|
observe,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(trace.execution).toMatchObject({
|
||||||
|
kind: 'instantNation',
|
||||||
|
action: 'che_불가침수락',
|
||||||
|
seedDomain: 'none',
|
||||||
|
});
|
||||||
|
expect(trace.rng).toEqual([]);
|
||||||
|
expect(trace.after.diplomacy).toEqual([
|
||||||
|
expect.objectContaining({ fromNationId: 1, toNationId: 2, state: 7, term: 12 }),
|
||||||
|
expect.objectContaining({ fromNationId: 2, toNationId: 1, state: 7, term: 12 }),
|
||||||
|
]);
|
||||||
|
expect(trace.after.nations[1]).toMatchObject({
|
||||||
|
meta: {
|
||||||
|
recv_assist: { n1: [1, 37] },
|
||||||
|
resp_assist: { n1: [1, 37] },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(addedLogs(trace).map((log) => [log.generalId, log.category])).toEqual([
|
||||||
|
[1, 'history'],
|
||||||
|
[1, 'action'],
|
||||||
|
[2, 'history'],
|
||||||
|
[2, 'action'],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts non-aggression cancellation with legacy grouped log order', () => {
|
||||||
|
const trace = runReferenceTurnCommandTraceRequest(workspaceRoot!, {
|
||||||
|
kind: 'instantNation',
|
||||||
|
actorGeneralId: 1,
|
||||||
|
action: 'che_불가침파기수락',
|
||||||
|
args: { destNationID: 2, destGeneralID: 2 },
|
||||||
|
setup: baseSetup(7, 12),
|
||||||
|
observe,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(trace.rng).toEqual([]);
|
||||||
|
expect(trace.after.diplomacy).toEqual([
|
||||||
|
expect.objectContaining({ fromNationId: 1, toNationId: 2, state: 2, term: 0 }),
|
||||||
|
expect.objectContaining({ fromNationId: 2, toNationId: 1, state: 2, term: 0 }),
|
||||||
|
]);
|
||||||
|
expect(addedLogs(trace).map((log) => [log.generalId, log.category])).toEqual([
|
||||||
|
[1, 'history'],
|
||||||
|
[1, 'action'],
|
||||||
|
[0, 'history'],
|
||||||
|
[2, 'history'],
|
||||||
|
[2, 'action'],
|
||||||
|
]);
|
||||||
|
expect(addedLogs(trace)[2]?.text).toContain('수락장수');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts stop-war and recalculates both nations fronts', () => {
|
||||||
|
const setup = baseSetup(0, 6);
|
||||||
|
setup.diplomacy[1] = { fromNationId: 2, toNationId: 1, state: 1, term: 6 };
|
||||||
|
const trace = runReferenceTurnCommandTraceRequest(workspaceRoot!, {
|
||||||
|
kind: 'instantNation',
|
||||||
|
actorGeneralId: 1,
|
||||||
|
action: 'che_종전수락',
|
||||||
|
args: { destNationID: 2, destGeneralID: 2 },
|
||||||
|
setup,
|
||||||
|
observe,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(trace.rng).toEqual([]);
|
||||||
|
expect(trace.after.diplomacy).toEqual([
|
||||||
|
expect.objectContaining({ fromNationId: 1, toNationId: 2, state: 2, term: 0 }),
|
||||||
|
expect.objectContaining({ fromNationId: 2, toNationId: 1, state: 2, term: 0 }),
|
||||||
|
]);
|
||||||
|
expect(trace.after.cities).toEqual([
|
||||||
|
expect.objectContaining({ id: 1, frontState: 2 }),
|
||||||
|
expect.objectContaining({ id: 2, frontState: 2 }),
|
||||||
|
]);
|
||||||
|
expect(addedLogs(trace).map((log) => [log.generalId, log.category])).toEqual([
|
||||||
|
[1, 'history'],
|
||||||
|
[1, 'action'],
|
||||||
|
[0, 'history'],
|
||||||
|
[2, 'history'],
|
||||||
|
[2, 'action'],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user