fix: 특수 유저 커맨드의 Ref 호환 경계를 보강한다

수뇌 국가 설정과 NPC 정책을 actor-bound ENGINE mutation으로 옮기고, 추방·등용·점령·멸망·아이템 폐기의 특수 분기를 Ref와 맞춘다.

요청 ID를 사용자·프로필별로 격리하고 토너먼트 손상 projection을 fail-closed하며 실제 DB 및 Ref 차등 회귀를 보강한다.
This commit is contained in:
2026-08-24 12:10:57 +00:00
parent 5389f8ed94
commit 630bc29100
74 changed files with 3893 additions and 1141 deletions
@@ -1,4 +1,4 @@
import { asNumber, asRecord, JosaUtil, LiteHashDRBG, RandUtil } from '@sammo-ts/common';
import { asNumber, asRecord, isRecord, JosaUtil, LiteHashDRBG, RandUtil } from '@sammo-ts/common';
import { GamePrisma } from '@sammo-ts/infra';
import { LogCategory, LogFormat, LogScope, type MessageDraft, type MessagePayload } from '@sammo-ts/logic';
@@ -15,6 +15,7 @@ interface MessageRow {
id: number;
mailbox: number;
type: string;
time: Date;
validUntil: Date;
message: unknown;
}
@@ -25,8 +26,31 @@ export interface ActionableMessageResponseResult {
reason: string;
}
const parsePayload = (value: unknown): MessagePayload =>
(typeof value === 'string' ? JSON.parse(value) : value) as MessagePayload;
const parsePayload = (value: unknown): MessagePayload | null => {
let parsed: unknown;
try {
parsed = typeof value === 'string' ? JSON.parse(value) : value;
} catch {
return null;
}
const payload = asRecord(parsed);
const src = asRecord(payload.src);
const dest = asRecord(payload.dest);
const isTarget = (target: Record<string, unknown>): boolean =>
Number.isSafeInteger(target.generalId) &&
Number.isSafeInteger(target.nationId) &&
typeof target.generalName === 'string' &&
typeof target.nationName === 'string' &&
typeof target.color === 'string' &&
typeof target.icon === 'string';
if (!isTarget(src) || !isTarget(dest) || typeof payload.text !== 'string') {
return null;
}
if (payload.option !== undefined && payload.option !== null && !isRecord(payload.option)) {
return null;
}
return payload as unknown as MessagePayload;
};
const systemTarget: MessageDraft['src'] = {
generalId: 0,
@@ -37,6 +61,16 @@ const systemTarget: MessageDraft['src'] = {
icon: '',
};
const isLegacyTruthy = (value: unknown): boolean => {
if (value === undefined || value === null || value === false || value === 0 || value === '' || value === '0') {
return false;
}
if (Array.isArray(value)) {
return value.length > 0;
}
return true;
};
const queuePrivateNotice = (
world: InMemoryTurnWorld,
destination: MessagePayload['dest'],
@@ -103,7 +137,7 @@ const fetchMessageForUpdate = async (
): Promise<MessageRow | null> => {
const currentTick = BigInt(world.dateToGameTick(now));
const rows = await db.$queryRaw<MessageRow[]>(GamePrisma.sql`
SELECT id, mailbox, type, valid_until AS "validUntil", message
SELECT id, mailbox, type, time, valid_until AS "validUntil", message
FROM message
WHERE id = ${messageId}
AND (
@@ -130,7 +164,7 @@ const respondToScout = async (options: {
if (row.type !== 'private' || row.mailbox !== actorId || payload.dest.generalId !== actorId) {
return { ok: false, action: 'scout', reason: '올바른 수신자가 아닙니다.' };
}
if (asRecord(payload.option).used === true) {
if (row.validUntil.getTime() <= row.time.getTime() || isLegacyTruthy(asRecord(payload.option).used)) {
return { ok: false, action: 'scout', reason: '유효하지 않은 등용장입니다.' };
}
@@ -280,6 +314,7 @@ export const respondToActionableMessage = async (options: {
const row = await fetchMessageForUpdate(options.db, options.world, options.messageId, now);
if (!row) return { ok: false, reason: '존재하지 않는 메시지입니다.' };
const payload = parsePayload(row.message);
if (!payload) return { ok: false, reason: '응답할 수 없는 메시지입니다.' };
const action = asRecord(payload.option).action;
if (action === 'scout') {
return await respondToScout({ ...options, actorId: options.generalId, row, payload, now });