fix: 외교문서와 수뇌 예약의 Ref 계약을 복원한다
This commit is contained in:
@@ -1,4 +1,5 @@
|
|||||||
import { enqueuePrivateMessageWebPush, GamePrisma } from '@sammo-ts/infra';
|
import { enqueuePrivateMessageWebPush, GamePrisma } from '@sammo-ts/infra';
|
||||||
|
import { MAX_SAFE_GAME_TICK } from '@sammo-ts/common';
|
||||||
import type { MessagePayload, MessageRecordDraft, MessageType } from '@sammo-ts/logic';
|
import type { MessagePayload, MessageRecordDraft, MessageType } from '@sammo-ts/logic';
|
||||||
|
|
||||||
import type { DatabaseClient } from '../context.js';
|
import type { DatabaseClient } from '../context.js';
|
||||||
@@ -63,6 +64,11 @@ const toMessageView = (row: MessageRow): MessageView => {
|
|||||||
export const insertMessage = async (db: DatabaseClient, draft: MessageRecordDraft): Promise<number> => {
|
export const insertMessage = async (db: DatabaseClient, draft: MessageRecordDraft): Promise<number> => {
|
||||||
const gameTime = await loadCurrentGameTime(db);
|
const gameTime = await loadCurrentGameTime(db);
|
||||||
const toTickOrNull = (date: Date): bigint | null => {
|
const toTickOrNull = (date: Date): bigint | null => {
|
||||||
|
// Ref represents its unlimited 9999-12-31 message lifetime with the
|
||||||
|
// largest safe game tick instead of falling back to a wall-clock-only row.
|
||||||
|
if (date.getUTCFullYear() >= 9000) {
|
||||||
|
return BigInt(MAX_SAFE_GAME_TICK);
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const tick = gameTime.dateToTick(date);
|
const tick = gameTime.dateToTick(date);
|
||||||
return tick === null ? null : BigInt(tick);
|
return tick === null ? null : BigInt(tick);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { MessageTarget } from '@sammo-ts/logic';
|
import { resolveMessageTargetIcon, type MessageTarget } from '@sammo-ts/logic';
|
||||||
|
|
||||||
import type { DatabaseClient, GeneralRow } from '../context.js';
|
import type { DatabaseClient, GeneralRow } from '../context.js';
|
||||||
|
|
||||||
@@ -42,5 +42,5 @@ export const buildNationTarget = (nationId: number, nationName: string, color: s
|
|||||||
nationId,
|
nationId,
|
||||||
nationName,
|
nationName,
|
||||||
color,
|
color,
|
||||||
icon: '',
|
icon: resolveMessageTargetIcon(null),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,15 +1,95 @@
|
|||||||
import { TRPCError } from '@trpc/server';
|
import { TRPCError } from '@trpc/server';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { asRecord } from '@sammo-ts/common';
|
import { asRecord, JosaUtil } from '@sammo-ts/common';
|
||||||
import type { GamePrisma } from '@sammo-ts/infra';
|
import type { GamePrisma } from '@sammo-ts/infra';
|
||||||
|
import {
|
||||||
|
MESSAGE_MAILBOX_NATIONAL_BASE,
|
||||||
|
resolveMessageTargetIcon,
|
||||||
|
sendMessage,
|
||||||
|
type MessageDraft,
|
||||||
|
type MessageRecordDraft,
|
||||||
|
type MessageTarget,
|
||||||
|
} from '@sammo-ts/logic';
|
||||||
|
|
||||||
|
import type { GameApiContext, GeneralRow, NationRow } from '../../context.js';
|
||||||
|
import { insertMessage } from '../../messages/store.js';
|
||||||
import { purifyDiplomacyHtml } from '../../security/diplomacyHtml.js';
|
import { purifyDiplomacyHtml } from '../../security/diplomacyHtml.js';
|
||||||
import { loadCurrentGameTime } from '../../services/gameClock.js';
|
import { loadCurrentGameTime } from '../../services/gameClock.js';
|
||||||
import { accessAuthedInputProcedure, accessAuthedProcedure, router } from '../../trpc.js';
|
import { accessAuthedInputProcedure, accessAuthedProcedure, router } from '../../trpc.js';
|
||||||
import { getMyGeneral } from '../shared/general.js';
|
import { getMyGeneral } from '../shared/general.js';
|
||||||
import { assertNationAccess, resolveNationPermission } from '../nation/shared.js';
|
import { assertNationAccess, resolveNationPermission } from '../nation/shared.js';
|
||||||
|
|
||||||
|
const DIPLOMACY_MESSAGE_VALID_UNTIL = new Date('9999-12-31T00:00:00.000Z');
|
||||||
|
|
||||||
|
type DiplomacyNation = Pick<NationRow, 'id' | 'name' | 'color'>;
|
||||||
|
|
||||||
|
const buildActorTarget = (general: GeneralRow, nation: DiplomacyNation): MessageTarget => ({
|
||||||
|
generalId: general.id,
|
||||||
|
generalName: general.name,
|
||||||
|
nationId: nation.id,
|
||||||
|
nationName: nation.name,
|
||||||
|
color: nation.color,
|
||||||
|
icon: resolveMessageTargetIcon({ picture: general.picture, imageServer: general.imageServer }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const buildNationTarget = (nation: DiplomacyNation): MessageTarget => ({
|
||||||
|
generalId: 0,
|
||||||
|
generalName: '',
|
||||||
|
nationId: nation.id,
|
||||||
|
nationName: nation.name,
|
||||||
|
color: nation.color,
|
||||||
|
icon: resolveMessageTargetIcon(null),
|
||||||
|
});
|
||||||
|
|
||||||
|
const loadLetterNations = async (
|
||||||
|
ctx: Pick<GameApiContext, 'db'>,
|
||||||
|
srcNationId: number,
|
||||||
|
destNationId: number
|
||||||
|
): Promise<{ srcNation: DiplomacyNation; destNation: DiplomacyNation }> => {
|
||||||
|
const nations = await ctx.db.nation.findMany({
|
||||||
|
where: { id: { in: [srcNationId, destNationId] } },
|
||||||
|
select: { id: true, name: true, color: true },
|
||||||
|
});
|
||||||
|
const srcNation = nations.find((nation) => nation.id === srcNationId);
|
||||||
|
const destNation = nations.find((nation) => nation.id === destNationId);
|
||||||
|
if (!srcNation || !destNation) {
|
||||||
|
throw new TRPCError({ code: 'BAD_REQUEST', message: '올바르지 않은 국가입니다.' });
|
||||||
|
}
|
||||||
|
return { srcNation, destNation };
|
||||||
|
};
|
||||||
|
|
||||||
|
const sendDocumentNotice = async (options: {
|
||||||
|
ctx: Pick<GameApiContext, 'db' | 'changeJournal'>;
|
||||||
|
src: MessageTarget;
|
||||||
|
dest: MessageTarget;
|
||||||
|
text: string;
|
||||||
|
time: Date;
|
||||||
|
includeNational?: boolean;
|
||||||
|
}): Promise<void> => {
|
||||||
|
const store = {
|
||||||
|
insertMessage: (draft: MessageRecordDraft) => insertMessage(options.ctx.db, draft),
|
||||||
|
};
|
||||||
|
const draft = {
|
||||||
|
msgType: 'diplomacy',
|
||||||
|
src: options.src,
|
||||||
|
dest: options.dest,
|
||||||
|
text: options.text,
|
||||||
|
time: options.time,
|
||||||
|
validUntil: DIPLOMACY_MESSAGE_VALID_UNTIL,
|
||||||
|
option: { deletable: false },
|
||||||
|
} satisfies MessageDraft;
|
||||||
|
|
||||||
|
// Ref는 외교 사본을 먼저, 응답 때만 같은 문구의 국가 사본을 뒤이어 보낸다.
|
||||||
|
await sendMessage(store, draft);
|
||||||
|
if (options.includeNational) {
|
||||||
|
await sendMessage(store, { ...draft, msgType: 'national' });
|
||||||
|
}
|
||||||
|
|
||||||
|
options.ctx.changeJournal?.mark('messages.mailbox', MESSAGE_MAILBOX_NATIONAL_BASE + options.src.nationId);
|
||||||
|
options.ctx.changeJournal?.mark('messages.mailbox', MESSAGE_MAILBOX_NATIONAL_BASE + options.dest.nationId);
|
||||||
|
};
|
||||||
|
|
||||||
const resolvePermissionLevel = async (ctx: Parameters<typeof getMyGeneral>[0], nationId: number) => {
|
const resolvePermissionLevel = async (ctx: Parameters<typeof getMyGeneral>[0], nationId: number) => {
|
||||||
const nation = await ctx.db.nation.findUnique({
|
const nation = await ctx.db.nation.findUnique({
|
||||||
where: { id: nationId },
|
where: { id: nationId },
|
||||||
@@ -211,13 +291,15 @@ export const diplomacyRouter = router({
|
|||||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '올바르지 않은 국가입니다.' });
|
throw new TRPCError({ code: 'BAD_REQUEST', message: '올바르지 않은 국가입니다.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const srcTarget = buildActorTarget(me, srcNation);
|
||||||
|
const destTarget = buildNationTarget(destNation);
|
||||||
const aux = {
|
const aux = {
|
||||||
src: {
|
src: {
|
||||||
nationName: srcNation.name,
|
nationName: srcNation.name,
|
||||||
nationColor: srcNation.color,
|
nationColor: srcNation.color,
|
||||||
generalId: me.id,
|
generalId: me.id,
|
||||||
generalName: me.name,
|
generalName: me.name,
|
||||||
generalIcon: null,
|
generalIcon: srcTarget.icon,
|
||||||
},
|
},
|
||||||
dest: {
|
dest: {
|
||||||
nationName: destNation.name,
|
nationName: destNation.name,
|
||||||
@@ -240,6 +322,19 @@ export const diplomacyRouter = router({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const letterIdText = String(created.id);
|
||||||
|
const josaYi = JosaUtil.pick(letterIdText, '이');
|
||||||
|
const text = prevId
|
||||||
|
? `문서 #${prevId}의 새로운 외교 문서 #${letterIdText}${josaYi} 준비되었습니다. 외교부에서 확인해주세요.`
|
||||||
|
: `새로운 외교 문서 #${letterIdText}${josaYi} 준비되었습니다. 외교부에서 확인해주세요.`;
|
||||||
|
await sendDocumentNotice({
|
||||||
|
ctx,
|
||||||
|
src: srcTarget,
|
||||||
|
dest: destTarget,
|
||||||
|
text,
|
||||||
|
time: letterDate,
|
||||||
|
});
|
||||||
|
|
||||||
return { id: created.id };
|
return { id: created.id };
|
||||||
}),
|
}),
|
||||||
respondLetter: accessAuthedInputProcedure(
|
respondLetter: accessAuthedInputProcedure(
|
||||||
@@ -269,12 +364,21 @@ export const diplomacyRouter = router({
|
|||||||
throw new TRPCError({ code: 'NOT_FOUND', message: '서신이 없습니다.' });
|
throw new TRPCError({ code: 'NOT_FOUND', message: '서신이 없습니다.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { srcNation, destNation } = await loadLetterNations(
|
||||||
|
ctx,
|
||||||
|
letter.srcNationId,
|
||||||
|
letter.destNationId
|
||||||
|
);
|
||||||
|
const messageSrc = buildActorTarget(me, destNation);
|
||||||
|
const messageDest = buildNationTarget(srcNation);
|
||||||
|
const messageTime = (await loadCurrentGameTime(ctx.db)).now;
|
||||||
const aux = asRecord(letter.aux);
|
const aux = asRecord(letter.aux);
|
||||||
|
let messageText: string;
|
||||||
if (input.agree) {
|
if (input.agree) {
|
||||||
const dest = asRecord(aux.dest);
|
const dest = asRecord(aux.dest);
|
||||||
dest.generalId = me.id;
|
dest.generalId = me.id;
|
||||||
dest.generalName = me.name;
|
dest.generalName = me.name;
|
||||||
dest.generalIcon = null;
|
dest.generalIcon = messageSrc.icon;
|
||||||
aux.dest = dest;
|
aux.dest = dest;
|
||||||
|
|
||||||
await ctx.db.diplomacyLetter.update({
|
await ctx.db.diplomacyLetter.update({
|
||||||
@@ -289,7 +393,7 @@ export const diplomacyRouter = router({
|
|||||||
let prevId = letter.prevId;
|
let prevId = letter.prevId;
|
||||||
while (prevId) {
|
while (prevId) {
|
||||||
const prevLetter = await ctx.db.diplomacyLetter.findFirst({ where: { id: prevId } });
|
const prevLetter = await ctx.db.diplomacyLetter.findFirst({ where: { id: prevId } });
|
||||||
if (!prevLetter || prevLetter.state === 'CANCELLED') {
|
if (!prevLetter) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
await ctx.db.diplomacyLetter.update({
|
await ctx.db.diplomacyLetter.update({
|
||||||
@@ -298,6 +402,7 @@ export const diplomacyRouter = router({
|
|||||||
});
|
});
|
||||||
prevId = prevLetter.prevId;
|
prevId = prevLetter.prevId;
|
||||||
}
|
}
|
||||||
|
messageText = `외교 서신( #${letter.id})이 승인되었습니다.`;
|
||||||
} else {
|
} else {
|
||||||
aux.reason = {
|
aux.reason = {
|
||||||
who: me.id,
|
who: me.id,
|
||||||
@@ -308,7 +413,20 @@ export const diplomacyRouter = router({
|
|||||||
where: { id: letter.id },
|
where: { id: letter.id },
|
||||||
data: { state: 'CANCELLED', aux: aux as GamePrisma.InputJsonValue },
|
data: { state: 'CANCELLED', aux: aux as GamePrisma.InputJsonValue },
|
||||||
});
|
});
|
||||||
|
messageText = `외교 서신(#${letter.id})이 거부되었습니다.`;
|
||||||
|
if (input.reason && input.reason !== '0') {
|
||||||
|
messageText += ` 이유 : ${input.reason}`;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await sendDocumentNotice({
|
||||||
|
ctx,
|
||||||
|
src: messageSrc,
|
||||||
|
dest: messageDest,
|
||||||
|
text: messageText,
|
||||||
|
time: messageTime,
|
||||||
|
includeNational: true,
|
||||||
|
});
|
||||||
|
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
}),
|
}),
|
||||||
@@ -333,6 +451,14 @@ export const diplomacyRouter = router({
|
|||||||
throw new TRPCError({ code: 'NOT_FOUND', message: '서신이 없습니다.' });
|
throw new TRPCError({ code: 'NOT_FOUND', message: '서신이 없습니다.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { srcNation, destNation } = await loadLetterNations(
|
||||||
|
ctx,
|
||||||
|
letter.srcNationId,
|
||||||
|
letter.destNationId
|
||||||
|
);
|
||||||
|
const messageSrc = buildActorTarget(me, srcNation);
|
||||||
|
const messageDest = buildNationTarget(destNation);
|
||||||
|
const messageTime = (await loadCurrentGameTime(ctx.db)).now;
|
||||||
const aux = asRecord(letter.aux);
|
const aux = asRecord(letter.aux);
|
||||||
aux.reason = {
|
aux.reason = {
|
||||||
who: me.id,
|
who: me.id,
|
||||||
@@ -345,6 +471,14 @@ export const diplomacyRouter = router({
|
|||||||
data: { state: 'CANCELLED', aux: aux as GamePrisma.InputJsonValue },
|
data: { state: 'CANCELLED', aux: aux as GamePrisma.InputJsonValue },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await sendDocumentNotice({
|
||||||
|
ctx,
|
||||||
|
src: messageSrc,
|
||||||
|
dest: messageDest,
|
||||||
|
text: `외교 서신(#${letter.id})이 회수되었습니다.`,
|
||||||
|
time: messageTime,
|
||||||
|
});
|
||||||
|
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
}),
|
}),
|
||||||
destroyLetter: accessAuthedInputProcedure(z.object({ letterId: z.number().int().positive() }))
|
destroyLetter: accessAuthedInputProcedure(z.object({ letterId: z.number().int().positive() }))
|
||||||
@@ -376,24 +510,43 @@ export const diplomacyRouter = router({
|
|||||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '이미 파기 신청을 했습니다.' });
|
throw new TRPCError({ code: 'BAD_REQUEST', message: '이미 파기 신청을 했습니다.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { srcNation, destNation } = await loadLetterNations(
|
||||||
|
ctx,
|
||||||
|
letter.srcNationId,
|
||||||
|
letter.destNationId
|
||||||
|
);
|
||||||
|
const actorNation = letter.srcNationId === me.nationId ? srcNation : destNation;
|
||||||
|
const otherNation = letter.srcNationId === me.nationId ? destNation : srcNation;
|
||||||
|
const messageSrc = buildActorTarget(me, actorNation);
|
||||||
|
const messageDest = buildNationTarget(otherNation);
|
||||||
|
const messageTime = (await loadCurrentGameTime(ctx.db)).now;
|
||||||
|
let resultState: 'ACTIVATED' | 'CANCELLED';
|
||||||
|
let messageText: string;
|
||||||
|
|
||||||
if (stateOpt && stateOpt !== myStateOpt) {
|
if (stateOpt && stateOpt !== myStateOpt) {
|
||||||
aux.reason = {
|
|
||||||
who: me.id,
|
|
||||||
action: 'destroy',
|
|
||||||
reason: '파기',
|
|
||||||
};
|
|
||||||
await ctx.db.diplomacyLetter.update({
|
await ctx.db.diplomacyLetter.update({
|
||||||
where: { id: letter.id },
|
where: { id: letter.id },
|
||||||
data: { state: 'CANCELLED', aux: aux as GamePrisma.InputJsonValue },
|
data: { state: 'CANCELLED', aux: aux as GamePrisma.InputJsonValue },
|
||||||
});
|
});
|
||||||
return { state: 'CANCELLED' };
|
resultState = 'CANCELLED';
|
||||||
}
|
messageText = `외교 서신(#${letter.id})을 파기했습니다.`;
|
||||||
|
} else {
|
||||||
aux.state_opt = myStateOpt;
|
aux.state_opt = myStateOpt;
|
||||||
await ctx.db.diplomacyLetter.update({
|
await ctx.db.diplomacyLetter.update({
|
||||||
where: { id: letter.id },
|
where: { id: letter.id },
|
||||||
data: { aux: aux as GamePrisma.InputJsonValue },
|
data: { aux: aux as GamePrisma.InputJsonValue },
|
||||||
});
|
});
|
||||||
return { state: 'ACTIVATED' };
|
resultState = 'ACTIVATED';
|
||||||
|
messageText = `외교 서신(#${letter.id})을 파기 요청합니다.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
await sendDocumentNotice({
|
||||||
|
ctx,
|
||||||
|
src: messageSrc,
|
||||||
|
dest: messageDest,
|
||||||
|
text: messageText,
|
||||||
|
time: messageTime,
|
||||||
|
});
|
||||||
|
return { state: resultState };
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { TRPCError } from '@trpc/server';
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { loadActionModuleBundle } from '@sammo-ts/logic';
|
import { loadActionModuleBundle } from '@sammo-ts/logic';
|
||||||
import { asRecord } from '@sammo-ts/common';
|
import { asRecord } from '@sammo-ts/common';
|
||||||
|
import { GamePrisma } from '@sammo-ts/infra';
|
||||||
|
|
||||||
import { accessAuthedInputProcedure, authedProcedure, router } from '../../trpc.js';
|
import { accessAuthedInputProcedure, authedProcedure, router } from '../../trpc.js';
|
||||||
import { buildBattleSimEnvironment } from '../../battleSim/environment.js';
|
import { buildBattleSimEnvironment } from '../../battleSim/environment.js';
|
||||||
@@ -23,6 +24,7 @@ import {
|
|||||||
MAX_GENERAL_TURNS,
|
MAX_GENERAL_TURNS,
|
||||||
MAX_NATION_TURNS,
|
MAX_NATION_TURNS,
|
||||||
ReservedTurnRevisionConflictError,
|
ReservedTurnRevisionConflictError,
|
||||||
|
type ReservedTurnUpdate,
|
||||||
expandGeneralTurnIndices,
|
expandGeneralTurnIndices,
|
||||||
getGeneralTurnSnapshot,
|
getGeneralTurnSnapshot,
|
||||||
getNationTurnSnapshot,
|
getNationTurnSnapshot,
|
||||||
@@ -143,6 +145,68 @@ const readGeneralMetaNumber = (meta: unknown, key: string): number | null => {
|
|||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const assertNationTurnInputAllowed = (general: GeneralRow): void => {
|
||||||
|
if (!Object.prototype.hasOwnProperty.call(asRecord(general.penalty), 'noChiefTurnInput')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'PRECONDITION_FAILED',
|
||||||
|
message: '수뇌 턴 입력 불가능',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const refillNationTurnInputKillturn = async (
|
||||||
|
ctx: GameApiContext,
|
||||||
|
general: GeneralRow,
|
||||||
|
worldState: WorldStateRow
|
||||||
|
): Promise<boolean> => {
|
||||||
|
if (general.npcState >= 2) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const worldKillturn = readGeneralMetaNumber(worldState.meta, 'killturn');
|
||||||
|
if (worldKillturn === null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const currentKillturn = readGeneralMetaNumber(general.meta, 'killturn');
|
||||||
|
const nextKillturn = Math.max(currentKillturn ?? 0, worldKillturn);
|
||||||
|
if (currentKillturn !== null && nextKillturn === currentKillturn) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await ctx.db.$queryRaw<Array<{ id: number }>>(
|
||||||
|
GamePrisma.sql`
|
||||||
|
UPDATE general
|
||||||
|
SET meta = jsonb_set(
|
||||||
|
CASE
|
||||||
|
WHEN jsonb_typeof(meta) = 'object' THEN meta
|
||||||
|
ELSE '{}'::jsonb
|
||||||
|
END,
|
||||||
|
'{killturn}',
|
||||||
|
to_jsonb(
|
||||||
|
GREATEST(
|
||||||
|
CASE
|
||||||
|
WHEN jsonb_typeof(meta->'killturn') = 'number'
|
||||||
|
THEN (meta->>'killturn')::double precision
|
||||||
|
ELSE 0::double precision
|
||||||
|
END,
|
||||||
|
${nextKillturn}::double precision
|
||||||
|
)
|
||||||
|
),
|
||||||
|
true
|
||||||
|
)
|
||||||
|
WHERE id = ${general.id}
|
||||||
|
AND npc_state < 2
|
||||||
|
AND (
|
||||||
|
jsonb_typeof(meta->'killturn') IS DISTINCT FROM 'number'
|
||||||
|
OR (meta->>'killturn')::double precision < ${nextKillturn}
|
||||||
|
)
|
||||||
|
RETURNING id
|
||||||
|
`
|
||||||
|
);
|
||||||
|
return updated.length > 0;
|
||||||
|
};
|
||||||
|
|
||||||
const assertReservedTurnPermission = async (
|
const assertReservedTurnPermission = async (
|
||||||
worldState: WorldStateRow,
|
worldState: WorldStateRow,
|
||||||
general: GeneralRow,
|
general: GeneralRow,
|
||||||
@@ -535,6 +599,7 @@ export const turnsRouter = router({
|
|||||||
const args = await parseCommandArgs('nation', input.action, input.args);
|
const args = await parseCommandArgs('nation', input.action, input.args);
|
||||||
const worldState = await getReservationWorldState(ctx);
|
const worldState = await getReservationWorldState(ctx);
|
||||||
await assertScenarioCommandAvailable('nation', input.action, worldState);
|
await assertScenarioCommandAvailable('nation', input.action, worldState);
|
||||||
|
assertNationTurnInputAllowed(general);
|
||||||
await assertReservedTurnPermission(worldState, general, 'nation', input.action, args);
|
await assertReservedTurnPermission(worldState, general, 'nation', input.action, args);
|
||||||
|
|
||||||
const snapshot = await mutateReservedTurns(() =>
|
const snapshot = await mutateReservedTurns(() =>
|
||||||
@@ -548,6 +613,10 @@ export const turnsRouter = router({
|
|||||||
input.expectedRevision
|
input.expectedRevision
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
if (await refillNationTurnInputKillturn(ctx, general, worldState)) {
|
||||||
|
ctx.changeJournal?.mark('general.content', general.id);
|
||||||
|
ctx.changeJournal?.mark('dashboard.global');
|
||||||
|
}
|
||||||
return { ok: true, ...snapshot };
|
return { ok: true, ...snapshot };
|
||||||
}),
|
}),
|
||||||
shiftNation: authedProcedure
|
shiftNation: authedProcedure
|
||||||
@@ -639,17 +708,18 @@ export const turnsRouter = router({
|
|||||||
message: 'General is not an officer.',
|
message: 'General is not an officer.',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const updates = await Promise.all(
|
const worldState = await getReservationWorldState(ctx);
|
||||||
input.entries.map(async (entry) => ({
|
const updates: ReservedTurnUpdate[] = [];
|
||||||
|
for (const entry of input.entries) {
|
||||||
|
const update = {
|
||||||
turnIndices: entry.turnList,
|
turnIndices: entry.turnList,
|
||||||
action: entry.action,
|
action: entry.action,
|
||||||
args: await parseCommandArgs('nation', entry.action, entry.args),
|
args: await parseCommandArgs('nation', entry.action, entry.args),
|
||||||
}))
|
};
|
||||||
);
|
|
||||||
const worldState = await getReservationWorldState(ctx);
|
|
||||||
for (const update of updates) {
|
|
||||||
await assertScenarioCommandAvailable('nation', update.action, worldState);
|
await assertScenarioCommandAvailable('nation', update.action, worldState);
|
||||||
|
assertNationTurnInputAllowed(general);
|
||||||
await assertReservedTurnPermission(worldState, general, 'nation', update.action, update.args);
|
await assertReservedTurnPermission(worldState, general, 'nation', update.action, update.args);
|
||||||
|
updates.push(update);
|
||||||
}
|
}
|
||||||
const snapshot = await mutateReservedTurns(() =>
|
const snapshot = await mutateReservedTurns(() =>
|
||||||
setNationTurnsAtCurrentPositions(
|
setNationTurnsAtCurrentPositions(
|
||||||
@@ -660,6 +730,10 @@ export const turnsRouter = router({
|
|||||||
input.expectedRevision
|
input.expectedRevision
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
if (await refillNationTurnInputKillturn(ctx, general, worldState)) {
|
||||||
|
ctx.changeJournal?.mark('general.content', general.id);
|
||||||
|
ctx.changeJournal?.mark('dashboard.global');
|
||||||
|
}
|
||||||
return { ok: true, ...snapshot };
|
return { ok: true, ...snapshot };
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -0,0 +1,516 @@
|
|||||||
|
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { JosaUtil } from '@sammo-ts/common';
|
||||||
|
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
|
||||||
|
import { createGamePostgresConnector, type GamePrismaClient, type RedisConnector } from '@sammo-ts/infra';
|
||||||
|
import {
|
||||||
|
MESSAGE_MAILBOX_NATIONAL_BASE,
|
||||||
|
type MessagePayload,
|
||||||
|
type MessageTarget,
|
||||||
|
type MessageType,
|
||||||
|
} from '@sammo-ts/logic';
|
||||||
|
|
||||||
|
import { RedisAccessTokenStore } from '../src/auth/accessTokenStore.js';
|
||||||
|
import { InMemoryFlushStore } from '../src/auth/flushStore.js';
|
||||||
|
import { InMemoryBattleSimTransport } from '../src/battleSim/inMemoryTransport.js';
|
||||||
|
import type { GameApiContext } from '../src/context.js';
|
||||||
|
import { InMemoryTurnDaemonTransport } from '../src/daemon/inMemoryTransport.js';
|
||||||
|
import { appRouter } from '../src/router.js';
|
||||||
|
|
||||||
|
const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL;
|
||||||
|
const integration = describe.skipIf(!databaseUrl);
|
||||||
|
|
||||||
|
const fixtureNationId = 841;
|
||||||
|
const foreignNationId = fixtureNationId + 1;
|
||||||
|
const fixtureGeneralId = 8_864_243;
|
||||||
|
const foreignGeneralId = fixtureGeneralId + 1;
|
||||||
|
const fixtureWorldStateId = -8_864_241;
|
||||||
|
const fixtureUserId = 'diplomacy-document-message-src-user';
|
||||||
|
const foreignUserId = 'diplomacy-document-message-dest-user';
|
||||||
|
const requestPrefix = 'integration:diplomacy-document-message';
|
||||||
|
const fixtureMailboxes = [
|
||||||
|
MESSAGE_MAILBOX_NATIONAL_BASE + fixtureNationId,
|
||||||
|
MESSAGE_MAILBOX_NATIONAL_BASE + foreignNationId,
|
||||||
|
] as const;
|
||||||
|
const clockBaseTime = new Date('0208-04-05T06:07:08.000Z');
|
||||||
|
const logicalGameTime = new Date('0208-04-05T06:17:08.000Z');
|
||||||
|
const logicalGameTick = 36_000_000n;
|
||||||
|
|
||||||
|
const buildAuth = (userId: string, sessionSuffix: string): GameSessionTokenPayload => ({
|
||||||
|
version: 1,
|
||||||
|
profile: 'che:diplomacy-document-message',
|
||||||
|
issuedAt: '2026-08-24T00:00:00.000Z',
|
||||||
|
expiresAt: '2027-08-24T00:00:00.000Z',
|
||||||
|
sessionId: `diplomacy-document-message-${sessionSuffix}`,
|
||||||
|
user: {
|
||||||
|
id: userId,
|
||||||
|
username: userId,
|
||||||
|
displayName: userId,
|
||||||
|
roles: ['user'],
|
||||||
|
},
|
||||||
|
sanctions: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const fixtureAuth = buildAuth(fixtureUserId, 'src');
|
||||||
|
const foreignAuth = buildAuth(foreignUserId, 'dest');
|
||||||
|
|
||||||
|
const isFixtureOutboxPayload = (payload: unknown): boolean => {
|
||||||
|
if (!payload || typeof payload !== 'object' || !('changes' in payload)) return false;
|
||||||
|
const changes = (payload as { changes?: unknown }).changes;
|
||||||
|
return (
|
||||||
|
Array.isArray(changes) &&
|
||||||
|
changes.some(
|
||||||
|
(change) =>
|
||||||
|
Array.isArray(change) &&
|
||||||
|
change[0] === 'messages.mailbox' &&
|
||||||
|
fixtureMailboxes.includes(change[1] as (typeof fixtureMailboxes)[number])
|
||||||
|
)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
integration('diplomacy document message persistence', () => {
|
||||||
|
let db: GamePrismaClient;
|
||||||
|
let closeDb: (() => Promise<void>) | undefined;
|
||||||
|
|
||||||
|
const deleteFixtureOutboxes = async (): Promise<void> => {
|
||||||
|
const rows = await db.readModelOutbox.findMany({ select: { id: true, payload: true } });
|
||||||
|
const ids = rows.filter(({ payload }) => isFixtureOutboxPayload(payload)).map(({ id }) => id);
|
||||||
|
if (ids.length > 0) {
|
||||||
|
await db.readModelOutbox.deleteMany({ where: { id: { in: ids } } });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const cleanupRouteState = async (): Promise<void> => {
|
||||||
|
await db.message.deleteMany({ where: { mailbox: { in: [...fixtureMailboxes] } } });
|
||||||
|
await db.diplomacyLetter.deleteMany({
|
||||||
|
where: {
|
||||||
|
OR: [
|
||||||
|
{ srcNationId: { in: [fixtureNationId, foreignNationId] } },
|
||||||
|
{ destNationId: { in: [fixtureNationId, foreignNationId] } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await db.inputEvent.deleteMany({ where: { requestId: { startsWith: requestPrefix } } });
|
||||||
|
await deleteFixtureOutboxes();
|
||||||
|
await db.readModelRevision.deleteMany({
|
||||||
|
where: { domain: 'messages.mailbox', entityId: { in: [...fixtureMailboxes] } },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const cleanupFixture = async (): Promise<void> => {
|
||||||
|
await cleanupRouteState();
|
||||||
|
await db.general.deleteMany({ where: { id: { in: [fixtureGeneralId, foreignGeneralId] } } });
|
||||||
|
await db.nation.deleteMany({ where: { id: { in: [fixtureNationId, foreignNationId] } } });
|
||||||
|
await db.worldState.deleteMany({ where: { id: fixtureWorldStateId } });
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildContext = (
|
||||||
|
requestId: string,
|
||||||
|
auth: GameSessionTokenPayload,
|
||||||
|
database: GameApiContext['db'] = db
|
||||||
|
): GameApiContext => {
|
||||||
|
const redisClient = {
|
||||||
|
get: async () => null,
|
||||||
|
set: async () => null,
|
||||||
|
publish: async () => 0,
|
||||||
|
} as unknown as RedisConnector['client'];
|
||||||
|
return {
|
||||||
|
requestId: `${requestPrefix}:${requestId}`,
|
||||||
|
db: database,
|
||||||
|
redis: redisClient,
|
||||||
|
turnDaemon: new InMemoryTurnDaemonTransport(),
|
||||||
|
battleSim: new InMemoryBattleSimTransport(),
|
||||||
|
profile: {
|
||||||
|
id: 'che',
|
||||||
|
scenario: 'diplomacy-document-message',
|
||||||
|
name: 'che:diplomacy-document-message',
|
||||||
|
},
|
||||||
|
uploadDir: 'uploads',
|
||||||
|
uploadPath: '/uploads',
|
||||||
|
uploadPublicUrl: null,
|
||||||
|
auth,
|
||||||
|
accessTokenStore: new RedisAccessTokenStore(redisClient, 'che:diplomacy-document-message'),
|
||||||
|
flushStore: new InMemoryFlushStore(),
|
||||||
|
gameTokenSecret: 'diplomacy-document-message-secret',
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const createLetter = async (
|
||||||
|
options: {
|
||||||
|
state?: 'PROPOSED' | 'ACTIVATED';
|
||||||
|
srcNationId?: number;
|
||||||
|
destNationId?: number;
|
||||||
|
srcSignerId?: number;
|
||||||
|
destSignerId?: number | null;
|
||||||
|
} = {}
|
||||||
|
) => {
|
||||||
|
const srcNationId = options.srcNationId ?? fixtureNationId;
|
||||||
|
const destNationId = options.destNationId ?? foreignNationId;
|
||||||
|
const srcSignerId = options.srcSignerId ?? fixtureGeneralId;
|
||||||
|
const state = options.state ?? 'PROPOSED';
|
||||||
|
return db.diplomacyLetter.create({
|
||||||
|
data: {
|
||||||
|
srcNationId,
|
||||||
|
destNationId,
|
||||||
|
state,
|
||||||
|
textBrief: '통합 외교문서',
|
||||||
|
textDetail: '통합 외교문서 상세',
|
||||||
|
date: logicalGameTime,
|
||||||
|
srcSignerId,
|
||||||
|
destSignerId:
|
||||||
|
options.destSignerId === undefined
|
||||||
|
? state === 'ACTIVATED'
|
||||||
|
? foreignGeneralId
|
||||||
|
: null
|
||||||
|
: options.destSignerId,
|
||||||
|
aux: {
|
||||||
|
src: {
|
||||||
|
nationName: srcNationId === fixtureNationId ? '원민국' : '상대국',
|
||||||
|
nationColor: srcNationId === fixtureNationId ? '#123456' : '#654321',
|
||||||
|
generalId: srcSignerId,
|
||||||
|
generalName: srcSignerId === fixtureGeneralId ? '원민수뇌' : '상대수뇌',
|
||||||
|
},
|
||||||
|
dest: {
|
||||||
|
nationName: destNationId === fixtureNationId ? '원민국' : '상대국',
|
||||||
|
nationColor: destNationId === fixtureNationId ? '#123456' : '#654321',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const expectInputEvent = async (requestId: string, route: string, actorUserId: string): Promise<void> => {
|
||||||
|
await expect(
|
||||||
|
db.inputEvent.findUniqueOrThrow({
|
||||||
|
where: { requestId: `${requestPrefix}:${requestId}:diplomacy.${route}` },
|
||||||
|
})
|
||||||
|
).resolves.toMatchObject({
|
||||||
|
target: 'API',
|
||||||
|
eventType: `diplomacy.${route}`,
|
||||||
|
actorUserId,
|
||||||
|
status: 'SUCCEEDED',
|
||||||
|
attempts: 1,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const expectNoticeCopies = async (options: {
|
||||||
|
text: string;
|
||||||
|
types: readonly MessageType[];
|
||||||
|
src: Pick<MessageTarget, 'generalId' | 'generalName' | 'nationId' | 'nationName'>;
|
||||||
|
dest: Pick<MessageTarget, 'generalId' | 'generalName' | 'nationId' | 'nationName'>;
|
||||||
|
}): Promise<void> => {
|
||||||
|
const allRows = await db.message.findMany({
|
||||||
|
where: { mailbox: { in: [...fixtureMailboxes] } },
|
||||||
|
orderBy: { id: 'asc' },
|
||||||
|
});
|
||||||
|
const srcMailbox = MESSAGE_MAILBOX_NATIONAL_BASE + options.src.nationId;
|
||||||
|
const destMailbox = MESSAGE_MAILBOX_NATIONAL_BASE + options.dest.nationId;
|
||||||
|
|
||||||
|
for (const type of options.types) {
|
||||||
|
const rows = allRows.filter((row) => {
|
||||||
|
const payload = row.message as unknown as MessagePayload;
|
||||||
|
return row.type === type && payload.text === options.text;
|
||||||
|
});
|
||||||
|
expect(rows, `${type}: ${options.text}`).toHaveLength(2);
|
||||||
|
expect(rows.map(({ mailbox }) => mailbox).sort((left, right) => left - right)).toEqual(
|
||||||
|
[srcMailbox, destMailbox].sort((left, right) => left - right)
|
||||||
|
);
|
||||||
|
|
||||||
|
const receiver = rows.find(({ mailbox }) => mailbox === destMailbox);
|
||||||
|
const sender = rows.find(({ mailbox }) => mailbox === srcMailbox);
|
||||||
|
expect(receiver).toBeDefined();
|
||||||
|
expect(sender).toBeDefined();
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
const payload = row.message as unknown as MessagePayload;
|
||||||
|
expect(row).toMatchObject({
|
||||||
|
type,
|
||||||
|
src: srcMailbox,
|
||||||
|
dest: destMailbox,
|
||||||
|
time: logicalGameTime,
|
||||||
|
timeTick: logicalGameTick,
|
||||||
|
});
|
||||||
|
expect(payload).toMatchObject({
|
||||||
|
src: options.src,
|
||||||
|
dest: options.dest,
|
||||||
|
text: options.text,
|
||||||
|
option: { deletable: false },
|
||||||
|
});
|
||||||
|
expect(payload.option?.deletable).toBe(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
const receiverPayload = receiver?.message as unknown as MessagePayload;
|
||||||
|
const senderPayload = sender?.message as unknown as MessagePayload;
|
||||||
|
expect(receiverPayload.option).toEqual({ deletable: false });
|
||||||
|
expect(senderPayload.option).toMatchObject({
|
||||||
|
deletable: false,
|
||||||
|
receiverMessageID: receiver?.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildRollbackDatabase = (failure: Error): GameApiContext['db'] => {
|
||||||
|
const database = db as unknown as GameApiContext['db'];
|
||||||
|
return new Proxy(database, {
|
||||||
|
get(target, property) {
|
||||||
|
if (property === '$transaction') {
|
||||||
|
return async (callback: (transaction: GameApiContext['db']) => Promise<unknown>) =>
|
||||||
|
db.$transaction(async (transaction) => {
|
||||||
|
await callback(transaction as unknown as GameApiContext['db']);
|
||||||
|
throw failure;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return Reflect.get(target, property, target);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const connector = createGamePostgresConnector({ url: databaseUrl! });
|
||||||
|
await connector.connect();
|
||||||
|
db = connector.prisma;
|
||||||
|
closeDb = () => connector.disconnect();
|
||||||
|
await cleanupFixture();
|
||||||
|
await db.worldState.create({
|
||||||
|
data: {
|
||||||
|
id: fixtureWorldStateId,
|
||||||
|
scenarioCode: 'diplomacy-document-message',
|
||||||
|
currentYear: 208,
|
||||||
|
currentMonth: 4,
|
||||||
|
tickSeconds: 600,
|
||||||
|
clockBaseTime,
|
||||||
|
clockTick: logicalGameTick,
|
||||||
|
clockMode: 'manual',
|
||||||
|
clockWallAnchor: new Date('2026-08-24T00:00:00.000Z'),
|
||||||
|
config: {},
|
||||||
|
meta: {},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await db.nation.createMany({
|
||||||
|
data: [
|
||||||
|
{ id: fixtureNationId, name: '원민국', color: '#123456' },
|
||||||
|
{ id: foreignNationId, name: '상대국', color: '#654321' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
await db.general.createMany({
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
id: fixtureGeneralId,
|
||||||
|
userId: fixtureUserId,
|
||||||
|
name: '원민수뇌',
|
||||||
|
nationId: fixtureNationId,
|
||||||
|
officerLevel: 12,
|
||||||
|
picture: 'src.png',
|
||||||
|
imageServer: 0,
|
||||||
|
turnTime: logicalGameTime,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: foreignGeneralId,
|
||||||
|
userId: foreignUserId,
|
||||||
|
name: '상대수뇌',
|
||||||
|
nationId: foreignNationId,
|
||||||
|
officerLevel: 12,
|
||||||
|
picture: 'dest.png',
|
||||||
|
imageServer: 0,
|
||||||
|
turnTime: logicalGameTime,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(cleanupRouteState);
|
||||||
|
afterEach(cleanupRouteState);
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await cleanupFixture();
|
||||||
|
await closeDb?.();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stores only diplomacy receiver/sender copies for new and chained documents', async () => {
|
||||||
|
const firstRequestId = 'send-first';
|
||||||
|
const first = await appRouter.createCaller(buildContext(firstRequestId, fixtureAuth)).diplomacy.sendLetter({
|
||||||
|
destNationId: foreignNationId,
|
||||||
|
brief: '첫 외교문서',
|
||||||
|
detail: '첫 외교문서 상세',
|
||||||
|
});
|
||||||
|
const firstText = `새로운 외교 문서 #${first.id}${JosaUtil.pick(String(first.id), '이')} 준비되었습니다. 외교부에서 확인해주세요.`;
|
||||||
|
await expectNoticeCopies({
|
||||||
|
text: firstText,
|
||||||
|
types: ['diplomacy'],
|
||||||
|
src: {
|
||||||
|
generalId: fixtureGeneralId,
|
||||||
|
generalName: '원민수뇌',
|
||||||
|
nationId: fixtureNationId,
|
||||||
|
nationName: '원민국',
|
||||||
|
},
|
||||||
|
dest: { generalId: 0, generalName: '', nationId: foreignNationId, nationName: '상대국' },
|
||||||
|
});
|
||||||
|
await expectInputEvent(firstRequestId, 'sendLetter', fixtureUserId);
|
||||||
|
|
||||||
|
const chainedRequestId = 'send-chained';
|
||||||
|
const chained = await appRouter.createCaller(buildContext(chainedRequestId, fixtureAuth)).diplomacy.sendLetter({
|
||||||
|
destNationId: foreignNationId,
|
||||||
|
prevId: first.id,
|
||||||
|
brief: '후속 외교문서',
|
||||||
|
detail: '후속 외교문서 상세',
|
||||||
|
});
|
||||||
|
const chainedText = `문서 #${first.id}의 새로운 외교 문서 #${chained.id}${JosaUtil.pick(String(chained.id), '이')} 준비되었습니다. 외교부에서 확인해주세요.`;
|
||||||
|
await expectNoticeCopies({
|
||||||
|
text: chainedText,
|
||||||
|
types: ['diplomacy'],
|
||||||
|
src: {
|
||||||
|
generalId: fixtureGeneralId,
|
||||||
|
generalName: '원민수뇌',
|
||||||
|
nationId: fixtureNationId,
|
||||||
|
nationName: '원민국',
|
||||||
|
},
|
||||||
|
dest: { generalId: 0, generalName: '', nationId: foreignNationId, nationName: '상대국' },
|
||||||
|
});
|
||||||
|
expect(await db.message.count({ where: { mailbox: { in: [...fixtureMailboxes] } } })).toBe(4);
|
||||||
|
await expectInputEvent(chainedRequestId, 'sendLetter', fixtureUserId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stores diplomacy and national copies for both approval and rejection responses', async () => {
|
||||||
|
const approved = await createLetter();
|
||||||
|
const approveRequestId = 'respond-approve';
|
||||||
|
await expect(
|
||||||
|
appRouter
|
||||||
|
.createCaller(buildContext(approveRequestId, foreignAuth))
|
||||||
|
.diplomacy.respondLetter({ letterId: approved.id, agree: true })
|
||||||
|
).resolves.toEqual({ ok: true });
|
||||||
|
await expectNoticeCopies({
|
||||||
|
text: `외교 서신( #${approved.id})이 승인되었습니다.`,
|
||||||
|
types: ['diplomacy', 'national'],
|
||||||
|
src: {
|
||||||
|
generalId: foreignGeneralId,
|
||||||
|
generalName: '상대수뇌',
|
||||||
|
nationId: foreignNationId,
|
||||||
|
nationName: '상대국',
|
||||||
|
},
|
||||||
|
dest: { generalId: 0, generalName: '', nationId: fixtureNationId, nationName: '원민국' },
|
||||||
|
});
|
||||||
|
await expectInputEvent(approveRequestId, 'respondLetter', foreignUserId);
|
||||||
|
|
||||||
|
const rejected = await createLetter();
|
||||||
|
const rejectRequestId = 'respond-reject';
|
||||||
|
await expect(
|
||||||
|
appRouter.createCaller(buildContext(rejectRequestId, foreignAuth)).diplomacy.respondLetter({
|
||||||
|
letterId: rejected.id,
|
||||||
|
agree: false,
|
||||||
|
reason: '조건 불충족',
|
||||||
|
})
|
||||||
|
).resolves.toEqual({ ok: true });
|
||||||
|
await expectNoticeCopies({
|
||||||
|
text: `외교 서신(#${rejected.id})이 거부되었습니다. 이유 : 조건 불충족`,
|
||||||
|
types: ['diplomacy', 'national'],
|
||||||
|
src: {
|
||||||
|
generalId: foreignGeneralId,
|
||||||
|
generalName: '상대수뇌',
|
||||||
|
nationId: foreignNationId,
|
||||||
|
nationName: '상대국',
|
||||||
|
},
|
||||||
|
dest: { generalId: 0, generalName: '', nationId: fixtureNationId, nationName: '원민국' },
|
||||||
|
});
|
||||||
|
expect(await db.message.count({ where: { mailbox: { in: [...fixtureMailboxes] } } })).toBe(8);
|
||||||
|
await expectInputEvent(rejectRequestId, 'respondLetter', foreignUserId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stores diplomacy copies when the sender rolls a proposed document back', async () => {
|
||||||
|
const letter = await createLetter();
|
||||||
|
const requestId = 'rollback';
|
||||||
|
await expect(
|
||||||
|
appRouter
|
||||||
|
.createCaller(buildContext(requestId, fixtureAuth))
|
||||||
|
.diplomacy.rollbackLetter({ letterId: letter.id })
|
||||||
|
).resolves.toEqual({ ok: true });
|
||||||
|
await expectNoticeCopies({
|
||||||
|
text: `외교 서신(#${letter.id})이 회수되었습니다.`,
|
||||||
|
types: ['diplomacy'],
|
||||||
|
src: {
|
||||||
|
generalId: fixtureGeneralId,
|
||||||
|
generalName: '원민수뇌',
|
||||||
|
nationId: fixtureNationId,
|
||||||
|
nationName: '원민국',
|
||||||
|
},
|
||||||
|
dest: { generalId: 0, generalName: '', nationId: foreignNationId, nationName: '상대국' },
|
||||||
|
});
|
||||||
|
expect(await db.message.count({ where: { mailbox: { in: [...fixtureMailboxes] } } })).toBe(2);
|
||||||
|
await expectInputEvent(requestId, 'rollbackLetter', fixtureUserId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stores actor-directed diplomacy copies for the first and second destroy phases', async () => {
|
||||||
|
const letter = await createLetter({ state: 'ACTIVATED' });
|
||||||
|
const requestRequestId = 'destroy-request';
|
||||||
|
await expect(
|
||||||
|
appRouter
|
||||||
|
.createCaller(buildContext(requestRequestId, fixtureAuth))
|
||||||
|
.diplomacy.destroyLetter({ letterId: letter.id })
|
||||||
|
).resolves.toEqual({ state: 'ACTIVATED' });
|
||||||
|
await expectNoticeCopies({
|
||||||
|
text: `외교 서신(#${letter.id})을 파기 요청합니다.`,
|
||||||
|
types: ['diplomacy'],
|
||||||
|
src: {
|
||||||
|
generalId: fixtureGeneralId,
|
||||||
|
generalName: '원민수뇌',
|
||||||
|
nationId: fixtureNationId,
|
||||||
|
nationName: '원민국',
|
||||||
|
},
|
||||||
|
dest: { generalId: 0, generalName: '', nationId: foreignNationId, nationName: '상대국' },
|
||||||
|
});
|
||||||
|
await expectInputEvent(requestRequestId, 'destroyLetter', fixtureUserId);
|
||||||
|
|
||||||
|
const completeRequestId = 'destroy-complete';
|
||||||
|
await expect(
|
||||||
|
appRouter
|
||||||
|
.createCaller(buildContext(completeRequestId, foreignAuth))
|
||||||
|
.diplomacy.destroyLetter({ letterId: letter.id })
|
||||||
|
).resolves.toEqual({ state: 'CANCELLED' });
|
||||||
|
await expectNoticeCopies({
|
||||||
|
text: `외교 서신(#${letter.id})을 파기했습니다.`,
|
||||||
|
types: ['diplomacy'],
|
||||||
|
src: {
|
||||||
|
generalId: foreignGeneralId,
|
||||||
|
generalName: '상대수뇌',
|
||||||
|
nationId: foreignNationId,
|
||||||
|
nationName: '상대국',
|
||||||
|
},
|
||||||
|
dest: { generalId: 0, generalName: '', nationId: fixtureNationId, nationName: '원민국' },
|
||||||
|
});
|
||||||
|
expect(await db.message.count({ where: { mailbox: { in: [...fixtureMailboxes] } } })).toBe(4);
|
||||||
|
await expectInputEvent(completeRequestId, 'destroyLetter', foreignUserId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rolls letter and message writes back together while retaining the failed API input event', async () => {
|
||||||
|
const failure = new Error('injected diplomacy message transaction rollback');
|
||||||
|
const requestId = 'send-rollback';
|
||||||
|
const rollbackDb = buildRollbackDatabase(failure);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
appRouter.createCaller(buildContext(requestId, fixtureAuth, rollbackDb)).diplomacy.sendLetter({
|
||||||
|
destNationId: foreignNationId,
|
||||||
|
brief: 'rollback 외교문서',
|
||||||
|
detail: 'rollback 외교문서 상세',
|
||||||
|
})
|
||||||
|
).rejects.toThrow(failure.message);
|
||||||
|
|
||||||
|
await expect(db.diplomacyLetter.findFirst({ where: { textBrief: 'rollback 외교문서' } })).resolves.toBeNull();
|
||||||
|
await expect(db.message.count({ where: { mailbox: { in: [...fixtureMailboxes] } } })).resolves.toBe(0);
|
||||||
|
await expect(
|
||||||
|
db.readModelRevision.count({
|
||||||
|
where: { domain: 'messages.mailbox', entityId: { in: [...fixtureMailboxes] } },
|
||||||
|
})
|
||||||
|
).resolves.toBe(0);
|
||||||
|
await expect(
|
||||||
|
db.inputEvent.findUniqueOrThrow({
|
||||||
|
where: { requestId: `${requestPrefix}:${requestId}:diplomacy.sendLetter` },
|
||||||
|
})
|
||||||
|
).resolves.toMatchObject({
|
||||||
|
target: 'API',
|
||||||
|
eventType: 'diplomacy.sendLetter',
|
||||||
|
actorUserId: fixtureUserId,
|
||||||
|
status: 'FAILED',
|
||||||
|
attempts: 1,
|
||||||
|
error: failure.message,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { describe, expect, it, vi } from 'vitest';
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { MAX_SAFE_GAME_TICK } from '@sammo-ts/common';
|
||||||
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
|
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
|
||||||
import type { RedisConnector } from '@sammo-ts/infra';
|
import type { RedisConnector } from '@sammo-ts/infra';
|
||||||
|
|
||||||
@@ -88,7 +89,10 @@ const storedLetter = {
|
|||||||
|
|
||||||
const buildContext = (officerLevel = 12, letter: Record<string, unknown> = storedLetter) => {
|
const buildContext = (officerLevel = 12, letter: Record<string, unknown> = storedLetter) => {
|
||||||
const create = vi.fn(async () => ({ id: 9 }));
|
const create = vi.fn(async () => ({ id: 9 }));
|
||||||
|
let messageId = 100;
|
||||||
|
const queryRaw = vi.fn(async (..._args: unknown[]) => [{ id: messageId++ }]);
|
||||||
const db = {
|
const db = {
|
||||||
|
$queryRaw: queryRaw,
|
||||||
general: {
|
general: {
|
||||||
findFirst: vi.fn(async () => buildGeneral(officerLevel)),
|
findFirst: vi.fn(async () => buildGeneral(officerLevel)),
|
||||||
findMany: vi.fn(async () => [
|
findMany: vi.fn(async () => [
|
||||||
@@ -136,7 +140,7 @@ const buildContext = (officerLevel = 12, letter: Record<string, unknown> = store
|
|||||||
flushStore: new InMemoryFlushStore(),
|
flushStore: new InMemoryFlushStore(),
|
||||||
gameTokenSecret: 'test-secret',
|
gameTokenSecret: 'test-secret',
|
||||||
};
|
};
|
||||||
return { caller: appRouter.createCaller(context), create };
|
return { caller: appRouter.createCaller(context), create, queryRaw };
|
||||||
};
|
};
|
||||||
|
|
||||||
describe('diplomacy HTML API boundary', () => {
|
describe('diplomacy HTML API boundary', () => {
|
||||||
@@ -158,6 +162,14 @@ describe('diplomacy HTML API boundary', () => {
|
|||||||
date: new Date('0185-01-01T00:00:00.000Z'),
|
date: new Date('0185-01-01T00:00:00.000Z'),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
expect(fixture.queryRaw).toHaveBeenCalledTimes(2);
|
||||||
|
expect(fixture.queryRaw.mock.calls[0]?.slice(1)).toEqual(
|
||||||
|
expect.arrayContaining([9002, 'diplomacy', 9001, 9002])
|
||||||
|
);
|
||||||
|
expect(fixture.queryRaw.mock.calls[1]?.slice(1)).toEqual(expect.arrayContaining([9001, 'diplomacy']));
|
||||||
|
expect(fixture.queryRaw.mock.calls[0]?.slice(1)).toContain(BigInt(MAX_SAFE_GAME_TICK));
|
||||||
|
expect(fixture.queryRaw.mock.calls[0]?.find((value) => typeof value === 'string' && value.includes('text')))
|
||||||
|
.toContain('새로운 외교 문서 #9가 준비되었습니다. 외교부에서 확인해주세요.');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('purifies legacy stored rows on every read while preserving secret redaction', async () => {
|
it('purifies legacy stored rows on every read while preserving secret redaction', async () => {
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ const profile: GameProfile = {
|
|||||||
name: 'che:default',
|
name: 'che:default',
|
||||||
};
|
};
|
||||||
|
|
||||||
const buildWorldState = (joinMode = 'full'): WorldStateRow =>
|
const buildWorldState = (joinMode = 'full', killturn?: number): WorldStateRow =>
|
||||||
({
|
({
|
||||||
id: 1,
|
id: 1,
|
||||||
scenarioCode: 'default',
|
scenarioCode: 'default',
|
||||||
@@ -39,6 +39,7 @@ const buildWorldState = (joinMode = 'full'): WorldStateRow =>
|
|||||||
scenarioMeta: {
|
scenarioMeta: {
|
||||||
startYear: 180,
|
startYear: 180,
|
||||||
},
|
},
|
||||||
|
...(killturn === undefined ? {} : { killturn }),
|
||||||
},
|
},
|
||||||
updatedAt: new Date('2026-01-01T00:00:00Z'),
|
updatedAt: new Date('2026-01-01T00:00:00Z'),
|
||||||
}) as unknown as WorldStateRow;
|
}) as unknown as WorldStateRow;
|
||||||
@@ -114,6 +115,7 @@ const buildContext = (options?: {
|
|||||||
nationTurns?: NationTurnRow[];
|
nationTurns?: NationTurnRow[];
|
||||||
generalTurnWrites?: unknown[];
|
generalTurnWrites?: unknown[];
|
||||||
nationTurnWrites?: unknown[];
|
nationTurnWrites?: unknown[];
|
||||||
|
generalUpdates?: unknown[];
|
||||||
auth?: GameSessionTokenPayload | null;
|
auth?: GameSessionTokenPayload | null;
|
||||||
currentAccountIcon?: unknown;
|
currentAccountIcon?: unknown;
|
||||||
accountIconGet?: (userId: string) => Promise<unknown>;
|
accountIconGet?: (userId: string) => Promise<unknown>;
|
||||||
@@ -128,6 +130,10 @@ const buildContext = (options?: {
|
|||||||
let generalTurnRevision: number | undefined;
|
let generalTurnRevision: number | undefined;
|
||||||
let nationTurnRevision: number | undefined;
|
let nationTurnRevision: number | undefined;
|
||||||
const db = {
|
const db = {
|
||||||
|
$queryRaw: async (query: unknown) => {
|
||||||
|
options?.generalUpdates?.push(query);
|
||||||
|
return options?.generalUpdates ? [{ id: options.general?.id ?? 0 }] : [];
|
||||||
|
},
|
||||||
worldState: {
|
worldState: {
|
||||||
findFirst: async () => {
|
findFirst: async () => {
|
||||||
if (options?.worldStateReads) {
|
if (options?.worldStateReads) {
|
||||||
@@ -1106,6 +1112,213 @@ describe('appRouter', () => {
|
|||||||
expect(nationWrites).toHaveLength(2);
|
expect(nationWrites).toHaveLength(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('preserves Ref nation-turn penalty key semantics and validation priority', async () => {
|
||||||
|
const general = buildGeneralRow({
|
||||||
|
id: 22,
|
||||||
|
nationId: 3,
|
||||||
|
officerLevel: 12,
|
||||||
|
penalty: { noChiefTurnInput: 0 },
|
||||||
|
meta: { killturn: 3 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const malformedWrites: unknown[] = [];
|
||||||
|
const malformedUpdates: unknown[] = [];
|
||||||
|
const malformedCaller = appRouter.createCaller(
|
||||||
|
buildContext({
|
||||||
|
state: buildWorldState('full', 12),
|
||||||
|
general,
|
||||||
|
nationTurnWrites: malformedWrites,
|
||||||
|
generalUpdates: malformedUpdates,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await expect(
|
||||||
|
malformedCaller.turns.reserved.setNation({
|
||||||
|
generalId: general.id,
|
||||||
|
turnIndex: 0,
|
||||||
|
action: 'che_포상',
|
||||||
|
args: { isGold: true, amount: '1', destGeneralId: 7 },
|
||||||
|
expectedRevision: 0,
|
||||||
|
})
|
||||||
|
).rejects.toMatchObject({ code: 'BAD_REQUEST' });
|
||||||
|
expect(malformedWrites).toHaveLength(0);
|
||||||
|
expect(malformedUpdates).toHaveLength(0);
|
||||||
|
|
||||||
|
const singleWrites: unknown[] = [];
|
||||||
|
const singleUpdates: unknown[] = [];
|
||||||
|
const singleJournal = new ChangeJournal();
|
||||||
|
const singleCaller = appRouter.createCaller(
|
||||||
|
buildContext({
|
||||||
|
state: buildWorldState('full', 12),
|
||||||
|
general,
|
||||||
|
nationTurnWrites: singleWrites,
|
||||||
|
generalUpdates: singleUpdates,
|
||||||
|
changeJournal: singleJournal,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await expect(
|
||||||
|
singleCaller.turns.reserved.setNation({
|
||||||
|
generalId: general.id,
|
||||||
|
turnIndex: 0,
|
||||||
|
action: '휴식',
|
||||||
|
expectedRevision: 0,
|
||||||
|
})
|
||||||
|
).rejects.toMatchObject({
|
||||||
|
code: 'PRECONDITION_FAILED',
|
||||||
|
message: '수뇌 턴 입력 불가능',
|
||||||
|
});
|
||||||
|
expect(singleWrites).toHaveLength(0);
|
||||||
|
expect(singleUpdates).toHaveLength(0);
|
||||||
|
expect(singleJournal.snapshot()).toEqual([]);
|
||||||
|
|
||||||
|
const bulkWrites: unknown[] = [];
|
||||||
|
const bulkUpdates: unknown[] = [];
|
||||||
|
const bulkCaller = appRouter.createCaller(
|
||||||
|
buildContext({
|
||||||
|
state: buildWorldState('full', 12),
|
||||||
|
general,
|
||||||
|
nationTurnWrites: bulkWrites,
|
||||||
|
generalUpdates: bulkUpdates,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await expect(
|
||||||
|
bulkCaller.turns.reserved.setNationBulk({
|
||||||
|
generalId: general.id,
|
||||||
|
entries: [
|
||||||
|
{ turnList: [0], action: '휴식' },
|
||||||
|
{ turnList: [1], action: 'not-a-command' },
|
||||||
|
],
|
||||||
|
expectedRevision: 0,
|
||||||
|
})
|
||||||
|
).rejects.toMatchObject({
|
||||||
|
code: 'PRECONDITION_FAILED',
|
||||||
|
message: '수뇌 턴 입력 불가능',
|
||||||
|
});
|
||||||
|
expect(bulkWrites).toHaveLength(0);
|
||||||
|
expect(bulkUpdates).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refills user killturn after a successful nation reservation and invalidates its readers', async () => {
|
||||||
|
const general = buildGeneralRow({
|
||||||
|
id: 23,
|
||||||
|
nationId: 3,
|
||||||
|
officerLevel: 12,
|
||||||
|
npcState: 0,
|
||||||
|
meta: { killturn: 3, marker: 'kept' },
|
||||||
|
});
|
||||||
|
const nationWrites: unknown[] = [];
|
||||||
|
const generalUpdates: unknown[] = [];
|
||||||
|
const changeJournal = new ChangeJournal();
|
||||||
|
const caller = appRouter.createCaller(
|
||||||
|
buildContext({
|
||||||
|
state: buildWorldState('full', 12),
|
||||||
|
general,
|
||||||
|
nationTurnWrites: nationWrites,
|
||||||
|
generalUpdates,
|
||||||
|
changeJournal,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
caller.turns.reserved.setNation({
|
||||||
|
generalId: general.id,
|
||||||
|
turnIndex: 0,
|
||||||
|
action: '휴식',
|
||||||
|
expectedRevision: 0,
|
||||||
|
})
|
||||||
|
).resolves.toMatchObject({ ok: true });
|
||||||
|
|
||||||
|
expect(nationWrites).toHaveLength(1);
|
||||||
|
expect(generalUpdates).toHaveLength(1);
|
||||||
|
expect(generalUpdates[0]).toMatchObject({ values: [12, general.id, 12] });
|
||||||
|
expect(changeJournal.snapshot()).toEqual([
|
||||||
|
{ domain: 'dashboard.global', entityId: 0 },
|
||||||
|
{ domain: 'general.content', entityId: general.id },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refills killturn once after all nation bulk entries succeed', async () => {
|
||||||
|
const general = buildGeneralRow({
|
||||||
|
id: 24,
|
||||||
|
nationId: 3,
|
||||||
|
officerLevel: 12,
|
||||||
|
npcState: 1,
|
||||||
|
meta: { killturn: 2 },
|
||||||
|
});
|
||||||
|
const nationWrites: unknown[] = [];
|
||||||
|
const generalUpdates: unknown[] = [];
|
||||||
|
const caller = appRouter.createCaller(
|
||||||
|
buildContext({
|
||||||
|
state: buildWorldState('full', 12),
|
||||||
|
general,
|
||||||
|
nationTurnWrites: nationWrites,
|
||||||
|
generalUpdates,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
caller.turns.reserved.setNationBulk({
|
||||||
|
generalId: general.id,
|
||||||
|
entries: [
|
||||||
|
{ turnList: [0], action: '휴식' },
|
||||||
|
{
|
||||||
|
turnList: [1],
|
||||||
|
action: 'che_포상',
|
||||||
|
args: { isGold: true, amount: 1, destGeneralId: 7 },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
expectedRevision: 0,
|
||||||
|
})
|
||||||
|
).resolves.toMatchObject({ ok: true });
|
||||||
|
|
||||||
|
expect(nationWrites).toHaveLength(1);
|
||||||
|
expect(generalUpdates).toHaveLength(1);
|
||||||
|
expect(generalUpdates[0]).toMatchObject({ values: [12, general.id, 12] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
{
|
||||||
|
label: '자동 장수',
|
||||||
|
npcState: 2,
|
||||||
|
currentKillturn: 3,
|
||||||
|
worldKillturn: 12,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '세계 기본보다 삭턴이 많은 유저 장수',
|
||||||
|
npcState: 0,
|
||||||
|
currentKillturn: 20,
|
||||||
|
worldKillturn: 12,
|
||||||
|
},
|
||||||
|
])('$label nation reservation does not change killturn', async ({ npcState, currentKillturn, worldKillturn }) => {
|
||||||
|
const general = buildGeneralRow({
|
||||||
|
id: 25 + npcState,
|
||||||
|
nationId: 3,
|
||||||
|
officerLevel: 12,
|
||||||
|
npcState,
|
||||||
|
meta: { killturn: currentKillturn },
|
||||||
|
});
|
||||||
|
const generalUpdates: unknown[] = [];
|
||||||
|
const changeJournal = new ChangeJournal();
|
||||||
|
const caller = appRouter.createCaller(
|
||||||
|
buildContext({
|
||||||
|
state: buildWorldState('full', worldKillturn),
|
||||||
|
general,
|
||||||
|
generalUpdates,
|
||||||
|
changeJournal,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
caller.turns.reserved.setNation({
|
||||||
|
generalId: general.id,
|
||||||
|
turnIndex: 0,
|
||||||
|
action: '휴식',
|
||||||
|
expectedRevision: 0,
|
||||||
|
})
|
||||||
|
).resolves.toMatchObject({ ok: true });
|
||||||
|
expect(generalUpdates).toHaveLength(0);
|
||||||
|
expect(changeJournal.snapshot()).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
it('enforces only legacy reservation permissions without applying full execution constraints', async () => {
|
it('enforces only legacy reservation permissions without applying full execution constraints', async () => {
|
||||||
const general = buildGeneralRow({ id: 19 });
|
const general = buildGeneralRow({ id: 19 });
|
||||||
const allowedWrites: unknown[] = [];
|
const allowedWrites: unknown[] = [];
|
||||||
|
|||||||
Reference in New Issue
Block a user