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> => {
|
||||
const uniqueIds = Array.from(new Set(ids.filter((id) => Number.isInteger(id) && id > 0)));
|
||||
if (uniqueIds.length === 0) return;
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
import { publishRealtimeEvent } from '../../realtime/publisher.js';
|
||||
import { getOwnedGeneral } from '../shared/general.js';
|
||||
import { resolveNationPermission } from '../nation/shared.js';
|
||||
import { respondToDiplomaticMessage } from '../../messages/diplomaticResponse.js';
|
||||
|
||||
const zMessageType = z.enum(['private', 'public', 'national', 'diplomacy']);
|
||||
|
||||
@@ -45,8 +46,8 @@ export const messagesRouter = router({
|
||||
diplomacy: MESSAGE_MAILBOX_NATIONAL_BASE + nationId,
|
||||
} satisfies Record<MessageType, number>;
|
||||
|
||||
const [privateMessages, publicMessages, nationalMessages, diplomacyMessages, readState] = await Promise.all(
|
||||
[
|
||||
const [privateMessages, publicMessages, nationalMessages, diplomacyMessages, readState, nation] =
|
||||
await Promise.all([
|
||||
fetchMessagesFromMailbox({
|
||||
db: ctx.db,
|
||||
mailbox: mailboxes.private,
|
||||
@@ -76,8 +77,13 @@ export const messagesRouter = router({
|
||||
fromSeq: sequence,
|
||||
}),
|
||||
ctx.db.messageReadState.findUnique({ where: { generalId: general.id } }),
|
||||
]
|
||||
);
|
||||
nationId > 0
|
||||
? ctx.db.nation.findUnique({
|
||||
where: { id: nationId },
|
||||
select: { meta: true },
|
||||
})
|
||||
: null,
|
||||
]);
|
||||
|
||||
const messageBuckets: Record<MessageType, MessageView[]> = {
|
||||
private: privateMessages,
|
||||
@@ -122,6 +128,10 @@ export const messagesRouter = router({
|
||||
sequence: nextSequence,
|
||||
nationId: nationId,
|
||||
generalName: general.name,
|
||||
canRespondDiplomacy:
|
||||
general.officerLevel > 4 &&
|
||||
nation !== null &&
|
||||
resolveNationPermission(general, nation.meta, false) >= 4,
|
||||
latestRead: {
|
||||
diplomacy: readState?.latestDiplomacyMessage ?? 0,
|
||||
private: readState?.latestPrivateMessage ?? 0,
|
||||
@@ -235,6 +245,40 @@ export const messagesRouter = router({
|
||||
await invalidateMessages(ctx.db, ids);
|
||||
return { ok: true, deletedIds: ids };
|
||||
}),
|
||||
respond: authedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
generalId: z.number().int().positive(),
|
||||
messageId: z.number().int().positive(),
|
||||
response: z.boolean(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const general = await getOwnedGeneral(ctx, input.generalId);
|
||||
const result = await respondToDiplomaticMessage({
|
||||
db: ctx.db,
|
||||
actor: general,
|
||||
messageId: input.messageId,
|
||||
response: input.response,
|
||||
});
|
||||
if (result.result) {
|
||||
for (const mailbox of result.affectedMailboxes) {
|
||||
try {
|
||||
await publishRealtimeEvent(ctx.redis, ctx.profile.name, {
|
||||
type: 'messageCreated',
|
||||
at: new Date().toISOString(),
|
||||
mailbox,
|
||||
msgType: 'diplomacy',
|
||||
messageId: input.messageId,
|
||||
senderId: general.id,
|
||||
});
|
||||
} catch {
|
||||
// 실시간 알림 실패는 외교 응답 실패로 취급하지 않는다.
|
||||
}
|
||||
}
|
||||
}
|
||||
return { result: result.result, reason: result.reason };
|
||||
}),
|
||||
getOld: authedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
|
||||
@@ -78,6 +78,25 @@ describe('messages router missing-flow compatibility', () => {
|
||||
const result = await caller.messages.getRecent({ generalId: general.id });
|
||||
|
||||
expect(result.latestRead).toEqual({ private: 11, diplomacy: 13 });
|
||||
expect(result.canRespondDiplomacy).toBe(false);
|
||||
});
|
||||
|
||||
it('marks the current ruler as able to answer diplomacy prompts', async () => {
|
||||
const ruler = { ...general, officerLevel: 12 };
|
||||
const { caller } = buildContext({
|
||||
general: {
|
||||
findUnique: vi.fn(async () => ruler),
|
||||
findMany: vi.fn(async () => []),
|
||||
},
|
||||
nation: {
|
||||
findMany: vi.fn(async () => []),
|
||||
findUnique: vi.fn(async () => ({ meta: {} })),
|
||||
},
|
||||
});
|
||||
|
||||
const result = await caller.messages.getRecent({ generalId: ruler.id });
|
||||
|
||||
expect(result.canRespondDiplomacy).toBe(true);
|
||||
});
|
||||
|
||||
it('persists latest-read updates through the monotonic upsert', async () => {
|
||||
@@ -166,4 +185,336 @@ describe('messages router missing-flow compatibility', () => {
|
||||
code: 'FORBIDDEN',
|
||||
});
|
||||
});
|
||||
|
||||
const buildDiplomaticContext = (options?: {
|
||||
action?: 'noAggression' | 'cancelNA' | 'stopWar';
|
||||
actorNationId?: number;
|
||||
mailboxNationId?: number;
|
||||
proposerNationId?: number;
|
||||
proposerCurrentNationId?: number;
|
||||
diplomacyState?: number;
|
||||
response?: boolean;
|
||||
cities?: Array<{ id: number; nationId: number; frontState: number }>;
|
||||
}) => {
|
||||
const action = options?.action ?? 'noAggression';
|
||||
const actorNationId = options?.actorNationId ?? 1;
|
||||
const mailboxNationId = options?.mailboxNationId ?? actorNationId;
|
||||
const proposerNationId = options?.proposerNationId ?? 2;
|
||||
const actor = {
|
||||
...general,
|
||||
cityId: 10,
|
||||
nationId: actorNationId,
|
||||
officerLevel: 12,
|
||||
meta: {},
|
||||
penalty: {},
|
||||
} as GeneralRow;
|
||||
const proposer = {
|
||||
...general,
|
||||
id: 8,
|
||||
userId: 'user-8',
|
||||
name: '제안자',
|
||||
cityId: 20,
|
||||
nationId: options?.proposerCurrentNationId ?? proposerNationId,
|
||||
officerLevel: 12,
|
||||
meta: {},
|
||||
penalty: {},
|
||||
} as GeneralRow;
|
||||
const messageRow = {
|
||||
id: 31,
|
||||
mailbox: 9000 + mailboxNationId,
|
||||
type: 'diplomacy',
|
||||
src: 9000 + proposerNationId,
|
||||
dest: 9000 + actorNationId,
|
||||
time: new Date(),
|
||||
valid_until: new Date('9999-12-31T00:00:00Z'),
|
||||
message: {
|
||||
src: {
|
||||
generalId: proposer.id,
|
||||
generalName: proposer.name,
|
||||
nationId: proposerNationId,
|
||||
nationName: '촉',
|
||||
color: '#000',
|
||||
icon: '',
|
||||
},
|
||||
dest: {
|
||||
generalId: actor.id,
|
||||
generalName: actor.name,
|
||||
nationId: actorNationId,
|
||||
nationName: '위',
|
||||
color: '#fff',
|
||||
icon: '',
|
||||
},
|
||||
text: '외교 제안',
|
||||
option: {
|
||||
action,
|
||||
...(action === 'noAggression' ? { year: 201, month: 2 } : {}),
|
||||
},
|
||||
},
|
||||
};
|
||||
let rawCall = 0;
|
||||
let insertedId = 100;
|
||||
const queryRaw = vi.fn(async () => {
|
||||
rawCall += 1;
|
||||
if (rawCall === 1) {
|
||||
return [messageRow];
|
||||
}
|
||||
if (rawCall >= 4) {
|
||||
insertedId += 1;
|
||||
return [{ id: insertedId }];
|
||||
}
|
||||
return [];
|
||||
});
|
||||
const diplomacyState = options?.diplomacyState ?? (action === 'cancelNA' ? 7 : action === 'stopWar' ? 0 : 2);
|
||||
const diplomacyRows = [
|
||||
{
|
||||
id: 1,
|
||||
srcNationId: actorNationId,
|
||||
destNationId: proposerNationId,
|
||||
stateCode: diplomacyState,
|
||||
term: 6,
|
||||
isDead: false,
|
||||
isShowing: true,
|
||||
meta: {},
|
||||
createdAt: new Date(),
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
srcNationId: proposerNationId,
|
||||
destNationId: actorNationId,
|
||||
stateCode: diplomacyState,
|
||||
term: 6,
|
||||
isDead: false,
|
||||
isShowing: true,
|
||||
meta: {},
|
||||
createdAt: new Date(),
|
||||
},
|
||||
];
|
||||
const diplomacyUpdate = vi.fn(
|
||||
async ({
|
||||
where,
|
||||
data,
|
||||
}: {
|
||||
where: { srcNationId_destNationId: { srcNationId: number; destNationId: number } };
|
||||
data: { stateCode?: number; term?: number };
|
||||
}) => {
|
||||
const row = diplomacyRows.find(
|
||||
(entry) =>
|
||||
entry.srcNationId === where.srcNationId_destNationId.srcNationId &&
|
||||
entry.destNationId === where.srcNationId_destNationId.destNationId
|
||||
);
|
||||
if (row) {
|
||||
if (data.stateCode !== undefined) row.stateCode = data.stateCode;
|
||||
if (data.term !== undefined) row.term = data.term;
|
||||
}
|
||||
return {};
|
||||
}
|
||||
);
|
||||
const nationUpdate = vi.fn(async () => ({}));
|
||||
const logCreateMany = vi.fn(async () => ({ count: 1 }));
|
||||
const messageUpdateMany = vi.fn(async () => ({ count: 1 }));
|
||||
const cityUpdate = vi.fn(async () => ({}));
|
||||
const { caller } = buildContext({
|
||||
general: {
|
||||
findUnique: vi.fn(async ({ where }: { where: { id: number } }) =>
|
||||
where.id === actor.id ? actor : where.id === proposer.id ? proposer : null
|
||||
),
|
||||
findMany: vi.fn(async () => []),
|
||||
},
|
||||
nation: {
|
||||
findUnique: vi.fn(async ({ where }: { where: { id: number } }) =>
|
||||
where.id === actorNationId
|
||||
? {
|
||||
id: actorNationId,
|
||||
name: '위',
|
||||
color: '#fff',
|
||||
capitalCityId: 10,
|
||||
chiefGeneralId: actor.id,
|
||||
gold: 1000,
|
||||
rice: 1000,
|
||||
tech: 0,
|
||||
level: 1,
|
||||
typeCode: 'test',
|
||||
meta: {},
|
||||
}
|
||||
: where.id === proposerNationId
|
||||
? {
|
||||
id: proposerNationId,
|
||||
name: '촉',
|
||||
color: '#000',
|
||||
capitalCityId: 20,
|
||||
chiefGeneralId: proposer.id,
|
||||
gold: 1000,
|
||||
rice: 1000,
|
||||
tech: 0,
|
||||
level: 1,
|
||||
typeCode: 'test',
|
||||
meta: { recv_assist: { [`n${actorNationId}`]: [actorNationId, 50] } },
|
||||
}
|
||||
: null
|
||||
),
|
||||
findMany: vi.fn(async () => []),
|
||||
update: nationUpdate,
|
||||
},
|
||||
city: {
|
||||
findUnique: vi.fn(async () => ({
|
||||
id: 10,
|
||||
nationId: actorNationId,
|
||||
supplyState: 1,
|
||||
})),
|
||||
findMany: vi.fn(async () => options?.cities ?? []),
|
||||
update: cityUpdate,
|
||||
},
|
||||
diplomacy: {
|
||||
findUnique: vi.fn(
|
||||
async ({
|
||||
where,
|
||||
}: {
|
||||
where: { srcNationId_destNationId: { srcNationId: number; destNationId: number } };
|
||||
}) =>
|
||||
diplomacyRows.find(
|
||||
(row) =>
|
||||
row.srcNationId === where.srcNationId_destNationId.srcNationId &&
|
||||
row.destNationId === where.srcNationId_destNationId.destNationId
|
||||
) ?? null
|
||||
),
|
||||
findMany: vi.fn(async () => diplomacyRows),
|
||||
update: diplomacyUpdate,
|
||||
},
|
||||
worldState: {
|
||||
findFirst: vi.fn(async () => ({
|
||||
currentYear: 200,
|
||||
currentMonth: 3,
|
||||
config: { environment: { mapName: 'che' } },
|
||||
})),
|
||||
},
|
||||
logEntry: { createMany: logCreateMany },
|
||||
message: { updateMany: messageUpdateMany },
|
||||
$queryRaw: queryRaw,
|
||||
});
|
||||
return {
|
||||
caller,
|
||||
actor,
|
||||
proposer,
|
||||
queryRaw,
|
||||
diplomacyUpdate,
|
||||
nationUpdate,
|
||||
logCreateMany,
|
||||
messageUpdateMany,
|
||||
cityUpdate,
|
||||
};
|
||||
};
|
||||
|
||||
it('accepts a diplomatic prompt atomically and applies the legacy non-aggression effects', async () => {
|
||||
const setup = buildDiplomaticContext();
|
||||
|
||||
const result = await setup.caller.messages.respond({
|
||||
generalId: setup.actor.id,
|
||||
messageId: 31,
|
||||
response: true,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ result: true, reason: 'success' });
|
||||
expect(setup.diplomacyUpdate).toHaveBeenCalledTimes(2);
|
||||
expect(setup.nationUpdate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { id: 2 },
|
||||
data: {
|
||||
meta: {
|
||||
recv_assist: { n1: [1, 50] },
|
||||
resp_assist: { n1: [1, 50] },
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
expect(setup.messageUpdateMany).toHaveBeenCalledWith({
|
||||
where: { id: { in: [31] } },
|
||||
data: { validUntil: expect.any(Date) },
|
||||
});
|
||||
expect(setup.queryRaw).toHaveBeenCalledTimes(8);
|
||||
});
|
||||
|
||||
it('declines a diplomatic prompt without changing diplomacy', async () => {
|
||||
const setup = buildDiplomaticContext({ action: 'stopWar' });
|
||||
|
||||
const result = await setup.caller.messages.respond({
|
||||
generalId: setup.actor.id,
|
||||
messageId: 31,
|
||||
response: false,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ result: true, reason: 'success' });
|
||||
expect(setup.diplomacyUpdate).not.toHaveBeenCalled();
|
||||
expect(setup.messageUpdateMany).toHaveBeenCalledOnce();
|
||||
expect(setup.logCreateMany).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['cancelNA' as const, 7],
|
||||
['stopWar' as const, 0],
|
||||
])('accepts the %s prompt through the same runtime path', async (action, diplomacyState) => {
|
||||
const setup = buildDiplomaticContext({
|
||||
action,
|
||||
diplomacyState,
|
||||
...(action === 'stopWar'
|
||||
? {
|
||||
cities: [
|
||||
{ id: 1, nationId: 1, frontState: 3 },
|
||||
{ id: 9, nationId: 2, frontState: 3 },
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
|
||||
const result = await setup.caller.messages.respond({
|
||||
generalId: setup.actor.id,
|
||||
messageId: 31,
|
||||
response: true,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ result: true, reason: 'success' });
|
||||
expect(setup.diplomacyUpdate).toHaveBeenCalledTimes(2);
|
||||
if (action === 'stopWar') {
|
||||
expect(setup.cityUpdate).toHaveBeenCalledTimes(2);
|
||||
expect(setup.cityUpdate).toHaveBeenCalledWith({
|
||||
where: { id: 1 },
|
||||
data: { frontState: 0 },
|
||||
});
|
||||
expect(setup.cityUpdate).toHaveBeenCalledWith({
|
||||
where: { id: 9 },
|
||||
data: { frontState: 0 },
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects a prompt when the proposer has left the proposing nation', async () => {
|
||||
const setup = buildDiplomaticContext({ proposerCurrentNationId: 3 });
|
||||
|
||||
const result = await setup.caller.messages.respond({
|
||||
generalId: setup.actor.id,
|
||||
messageId: 31,
|
||||
response: true,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ result: false, reason: '제의 장수가 국가 소속이 아닙니다' });
|
||||
expect(setup.diplomacyUpdate).not.toHaveBeenCalled();
|
||||
expect(setup.messageUpdateMany).not.toHaveBeenCalled();
|
||||
expect(setup.logCreateMany).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('does not let another nation process the diplomatic inbox row', async () => {
|
||||
const setup = buildDiplomaticContext({ mailboxNationId: 2 });
|
||||
|
||||
const result = await setup.caller.messages.respond({
|
||||
generalId: setup.actor.id,
|
||||
messageId: 31,
|
||||
response: true,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
result: false,
|
||||
reason: '송신자가 외교서신을 처리할 수 없습니다.',
|
||||
});
|
||||
expect(setup.diplomacyUpdate).not.toHaveBeenCalled();
|
||||
expect(setup.messageUpdateMany).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { City, MapDefinition, Nation } from '@sammo-ts/logic';
|
||||
import { buildNationFrontStatePatches, type City, type MapDefinition, type Nation } from '@sammo-ts/logic';
|
||||
import type { InMemoryTurnWorld, TurnCalendarHandler } from './inMemoryWorld.js';
|
||||
import type { TurnDiplomacy } from './types.js';
|
||||
|
||||
@@ -12,92 +12,6 @@ const buildConnectionMap = (map: MapDefinition): ConnectionMap => {
|
||||
return connectionMap;
|
||||
};
|
||||
|
||||
const collectAdjacentCities = (cityIds: number[], connectionMap: ConnectionMap, bucket: Set<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: {
|
||||
worldView: {
|
||||
listCities(): City[];
|
||||
@@ -114,32 +28,19 @@ export const buildFrontStatePatches = (options: {
|
||||
if (connectionMap.size === 0) {
|
||||
return [];
|
||||
}
|
||||
const cities = options.worldView.listCities();
|
||||
const cityIdsByNation = collectNationCityIds(cities);
|
||||
const diplomacy = options.worldView.listDiplomacy();
|
||||
const cityMap = new Map<number, City>();
|
||||
for (const city of cities) {
|
||||
cityMap.set(city.id, city);
|
||||
}
|
||||
|
||||
const nationList = options.nationIds
|
||||
? options.worldView
|
||||
.listNations()
|
||||
.filter((nation) => options.nationIds?.includes(nation.id))
|
||||
? options.worldView.listNations().filter((nation) => options.nationIds?.includes(nation.id))
|
||||
: options.worldView.listNations();
|
||||
const nations = nationList.filter((nation) => nation.level > 0);
|
||||
const patches: Array<{ id: number; patch: Partial<City> }> = [];
|
||||
for (const nation of nations) {
|
||||
const frontStates = resolveNationFrontStates(nation.id, connectionMap, cityIdsByNation, diplomacy);
|
||||
for (const [cityId, nextFrontState] of frontStates) {
|
||||
const city = cityMap.get(cityId);
|
||||
if (!city || city.frontState === nextFrontState) {
|
||||
continue;
|
||||
}
|
||||
patches.push({ id: cityId, patch: { frontState: nextFrontState } });
|
||||
}
|
||||
}
|
||||
return patches;
|
||||
const nationIds = nationList.filter((nation) => nation.level > 0).map((nation) => nation.id);
|
||||
return buildNationFrontStatePatches({
|
||||
cities: options.worldView.listCities(),
|
||||
diplomacy: options.worldView.listDiplomacy(),
|
||||
connections: connectionMap,
|
||||
nationIds,
|
||||
}).map((patch) => ({
|
||||
id: patch.id,
|
||||
patch: { frontState: patch.frontState },
|
||||
}));
|
||||
};
|
||||
|
||||
export const createFrontStateHandler = (options: {
|
||||
|
||||
@@ -8,6 +8,7 @@ interface MessageEntry {
|
||||
text: string;
|
||||
time: string;
|
||||
msgType: MessageType;
|
||||
option?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
interface MessageBucket {
|
||||
@@ -23,6 +24,7 @@ const props = defineProps<{
|
||||
targetMailbox: number;
|
||||
draftText: string;
|
||||
mailboxOptions: Array<{ label: string; value: number; disabled?: boolean }>;
|
||||
canRespondDiplomacy: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -31,6 +33,7 @@ const emit = defineEmits<{
|
||||
(event: 'send'): void;
|
||||
(event: 'refresh'): void;
|
||||
(event: 'load-older', type: MessageType): void;
|
||||
(event: 'respond', messageId: number, response: boolean): void;
|
||||
}>();
|
||||
|
||||
const messageTabs: Array<{ key: MessageType; label: string }> = [
|
||||
@@ -53,6 +56,19 @@ const setMailbox = (value: string) => {
|
||||
const parsed = Number(value);
|
||||
emit('update:targetMailbox', Number.isFinite(parsed) ? parsed : 0);
|
||||
};
|
||||
|
||||
const isDiplomacyPrompt = (message: MessageEntry): boolean =>
|
||||
message.msgType === 'diplomacy' &&
|
||||
(message.option?.action === 'noAggression' ||
|
||||
message.option?.action === 'cancelNA' ||
|
||||
message.option?.action === 'stopWar');
|
||||
|
||||
const respond = (messageId: number, response: boolean) => {
|
||||
if (!window.confirm(response ? '수락하시겠습니까?' : '거절하시겠습니까?')) {
|
||||
return;
|
||||
}
|
||||
emit('respond', messageId, response);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -99,14 +115,20 @@ const setMailbox = (value: string) => {
|
||||
<div v-if="props.loading">
|
||||
<SkeletonLines :lines="4" />
|
||||
</div>
|
||||
<div v-else-if="!props.messages" class="empty">
|
||||
메시지를 불러오지 못했습니다.
|
||||
</div>
|
||||
<div v-else-if="!props.messages" class="empty">메시지를 불러오지 못했습니다.</div>
|
||||
<div v-else class="message-list">
|
||||
<div v-if="activeMessages.length === 0" class="empty">메시지가 없습니다.</div>
|
||||
<div v-else>
|
||||
<div v-for="message in activeMessages" :key="message.id" class="message-item">
|
||||
<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>
|
||||
<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);
|
||||
}
|
||||
|
||||
.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 {
|
||||
border: 1px dashed rgba(201, 164, 90, 0.3);
|
||||
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 id = generalId.value;
|
||||
if (!id) {
|
||||
@@ -311,7 +331,6 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
let realtimeSource: EventSource | null = null;
|
||||
let realtimeToken: string | null = null;
|
||||
|
||||
@@ -472,6 +491,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
||||
refreshMessages,
|
||||
sendMessage,
|
||||
loadOlderMessages,
|
||||
respondToMessage,
|
||||
setGeneralTurn,
|
||||
shiftGeneralTurns,
|
||||
setNationTurn,
|
||||
|
||||
@@ -207,11 +207,13 @@ watch(
|
||||
:target-mailbox="targetMailbox"
|
||||
:draft-text="messageDraftText"
|
||||
:mailbox-options="mailboxOptions"
|
||||
:can-respond-diplomacy="messages?.canRespondDiplomacy ?? false"
|
||||
@update:target-mailbox="targetMailbox = $event"
|
||||
@update:draft-text="messageDraftText = $event"
|
||||
@send="dashboard.sendMessage"
|
||||
@load-older="dashboard.loadOlderMessages"
|
||||
@refresh="dashboard.refreshMessages"
|
||||
@respond="dashboard.respondToMessage"
|
||||
/>
|
||||
</PanelCard>
|
||||
</div>
|
||||
@@ -240,11 +242,13 @@ watch(
|
||||
:target-mailbox="targetMailbox"
|
||||
:draft-text="messageDraftText"
|
||||
:mailbox-options="mailboxOptions"
|
||||
:can-respond-diplomacy="messages?.canRespondDiplomacy ?? false"
|
||||
@update:target-mailbox="targetMailbox = $event"
|
||||
@update:draft-text="messageDraftText = $event"
|
||||
@send="dashboard.sendMessage"
|
||||
@load-older="dashboard.loadOlderMessages"
|
||||
@refresh="dashboard.refreshMessages"
|
||||
@respond="dashboard.respondToMessage"
|
||||
/>
|
||||
</PanelCard>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user