merge: Game API 명령 전수 Ref 호환 검증을 반영한다

This commit is contained in:
2026-08-24 22:16:19 +00:00
51 changed files with 4300 additions and 304 deletions
+270 -57
View File
@@ -1,86 +1,299 @@
import type { GamePrisma } from '@sammo-ts/infra';
import { createHash } from 'node:crypto';
import { GamePrisma, type DatabaseClient as InfraDatabaseClient } from '@sammo-ts/infra';
import type { DatabaseClient } from './context.js';
const API_INPUT_PAYLOAD_VERSION = 1 as const;
const BUSINESS_SAVEPOINT = 'api_input_event_business';
export interface ApiInputPayloadIdentity {
version: typeof API_INPUT_PAYLOAD_VERSION;
digest: string;
}
interface LockedInputEvent {
target: 'API' | 'ENGINE';
eventType: string;
payload: GamePrisma.JsonValue;
actorUserId: string | null;
status: 'PENDING' | 'PROCESSING' | 'SUCCEEDED' | 'FAILED';
result: GamePrisma.JsonValue | null;
attempts: number;
}
type InputEventOutcome<T> =
{ kind: 'executed'; value: T } | { kind: 'replayed'; value: T } | { kind: 'failed'; error: unknown };
type SavepointDatabaseClient = InfraDatabaseClient & {
$executeRawUnsafe(query: string): Promise<number>;
};
const asJson = (value: unknown): GamePrisma.InputJsonValue => value as GamePrisma.InputJsonValue;
const canonicalJson = (value: unknown): string =>
JSON.stringify(value, (_key, entry: unknown) => {
if (typeof entry === 'bigint') {
return entry.toString();
}
if (entry && typeof entry === 'object' && !Array.isArray(entry)) {
return Object.fromEntries(
Object.entries(entry as Record<string, unknown>).sort(([left], [right]) =>
left < right ? -1 : left > right ? 1 : 0
)
);
}
return entry;
}) ?? 'null';
const canonicalJsonValue = (value: unknown): GamePrisma.InputJsonValue =>
JSON.parse(canonicalJson(value)) as GamePrisma.InputJsonValue;
export const createApiInputPayloadIdentity = (payload: unknown): ApiInputPayloadIdentity => ({
version: API_INPUT_PAYLOAD_VERSION,
digest: `sha256:${createHash('sha256').update(canonicalJson(payload)).digest('hex')}`,
});
const isLegacyEmptyPayload = (payload: GamePrisma.JsonValue): boolean =>
payload !== null && !Array.isArray(payload) && typeof payload === 'object' && Object.keys(payload).length === 0;
const sameJson = (left: unknown, right: unknown): boolean => canonicalJson(left) === canonicalJson(right);
export class DuplicateInputEventError extends Error {
constructor(readonly requestId: string) {
super(`Input event ${requestId} was already accepted.`);
super(`Input event ${requestId} conflicts with an existing request.`);
this.name = 'DuplicateInputEventError';
}
}
const insertPendingIfAbsent = async (
db: DatabaseClient,
options: {
requestId: string;
eventType: string;
actorUserId: string | null;
payloadIdentity: ApiInputPayloadIdentity;
}
): Promise<void> => {
await db.$executeRaw(
GamePrisma.sql`
INSERT INTO input_event (
request_id,
target,
event_type,
payload,
actor_user_id,
status,
attempts,
created_at
)
VALUES (
${options.requestId},
'API'::"InputEventTarget",
${options.eventType},
CAST(${JSON.stringify(options.payloadIdentity)} AS jsonb),
${options.actorUserId},
'PENDING'::"InputEventStatus",
0,
CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
)
ON CONFLICT (request_id) DO NOTHING
`
);
};
const lockInputEvent = async (db: DatabaseClient, requestId: string): Promise<LockedInputEvent> => {
const rows = await db.$queryRaw<LockedInputEvent[]>(
GamePrisma.sql`
SELECT
target,
event_type AS "eventType",
payload,
actor_user_id AS "actorUserId",
status,
result,
attempts
FROM input_event
WHERE request_id = ${requestId}
FOR UPDATE
`
);
const row = rows[0];
if (!row) {
throw new Error(`Input event ${requestId} disappeared while being claimed.`);
}
return row;
};
const hasMatchingBaseIdentity = (
row: LockedInputEvent,
options: { eventType: string; actorUserId: string | null }
): boolean => row.target === 'API' && row.eventType === options.eventType && row.actorUserId === options.actorUserId;
const canAdoptLegacyFailedPayload = (
row: LockedInputEvent,
options: { eventType: string; actorUserId: string | null }
): boolean =>
row.status === 'FAILED' &&
row.result === null &&
isLegacyEmptyPayload(row.payload) &&
hasMatchingBaseIdentity(row, options);
const isMatchingIdentity = (
row: LockedInputEvent,
options: {
eventType: string;
actorUserId: string | null;
payloadIdentity: ApiInputPayloadIdentity;
}
): boolean => hasMatchingBaseIdentity(row, options) && sameJson(row.payload, options.payloadIdentity);
const claimInputEvent = async (
db: DatabaseClient,
requestId: string,
payloadIdentity: ApiInputPayloadIdentity
): Promise<void> => {
await db.inputEvent.update({
where: { requestId },
data: {
payload: asJson(payloadIdentity),
status: 'PROCESSING',
result: GamePrisma.DbNull,
error: null,
attempts: { increment: 1 },
lockedBy: null,
leaseUntil: null,
processingAt: new Date(),
completedAt: null,
},
});
};
const markUnexpectedFailure = async (
db: DatabaseClient,
options: {
requestId: string;
eventType: string;
actorUserId: string | null;
payloadIdentity: ApiInputPayloadIdentity;
error: unknown;
}
): Promise<void> => {
if (!db.$transaction) return;
const message = options.error instanceof Error ? options.error.message : 'Unknown API input event error.';
try {
await db.$transaction(async (transaction) => {
await insertPendingIfAbsent(transaction, options);
const row = await lockInputEvent(transaction, options.requestId);
const identityMatches = isMatchingIdentity(row, options) || canAdoptLegacyFailedPayload(row, options);
if (!identityMatches || row.status === 'SUCCEEDED' || row.status === 'PROCESSING') {
// A retry may have committed while the failed caller was unwinding. A
// late failure recorder must never replace its durable success.
return;
}
await transaction.inputEvent.update({
where: { requestId: options.requestId },
data: {
payload: asJson(options.payloadIdentity),
status: 'FAILED',
result: GamePrisma.DbNull,
error: message,
attempts: { increment: 1 },
lockedBy: null,
leaseUntil: null,
processingAt: new Date(),
completedAt: new Date(),
},
});
});
} catch {
// Preserve the transaction failure that the caller actually observed. If
// the database is unavailable, the prior PENDING/FAILED state (or absence
// of a newly rolled-back row) remains safely retryable.
}
};
export const executeInputEvent = async <T>(options: {
db: DatabaseClient;
requestId: string;
eventType: string;
payload: unknown;
actorUserId?: string | null;
execute(db: DatabaseClient): Promise<T>;
}): Promise<T> => {
const { db, requestId, eventType, actorUserId, execute } = options;
const { db, requestId, eventType, payload, execute } = options;
const actorUserId = options.actorUserId ?? null;
const payloadIdentity = createApiInputPayloadIdentity(payload);
if (!db.$transaction) {
return execute(db);
}
const processingAt = new Date();
let businessStarted = false;
let outcome: InputEventOutcome<T>;
try {
await db.inputEvent.create({
data: {
requestId,
target: 'API',
eventType,
payload: asJson({}),
actorUserId: actorUserId ?? null,
status: 'PROCESSING',
processingAt,
attempts: 1,
},
});
} catch (error) {
const isUniqueConflict =
typeof error === 'object' && error !== null && 'code' in error && error.code === 'P2002';
if (!isUniqueConflict) {
throw error;
}
const claimedRetry = await db.inputEvent.updateMany({
where: { requestId, status: 'FAILED' },
data: {
status: 'PROCESSING',
error: null,
processingAt,
completedAt: null,
attempts: { increment: 1 },
},
});
if (claimedRetry.count === 0) {
throw new DuplicateInputEventError(requestId);
}
}
outcome = await db.$transaction(async (transaction) => {
await insertPendingIfAbsent(transaction, { requestId, eventType, actorUserId, payloadIdentity });
const row = await lockInputEvent(transaction, requestId);
const identityMatches = isMatchingIdentity(row, { eventType, actorUserId, payloadIdentity });
try {
return await db.$transaction(async (transaction) => {
const result = await execute(transaction);
await transaction.inputEvent.update({
where: { requestId },
data: {
status: 'SUCCEEDED',
result: asJson({ ok: true }),
completedAt: new Date(),
},
});
return result;
if (row.status === 'SUCCEEDED') {
if (!identityMatches) throw new DuplicateInputEventError(requestId);
return { kind: 'replayed', value: row.result as T };
}
// A visible PROCESSING row was committed by the legacy boundary. It
// may still have an active business request and its {} payload cannot
// prove identity, so automatic reclaim would risk duplicate writes.
if (row.status === 'PROCESSING') {
throw new DuplicateInputEventError(requestId);
}
if (!identityMatches && !canAdoptLegacyFailedPayload(row, { eventType, actorUserId })) {
throw new DuplicateInputEventError(requestId);
}
await claimInputEvent(transaction, requestId, payloadIdentity);
const savepointDb = transaction as SavepointDatabaseClient;
await savepointDb.$executeRawUnsafe(`SAVEPOINT ${BUSINESS_SAVEPOINT}`);
businessStarted = true;
try {
const value = await execute(transaction);
const durableResult = canonicalJsonValue(value);
await transaction.inputEvent.update({
where: { requestId },
data: {
status: 'SUCCEEDED',
result: asJson(durableResult),
error: null,
completedAt: new Date(),
},
});
await savepointDb.$executeRawUnsafe(`RELEASE SAVEPOINT ${BUSINESS_SAVEPOINT}`);
return { kind: 'executed', value };
} catch (error) {
await savepointDb.$executeRawUnsafe(`ROLLBACK TO SAVEPOINT ${BUSINESS_SAVEPOINT}`);
await savepointDb.$executeRawUnsafe(`RELEASE SAVEPOINT ${BUSINESS_SAVEPOINT}`);
const message = error instanceof Error ? error.message : 'Unknown API input event error.';
await transaction.inputEvent.update({
where: { requestId },
data: {
status: 'FAILED',
result: GamePrisma.DbNull,
error: message,
completedAt: new Date(),
},
});
return { kind: 'failed', error };
}
});
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown API input event error.';
await db.inputEvent.update({
where: { requestId },
data: {
status: 'FAILED',
error: message,
completedAt: new Date(),
},
});
if (businessStarted && !(error instanceof DuplicateInputEventError)) {
await markUnexpectedFailure(db, { requestId, eventType, actorUserId, payloadIdentity, error });
}
throw error;
}
if (outcome.kind === 'failed') {
throw outcome.error;
}
return outcome.value;
};
+30 -18
View File
@@ -1,8 +1,9 @@
import { MAX_SAFE_GAME_TICK } from '@sammo-ts/common';
import { enqueuePrivateMessageWebPush, GamePrisma } from '@sammo-ts/infra';
import type { MessagePayload, MessageRecordDraft, MessageType } from '@sammo-ts/logic';
import type { DatabaseClient } from '../context.js';
import { loadCurrentGameTime } from '../services/gameClock.js';
import { loadCurrentGameTime, type CurrentGameTime } from '../services/gameClock.js';
export interface MessageView {
id: number;
@@ -47,6 +48,19 @@ const formatMessageTime = (value: Date): string => {
)} ${pad(value.getHours())}:${pad(value.getMinutes())}:${pad(value.getSeconds())}`;
};
const messageValidityPredicate = (gameTime: CurrentGameTime) => {
if (gameTime.tick === null) {
// A legacy or partially migrated profile has no authoritative logical
// tick. Rows that already carry a tick still need the wall-time
// fallback used by the clock migration.
return GamePrisma.sql`valid_until > ${gameTime.now}`;
}
return GamePrisma.sql`(
(valid_until_tick IS NOT NULL AND valid_until_tick > ${BigInt(gameTime.tick)})
OR (valid_until_tick IS NULL AND valid_until > ${gameTime.now})
)`;
};
const toMessageView = (row: MessageRow): MessageView => {
const payload = parsePayload(row.message);
return {
@@ -63,6 +77,11 @@ const toMessageView = (row: MessageRow): MessageView => {
export const insertMessage = async (db: DatabaseClient, draft: MessageRecordDraft): Promise<number> => {
const gameTime = await loadCurrentGameTime(db);
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 {
const tick = gameTime.dateToTick(date);
return tick === null ? null : BigInt(tick);
@@ -107,10 +126,7 @@ export const fetchMessagesFromMailbox = async (params: {
FROM message
WHERE mailbox = ${params.mailbox}
AND type = ${params.msgType}
AND (
(valid_until_tick IS NOT NULL AND valid_until_tick > ${gameTime.tick === null ? null : BigInt(gameTime.tick)})
OR (valid_until_tick IS NULL AND valid_until > ${gameTime.now})
)
AND ${messageValidityPredicate(gameTime)}
AND id >= ${fromSeq}
ORDER BY id DESC
LIMIT ${params.limit}
@@ -132,10 +148,7 @@ export const fetchOldMessagesFromMailbox = async (params: {
FROM message
WHERE mailbox = ${params.mailbox}
AND type = ${params.msgType}
AND (
(valid_until_tick IS NOT NULL AND valid_until_tick > ${gameTime.tick === null ? null : BigInt(gameTime.tick)})
OR (valid_until_tick IS NULL AND valid_until > ${gameTime.now})
)
AND ${messageValidityPredicate(gameTime)}
AND id < ${params.toSeq}
ORDER BY id DESC
LIMIT ${params.limit}
@@ -150,10 +163,7 @@ export const fetchMessageById = async (db: DatabaseClient, id: number): Promise<
SELECT id, mailbox, type, src, dest, time, valid_until, message
FROM message
WHERE id = ${id}
AND (
(valid_until_tick IS NOT NULL AND valid_until_tick > ${gameTime.tick === null ? null : BigInt(gameTime.tick)})
OR (valid_until_tick IS NULL AND valid_until > ${gameTime.now})
)
AND ${messageValidityPredicate(gameTime)}
LIMIT 1
`;
const row = rows[0];
@@ -173,10 +183,7 @@ export const fetchMessageByIdForUpdate = async (db: DatabaseClient, id: number):
SELECT id, mailbox, type, src, dest, time, valid_until, message
FROM message
WHERE id = ${id}
AND (
(valid_until_tick IS NOT NULL AND valid_until_tick > ${gameTime.tick === null ? null : BigInt(gameTime.tick)})
OR (valid_until_tick IS NULL AND valid_until > ${gameTime.now})
)
AND ${messageValidityPredicate(gameTime)}
LIMIT 1
FOR UPDATE
`;
@@ -199,7 +206,12 @@ export const invalidateMessages = async (db: DatabaseClient, ids: number[]): Pro
where: { id: { in: uniqueIds } },
data: {
validUntil: gameTime.now,
...(gameTime.tick === null ? {} : { validUntilTick: BigInt(gameTime.tick) }),
// A partially migrated profile can still carry a legacy logical
// sentinel even while no authoritative clock exists. Replace it
// with an already-expired logical tick when expiring by wall time;
// NULL would fall back to the wall timestamp after clock recovery
// and could make the handled message visible again.
validUntilTick: gameTime.tick === null ? 0n : BigInt(gameTime.tick),
},
});
};
+2 -2
View File
@@ -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';
@@ -42,5 +42,5 @@ export const buildNationTarget = (nationId: number, nationName: string, color: s
nationId,
nationName,
color,
icon: '',
icon: resolveMessageTargetIcon(null),
});
+168 -15
View File
@@ -1,15 +1,95 @@
import { TRPCError } from '@trpc/server';
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 {
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 { loadCurrentGameTime } from '../../services/gameClock.js';
import { accessAuthedInputProcedure, accessAuthedProcedure, router } from '../../trpc.js';
import { getMyGeneral } from '../shared/general.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 nation = await ctx.db.nation.findUnique({
where: { id: nationId },
@@ -211,13 +291,15 @@ export const diplomacyRouter = router({
throw new TRPCError({ code: 'BAD_REQUEST', message: '올바르지 않은 국가입니다.' });
}
const srcTarget = buildActorTarget(me, srcNation);
const destTarget = buildNationTarget(destNation);
const aux = {
src: {
nationName: srcNation.name,
nationColor: srcNation.color,
generalId: me.id,
generalName: me.name,
generalIcon: null,
generalIcon: srcTarget.icon,
},
dest: {
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 };
}),
respondLetter: accessAuthedInputProcedure(
@@ -269,12 +364,21 @@ export const diplomacyRouter = router({
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);
let messageText: string;
if (input.agree) {
const dest = asRecord(aux.dest);
dest.generalId = me.id;
dest.generalName = me.name;
dest.generalIcon = null;
dest.generalIcon = messageSrc.icon;
aux.dest = dest;
await ctx.db.diplomacyLetter.update({
@@ -289,7 +393,7 @@ export const diplomacyRouter = router({
let prevId = letter.prevId;
while (prevId) {
const prevLetter = await ctx.db.diplomacyLetter.findFirst({ where: { id: prevId } });
if (!prevLetter || prevLetter.state === 'CANCELLED') {
if (!prevLetter) {
break;
}
await ctx.db.diplomacyLetter.update({
@@ -298,6 +402,7 @@ export const diplomacyRouter = router({
});
prevId = prevLetter.prevId;
}
messageText = `외교 서신( #${letter.id})이 승인되었습니다.`;
} else {
aux.reason = {
who: me.id,
@@ -308,8 +413,21 @@ export const diplomacyRouter = router({
where: { id: letter.id },
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 };
}),
rollbackLetter: accessAuthedInputProcedure(z.object({ letterId: z.number().int().positive() }))
@@ -333,6 +451,14 @@ export const diplomacyRouter = router({
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);
aux.reason = {
who: me.id,
@@ -345,6 +471,14 @@ export const diplomacyRouter = router({
data: { state: 'CANCELLED', aux: aux as GamePrisma.InputJsonValue },
});
await sendDocumentNotice({
ctx,
src: messageSrc,
dest: messageDest,
text: `외교 서신(#${letter.id})이 회수되었습니다.`,
time: messageTime,
});
return { ok: true };
}),
destroyLetter: accessAuthedInputProcedure(z.object({ letterId: z.number().int().positive() }))
@@ -376,24 +510,43 @@ export const diplomacyRouter = router({
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) {
aux.reason = {
who: me.id,
action: 'destroy',
reason: '파기',
};
await ctx.db.diplomacyLetter.update({
where: { id: letter.id },
data: { state: 'CANCELLED', aux: aux as GamePrisma.InputJsonValue },
});
return { state: 'CANCELLED' };
resultState = 'CANCELLED';
messageText = `외교 서신(#${letter.id})을 파기했습니다.`;
} else {
aux.state_opt = myStateOpt;
await ctx.db.diplomacyLetter.update({
where: { id: letter.id },
data: { aux: aux as GamePrisma.InputJsonValue },
});
resultState = 'ACTIVATED';
messageText = `외교 서신(#${letter.id})을 파기 요청합니다.`;
}
aux.state_opt = myStateOpt;
await ctx.db.diplomacyLetter.update({
where: { id: letter.id },
data: { aux: aux as GamePrisma.InputJsonValue },
await sendDocumentNotice({
ctx,
src: messageSrc,
dest: messageDest,
text: messageText,
time: messageTime,
});
return { state: 'ACTIVATED' };
return { state: resultState };
}),
});
+121 -12
View File
@@ -2,6 +2,7 @@ import { TRPCError } from '@trpc/server';
import { z } from 'zod';
import { loadActionModuleBundle } from '@sammo-ts/logic';
import { asRecord } from '@sammo-ts/common';
import { GamePrisma } from '@sammo-ts/infra';
import { accessAuthedInputProcedure, authedProcedure, router } from '../../trpc.js';
import { buildBattleSimEnvironment } from '../../battleSim/environment.js';
@@ -13,6 +14,8 @@ import {
} from '../../turns/commandTable.js';
import { loadMapDefinitionByName } from '../../maps/mapDefinition.js';
import {
assertReservedTurnActionAvailable,
assertReservedTurnArgsPassLegacyBasicValidation,
buildEquipmentTradeItemOptions,
parseReservedTurnArgs,
TURN_COMMAND_NATION_COLORS,
@@ -22,6 +25,7 @@ import {
MAX_GENERAL_TURNS,
MAX_NATION_TURNS,
ReservedTurnRevisionConflictError,
type ReservedTurnUpdate,
expandGeneralTurnIndices,
getGeneralTurnSnapshot,
getNationTurnSnapshot,
@@ -83,6 +87,27 @@ const parseCommandArgs = async (
}
};
const preflightCommandArgs = async (
scope: 'general' | 'nation',
action: string,
args: unknown,
worldState: WorldStateRow
): Promise<void> => {
try {
// Ref checks scenario availability and common argument types before
// officer/penalty gates. Core keeps actor ownership first, then leaves
// required command-specific fields for the later parser.
await assertReservedTurnActionAvailable(scope, action, asRecord(worldState.config).const);
assertReservedTurnArgsPassLegacyBasicValidation(args);
} catch (error) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: error instanceof Error ? error.message : 'Invalid turn command arguments.',
cause: error,
});
}
};
const mutateReservedTurns = async <T>(mutation: () => Promise<T>): Promise<T> => {
try {
return await mutation();
@@ -134,6 +159,68 @@ const readGeneralMetaNumber = (meta: unknown, key: string): number | 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 (
worldState: WorldStateRow,
general: GeneralRow,
@@ -420,6 +507,7 @@ export const turnsRouter = router({
.mutation(async ({ ctx, input }) => {
const general = await getOwnedGeneral(ctx, input.generalId);
const worldState = await getReservationWorldState(ctx);
await preflightCommandArgs('general', input.action, input.args, worldState);
const args = await parseCommandArgs('general', input.action, input.args, worldState);
await assertReservedTurnPermission(worldState, general, 'general', input.action, args);
@@ -476,15 +564,20 @@ export const turnsRouter = router({
.mutation(async ({ ctx, input }) => {
const general = await getOwnedGeneral(ctx, input.generalId);
const worldState = await getReservationWorldState(ctx);
const updates = await Promise.all(
input.entries.map(async (entry) => ({
const firstEntry = input.entries[0];
await preflightCommandArgs('general', firstEntry.action, firstEntry.args, worldState);
const updates: ReservedTurnUpdate[] = [];
for (const [index, entry] of input.entries.entries()) {
if (index > 0) {
await preflightCommandArgs('general', entry.action, entry.args, worldState);
}
const update = {
turnIndices: expandGeneralTurnIndices(entry.turnList),
action: entry.action,
args: await parseCommandArgs('general', entry.action, entry.args, worldState),
}))
);
for (const update of updates) {
};
await assertReservedTurnPermission(worldState, general, 'general', update.action, update.args);
updates.push(update);
}
const snapshot = await mutateReservedTurns(() =>
setGeneralTurns(ctx.db, input.generalId, updates, input.expectedRevision)
@@ -509,6 +602,8 @@ export const turnsRouter = router({
)
.mutation(async ({ ctx, input }) => {
const general = await getOwnedGeneral(ctx, input.generalId);
const worldState = await getReservationWorldState(ctx);
await preflightCommandArgs('nation', input.action, input.args, worldState);
if (general.nationId <= 0) {
throw new TRPCError({
code: 'PRECONDITION_FAILED',
@@ -521,7 +616,7 @@ export const turnsRouter = router({
message: 'General is not an officer.',
});
}
const worldState = await getReservationWorldState(ctx);
assertNationTurnInputAllowed(general);
const args = await parseCommandArgs('nation', input.action, input.args, worldState);
await assertReservedTurnPermission(worldState, general, 'nation', input.action, args);
@@ -536,6 +631,10 @@ export const turnsRouter = router({
input.expectedRevision
)
);
if (await refillNationTurnInputKillturn(ctx, general, worldState)) {
ctx.changeJournal?.mark('general.content', general.id);
ctx.changeJournal?.mark('dashboard.global');
}
return { ok: true, ...snapshot };
}),
shiftNation: authedProcedure
@@ -615,6 +714,9 @@ export const turnsRouter = router({
)
.mutation(async ({ ctx, input }) => {
const general = await getOwnedGeneral(ctx, input.generalId);
const worldState = await getReservationWorldState(ctx);
const firstEntry = input.entries[0];
await preflightCommandArgs('nation', firstEntry.action, firstEntry.args, worldState);
if (general.nationId <= 0) {
throw new TRPCError({
code: 'PRECONDITION_FAILED',
@@ -627,16 +729,19 @@ export const turnsRouter = router({
message: 'General is not an officer.',
});
}
const worldState = await getReservationWorldState(ctx);
const updates = await Promise.all(
input.entries.map(async (entry) => ({
const updates: ReservedTurnUpdate[] = [];
for (const [index, entry] of input.entries.entries()) {
if (index > 0) {
await preflightCommandArgs('nation', entry.action, entry.args, worldState);
}
assertNationTurnInputAllowed(general);
const update = {
turnIndices: entry.turnList,
action: entry.action,
args: await parseCommandArgs('nation', entry.action, entry.args, worldState),
}))
);
for (const update of updates) {
};
await assertReservedTurnPermission(worldState, general, 'nation', update.action, update.args);
updates.push(update);
}
const snapshot = await mutateReservedTurns(() =>
setNationTurnsAtCurrentPositions(
@@ -647,6 +752,10 @@ export const turnsRouter = router({
input.expectedRevision
)
);
if (await refillNationTurnInputKillturn(ctx, general, worldState)) {
ctx.changeJournal?.mark('general.content', general.id);
ctx.changeJournal?.mark('dashboard.global');
}
return { ok: true, ...snapshot };
}),
}),
+20 -5
View File
@@ -1,5 +1,6 @@
import { randomUUID } from 'node:crypto';
import { initTRPC, TRPCError } from '@trpc/server';
import { middlewareMarker } from '@trpc/server/unstable-core-do-not-import';
import { ChangeJournal } from '@sammo-ts/common';
import { isGameAccessBlocked } from '@sammo-ts/common/auth/sanctions';
import { writeReadModelChangeJournal } from '@sammo-ts/infra';
@@ -58,19 +59,25 @@ const generalActivityMiddleware = t.middleware(async ({ ctx, type, next }) => {
return result;
});
const inputEventMiddleware = t.middleware(async ({ ctx, type, path, next }) => {
export const scopeApiInputEventRequestId = (baseRequestId: string, path: string, batchIndex: number): string =>
`${baseRequestId}:${path}${batchIndex === 0 ? '' : `:batch:${batchIndex}`}`;
const inputEventMiddleware = t.middleware(async ({ ctx, type, path, batchIndex, getRawInput, next }) => {
if (type !== 'mutation' || !ctx.db.$transaction) {
return next();
}
const requestId = `${ctx.requestId ?? randomUUID()}:${path}`;
const requestId = scopeApiInputEventRequestId(ctx.requestId ?? randomUUID(), path, batchIndex);
const payload = await getRawInput();
const changeJournal = new ChangeJournal();
let journalPersisted = false;
let executedResult: Awaited<ReturnType<typeof next>> | undefined;
try {
const result = await executeInputEvent({
const response = await executeInputEvent({
db: ctx.db,
requestId,
eventType: path,
payload,
actorUserId: ctx.auth?.user.id,
execute: async (transaction) => {
const result = await next({
@@ -85,13 +92,21 @@ const inputEventMiddleware = t.middleware(async ({ ctx, type, path, next }) => {
throw result.error;
}
journalPersisted = Boolean(await writeReadModelChangeJournal(transaction, changeJournal.snapshot()));
return result;
executedResult = result;
return result.data;
},
});
if (journalPersisted) {
ctx.readModelOutbox?.wake();
}
return result;
if (executedResult) {
return executedResult;
}
return {
marker: middlewareMarker,
ok: true,
data: response,
};
} catch (error) {
if (error instanceof DuplicateInputEventError) {
throw new TRPCError({
+184 -1
View File
@@ -1,6 +1,7 @@
import {
isGeneralTurnCommandKey,
isNationTurnCommandKey,
getLegacyStringWidth,
loadGeneralTurnCommandSpecs,
loadNationTurnCommandSpecs,
type GeneralTurnCommandSpec,
@@ -385,6 +386,188 @@ const parseRegisteredTurnArgs = async (
return spec.argsSchema.parse(rawArgs);
};
const LEGACY_REMOVED_TURN_ARG_CHARACTERS = new Set([
'"',
"'",
'ⓝ',
'ⓜ',
'ⓖ',
'ⓞ',
'ⓧ',
'㉥',
'\\',
'/',
'`',
'#',
'-',
'|',
]);
const sanitizeLegacyTurnArgString = (value: string): string => {
// Ref StringUtil::neutralize() treats the string "0" as empty because of
// PHP truthiness, both before and after removeSpecialCharacter().
if (value === '' || value === '0') {
return '';
}
const stripped = Array.from(value)
.filter((character) => !LEGACY_REMOVED_TURN_ARG_CHARACTERS.has(character))
.join('');
if (stripped === '' || stripped === '0') {
return '';
}
return stripped
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replace(/^[\p{Z}\p{C}]+|[\p{Z}\p{C}]+$/gu, '');
};
export const sanitizeReservedTurnArgs = (value: unknown): unknown => {
if (typeof value === 'string') {
return sanitizeLegacyTurnArgString(value);
}
if (Array.isArray(value)) {
return value.map((entry) => sanitizeReservedTurnArgs(entry));
}
if (value !== null && typeof value === 'object') {
return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, sanitizeReservedTurnArgs(entry)]));
}
return value;
};
const LEGACY_INTEGER_TURN_ARG_KEYS = new Set([
'crewType',
'destGeneralId',
'destGeneralID',
'destCityId',
'destCityID',
'destNationId',
'destNationID',
'amount',
'colorType',
'srcArmType',
'destArmType',
]);
const LEGACY_BOOLEAN_TURN_ARG_KEYS = new Set(['isGold', 'buyRice']);
const LEGACY_INTEGER_ARRAY_TURN_ARG_KEYS = new Set([
'destNationIdList',
'destNationIDList',
'destGeneralIdList',
'destGeneralIDList',
'amountList',
]);
const LEGACY_NUMERIC_PATTERN = /^[\t\n\r\f\v ]*[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?[\t\n\r\f\v ]*$/;
const skipsLegacyOptionalValidation = (value: unknown): boolean => value === null || value === '';
const isLegacyNumeric = (value: unknown): boolean => {
if (typeof value === 'number') {
return Number.isFinite(value);
}
if (typeof value !== 'string' || !LEGACY_NUMERIC_PATTERN.test(value)) {
return false;
}
return Number.isFinite(Number(value));
};
const throwLegacyBasicTurnArgError = (): never => {
throw new Error('턴이 입력되지 않았습니다.');
};
/**
* Ref checkCommandArg() only validates common fields that are present. Missing
* command-specific fields are deliberately left for command construction, so
* nation penalties and officer checks retain their legacy error priority.
*/
export const assertReservedTurnArgsPassLegacyBasicValidation = (rawArgs: unknown): void => {
const sanitizedArgs = sanitizeReservedTurnArgs(rawArgs);
if (sanitizedArgs === null || sanitizedArgs === undefined) {
return;
}
if (typeof sanitizedArgs !== 'object') {
throwLegacyBasicTurnArgError();
}
const args = sanitizedArgs as Record<string, unknown>;
for (const key of LEGACY_INTEGER_TURN_ARG_KEYS) {
if (
Object.prototype.hasOwnProperty.call(args, key) &&
!skipsLegacyOptionalValidation(args[key]) &&
!Number.isInteger(args[key])
) {
throwLegacyBasicTurnArgError();
}
}
for (const key of LEGACY_BOOLEAN_TURN_ARG_KEYS) {
if (
Object.prototype.hasOwnProperty.call(args, key) &&
!skipsLegacyOptionalValidation(args[key]) &&
typeof args[key] !== 'boolean'
) {
throwLegacyBasicTurnArgError();
}
}
for (const key of LEGACY_INTEGER_ARRAY_TURN_ARG_KEYS) {
if (!Object.prototype.hasOwnProperty.call(args, key)) {
continue;
}
const value = args[key];
if (skipsLegacyOptionalValidation(value)) {
continue;
}
if (!Array.isArray(value) || value.some((entry) => !Number.isInteger(entry))) {
throwLegacyBasicTurnArgError();
}
}
const month = args.month;
if (
Object.prototype.hasOwnProperty.call(args, 'month') &&
!skipsLegacyOptionalValidation(month) &&
(!isLegacyNumeric(month) || Number(month) < 1 || Number(month) > 12)
) {
throwLegacyBasicTurnArgError();
}
const year = args.year;
if (
Object.prototype.hasOwnProperty.call(args, 'year') &&
!skipsLegacyOptionalValidation(year) &&
(!isLegacyNumeric(year) || Number(year) < 0)
) {
throwLegacyBasicTurnArgError();
}
for (const [key, minimum] of [
['destGeneralId', 1],
['destGeneralID', 1],
['destCityId', 1],
['destCityID', 1],
['destNationId', 1],
['destNationID', 1],
['amount', 1],
['crewType', 0],
] as const) {
if (
Object.prototype.hasOwnProperty.call(args, key) &&
!skipsLegacyOptionalValidation(args[key]) &&
Number(args[key]) < minimum
) {
throwLegacyBasicTurnArgError();
}
}
if (Object.prototype.hasOwnProperty.call(args, 'nationName')) {
const nationName = args.nationName;
if (
!skipsLegacyOptionalValidation(nationName) &&
(typeof nationName !== 'string' ||
getLegacyStringWidth(nationName) < 1 ||
getLegacyStringWidth(nationName) > 18)
) {
throwLegacyBasicTurnArgError();
}
}
};
export const assertReservedTurnActionAvailable = async (
scope: 'general' | 'nation',
action: string,
@@ -403,5 +586,5 @@ export const parseReservedTurnArgs = async (
scenarioConst?: unknown
): Promise<Record<string, unknown>> => {
await assertReservedTurnActionAvailable(scope, action, scenarioConst);
return parseRegisteredTurnArgs(scope, action, rawArgs);
return parseRegisteredTurnArgs(scope, action, sanitizeReservedTurnArgs(rawArgs));
};
+123
View File
@@ -82,10 +82,14 @@ const buildContext = (options: {
requestId?: string;
transaction?: ReturnType<typeof vi.fn>;
clockTick?: number;
daemonResult?: Awaited<ReturnType<TurnDaemonTransport['requestCommand']>>;
}) => {
const auth = options.auth === undefined ? buildAuth() : options.auth;
const general = options.general === undefined ? buildGeneral() : options.general;
const requestCommand = vi.fn(async (command: { type: string }) => {
if (options.daemonResult !== undefined) {
return options.daemonResult;
}
if (command.type === 'auctionOpen') {
return {
type: 'auctionOpen' as const,
@@ -279,6 +283,38 @@ describe('auction router actor and permission boundaries', () => {
});
});
it('opens a sell-rice auction with only the authenticated actor and a stable ENGINE request identity', async () => {
const transaction = vi.fn(async () => {
throw new Error('API transaction must not run');
});
const fixture = buildContext({ requestId: 'http-auction-open-sell', transaction });
const input = {
amount: 1000,
closeTurnCnt: 3,
startBidAmount: 500,
finishBidAmount: 2000,
userId: 'forged-user',
generalId: 999,
};
await expect(appRouter.createCaller(fixture.context).auction.openSellRice(input)).resolves.toMatchObject({
auctionId: 91,
});
expect(transaction).not.toHaveBeenCalled();
expect(fixture.requestCommand).toHaveBeenCalledWith({
type: 'auctionOpen',
requestId: 'http-auction-open-sell:auction.openSellRice:engine:0:auctionOpen',
auctionType: 'SELL_RICE',
userId: 'user-1',
generalId: 7,
amount: 1000,
closeTurnCnt: 3,
startBidAmount: 500,
finishBidAmount: 2000,
});
});
it('rejects auction mutations after unification before sending a daemon command', async () => {
const fixture = buildContext({ isUnited: 0, isunited: 2 });
const caller = appRouter.createCaller(fixture.context);
@@ -426,4 +462,91 @@ describe('auction router actor and permission boundaries', () => {
).rejects.toMatchObject({ code: 'BAD_REQUEST', message: '금이 부족합니다.' });
expect(rejected.requestCommand).not.toHaveBeenCalled();
});
it('bids rice through the authenticated actor and preserves the sell-rice ENGINE request identity', async () => {
const queryRaw = async (query: GamePrisma.Sql) => {
const text = sqlText(query);
if (text.includes('FROM auction') && text.includes('WHERE id =')) {
return [
{
id: 31,
type: 'SELL_RICE',
targetCode: '100',
hostGeneralId: 88,
detail: { title: '금 100 경매', amount: 100, startBidAmount: 500, isReverse: false },
status: 'OPEN',
closeAt: new Date('2026-07-27T00:00:00.000Z'),
closeTick: 200n,
},
];
}
if (text.includes('FROM auction_bid')) {
return [];
}
return [];
};
const fixture = buildContext({
general: buildGeneral({ id: 7, userId: 'user-1', rice: 1_500 }),
queryRaw,
requestId: 'http-auction-bid-sell',
clockTick: 100,
});
const input = { auctionId: 31, amount: 500, userId: 'forged-user', generalId: 999 };
await expect(appRouter.createCaller(fixture.context).auction.bidSellRice(input)).resolves.toEqual({ ok: true });
expect(fixture.requestCommand).toHaveBeenCalledWith({
type: 'auctionBid',
requestId: 'http-auction-bid-sell:auction.bidSellRice:engine:0:auctionBid',
userId: 'user-1',
auctionId: 31,
generalId: 7,
amount: 500,
acceptedGameTick: 100,
tryExtendCloseDate: true,
});
});
it('maps a rejected sell-rice bid without trusting client actor fields', async () => {
const queryRaw = async (query: GamePrisma.Sql) => {
const text = sqlText(query);
if (text.includes('FROM auction') && text.includes('WHERE id =')) {
return [
{
id: 31,
type: 'SELL_RICE',
targetCode: '100',
hostGeneralId: 88,
detail: { amount: 100, startBidAmount: 500, isReverse: false },
status: 'OPEN',
closeAt: new Date('2026-07-27T00:00:00.000Z'),
closeTick: 200n,
},
];
}
if (text.includes('FROM auction_bid')) {
return [];
}
return [];
};
const fixture = buildContext({
general: buildGeneral({ rice: 1_500 }),
queryRaw,
clockTick: 100,
daemonResult: {
type: 'auctionBid',
ok: false,
auctionId: 31,
reason: '입찰이 취소되었습니다.',
},
});
const input = { auctionId: 31, amount: 500, userId: 'forged-user', generalId: 999 };
await expect(appRouter.createCaller(fixture.context).auction.bidSellRice(input)).rejects.toMatchObject({
code: 'CONFLICT',
message: '입찰이 취소되었습니다.',
});
expect(fixture.requestCommand).toHaveBeenCalledWith(
expect.objectContaining({ userId: 'user-1', generalId: 7, auctionId: 31 })
);
});
});
+42
View File
@@ -8,9 +8,11 @@ import { loadScenarioDefinitionById } from '@sammo-ts/game-engine/scenario/scena
import { describe, expect, it } from 'vitest';
import {
assertReservedTurnArgsPassLegacyBasicValidation,
buildEquipmentTradeItemOptions,
buildTurnCommandInputFields,
parseReservedTurnArgs,
sanitizeReservedTurnArgs,
} from '../src/turns/commandInput.js';
const buildShopItem = (key: string, name: string) => ({
@@ -99,6 +101,46 @@ describe('turn command argument input', () => {
await expect(parseReservedTurnArgs('general', 'che_포상', {})).rejects.toThrow('Unknown general turn command');
});
it('keeps Ref common validation separate from command-specific required fields', async () => {
expect(() => assertReservedTurnArgsPassLegacyBasicValidation({})).not.toThrow();
expect(() =>
assertReservedTurnArgsPassLegacyBasicValidation({
isGold: true,
amount: '1',
destGeneralId: 7,
})
).toThrow('턴이 입력되지 않았습니다.');
expect(() => assertReservedTurnArgsPassLegacyBasicValidation({ month: '12', year: 0 })).not.toThrow();
expect(() => assertReservedTurnArgsPassLegacyBasicValidation({ nationName: '0' })).not.toThrow();
expect(() => assertReservedTurnArgsPassLegacyBasicValidation({ year: '0x10' })).toThrow(
'턴이 입력되지 않았습니다.'
);
await expect(parseReservedTurnArgs('nation', 'che_국호변경', { nationName: '0' })).rejects.toBeDefined();
});
it('recursively sanitizes reserved command strings like Ref before command parsing', async () => {
expect(
sanitizeReservedTurnArgs({
nationName: ' <신-국># ',
nested: ['A/B', '0'],
})
).toEqual({
nationName: '&lt;신국&gt;',
nested: ['AB', ''],
});
await expect(
parseReservedTurnArgs('nation', 'che_국호변경', {
nationName: ' <신-국># ',
})
).resolves.toEqual({ nationName: '&lt;신국&gt;' });
await expect(
parseReservedTurnArgs('nation', 'che_피장파장', {
destNationId: 2,
commandType: 'che_-수몰',
})
).resolves.toEqual({ destNationId: 2, commandType: 'che_수몰' });
});
it('rejects internal general commands before parsing their arguments or scenario overrides', async () => {
await expect(parseReservedTurnArgs('general', 'che_NPC능동', {})).rejects.toThrow(
'Unknown general turn command: che_NPC능동'
@@ -0,0 +1,594 @@
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { JosaUtil, MAX_SAFE_GAME_TICK } 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 { fetchMessagesFromMailbox, invalidateMessages } from '../src/messages/store.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'];
let failNextTransaction = true;
return new Proxy(database, {
get(target, property) {
if (property === '$transaction') {
return async (callback: (transaction: GameApiContext['db']) => Promise<unknown>) => {
if (!failNextTransaction) {
return db.$transaction((transaction) =>
callback(transaction as unknown as GameApiContext['db'])
);
}
failNextTransaction = false;
return 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('keeps permanent messages readable without a clock and does not resurrect them after invalidation', async () => {
const created = await appRouter
.createCaller(buildContext('legacy-clock-fallback', fixtureAuth))
.diplomacy.sendLetter({
destNationId: foreignNationId,
brief: '시계 이관 중 외교문서',
detail: '시계 이관 중에도 보여야 합니다.',
});
const receiverMailbox = MESSAGE_MAILBOX_NATIONAL_BASE + foreignNationId;
const receiver = await db.message.findFirstOrThrow({
where: { mailbox: receiverMailbox, type: 'diplomacy' },
orderBy: { id: 'desc' },
});
expect(receiver.validUntilTick).toBe(BigInt(MAX_SAFE_GAME_TICK));
await db.worldState.update({
where: { id: fixtureWorldStateId },
data: { clockBaseTime: null, clockTick: null, clockWallAnchor: null },
});
try {
const messages = await fetchMessagesFromMailbox({
db,
mailbox: receiverMailbox,
msgType: 'diplomacy',
limit: 15,
fromSeq: 0,
});
expect(messages).toContainEqual(
expect.objectContaining({
id: receiver.id,
text: expect.stringContaining(`#${created.id}`),
})
);
await invalidateMessages(db, [receiver.id]);
await expect(
db.message.findUniqueOrThrow({ where: { id: receiver.id }, select: { validUntilTick: true } })
).resolves.toEqual({ validUntilTick: 0n });
await expect(
fetchMessagesFromMailbox({
db,
mailbox: receiverMailbox,
msgType: 'diplomacy',
limit: 15,
fromSeq: 0,
})
).resolves.not.toContainEqual(expect.objectContaining({ id: receiver.id }));
} finally {
await db.worldState.update({
where: { id: fixtureWorldStateId },
data: {
clockBaseTime,
clockTick: logicalGameTick,
clockWallAnchor: new Date('2026-08-24T00:00:00.000Z'),
},
});
}
await expect(
fetchMessagesFromMailbox({
db,
mailbox: receiverMailbox,
msgType: 'diplomacy',
limit: 15,
fromSeq: 0,
})
).resolves.not.toContainEqual(expect.objectContaining({ id: receiver.id }));
});
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,
});
});
});
@@ -12,6 +12,7 @@ import {
type GamePrismaClient,
type RedisConnector,
} from '@sammo-ts/infra';
import { MESSAGE_MAILBOX_NATIONAL_BASE } from '@sammo-ts/logic';
import { RedisAccessTokenStore } from '../src/auth/accessTokenStore.js';
import { createGameApiServer } from '../src/server.js';
@@ -21,8 +22,14 @@ const integration = describe.skipIf(!databaseUrl || !process.env.REDIS_URL);
const profileId = process.env.POSTGRES_SCHEMA ?? 'public';
const profileName = `che:diplomacy-html-${process.pid}`;
const userId = `diplomacy-html-user-${process.pid}`;
const fixtureId = 920_000 + (process.pid % 50_000);
// National and diplomacy mailboxes use the Ref-compatible 9000 + nation id
// address space, so a real nation fixture must stay in 1..998; 999 is public.
const fixtureId = 861;
const foreignNationId = fixtureId + 1;
const fixtureMailboxes = [
MESSAGE_MAILBOX_NATIONAL_BASE + fixtureId,
MESSAGE_MAILBOX_NATIONAL_BASE + foreignNationId,
] as const;
const secret = 'diplomacy-html-http-secret';
const redisPrefix = `sammo:diplomacy-html:${process.pid}`;
const envKeys = [
@@ -66,7 +73,22 @@ const deleteProfileRedisKeys = async (): Promise<void> => {
}
};
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])
)
);
};
const cleanup = async (): Promise<void> => {
await db.message.deleteMany({ where: { mailbox: { in: [...fixtureMailboxes] } } });
await db.diplomacyLetter.deleteMany({
where: {
OR: [
@@ -77,6 +99,15 @@ const cleanup = async (): Promise<void> => {
],
},
});
await db.inputEvent.deleteMany({ where: { actorUserId: userId } });
await db.readModelRevision.deleteMany({
where: { domain: 'messages.mailbox', entityId: { in: [...fixtureMailboxes] } },
});
const outboxes = await db.readModelOutbox.findMany({ select: { id: true, payload: true } });
const outboxIds = outboxes.filter(({ payload }) => isFixtureOutboxPayload(payload)).map(({ id }) => id);
if (outboxIds.length > 0) {
await db.readModelOutbox.deleteMany({ where: { id: { in: outboxIds } } });
}
await db.generalAccessLog.deleteMany({ where: { generalId: fixtureId } });
await db.general.deleteMany({ where: { id: fixtureId } });
await db.nation.deleteMany({ where: { id: { in: [fixtureId, foreignNationId] } } });
+13 -1
View File
@@ -1,5 +1,6 @@
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 { RedisConnector } from '@sammo-ts/infra';
@@ -88,7 +89,10 @@ const storedLetter = {
const buildContext = (officerLevel = 12, letter: Record<string, unknown> = storedLetter) => {
const create = vi.fn(async () => ({ id: 9 }));
let messageId = 100;
const queryRaw = vi.fn(async (..._args: unknown[]) => [{ id: messageId++ }]);
const db = {
$queryRaw: queryRaw,
general: {
findFirst: vi.fn(async () => buildGeneral(officerLevel)),
findMany: vi.fn(async () => [
@@ -136,7 +140,7 @@ const buildContext = (officerLevel = 12, letter: Record<string, unknown> = store
flushStore: new InMemoryFlushStore(),
gameTokenSecret: 'test-secret',
};
return { caller: appRouter.createCaller(context), create };
return { caller: appRouter.createCaller(context), create, queryRaw };
};
describe('diplomacy HTML API boundary', () => {
@@ -158,6 +162,14 @@ describe('diplomacy HTML API boundary', () => {
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 () => {
@@ -3,17 +3,25 @@ import { fileURLToPath } from 'node:url';
import path from 'node:path';
import { describe, expect, it } from 'vitest';
import { appRouter } from '../src/router.js';
const classifications = {
durableJournal: [
'betting.bet',
'diplomacy.destroyLetter',
'diplomacy.respondLetter',
'diplomacy.rollbackLetter',
'diplomacy.sendLetter',
'inherit.checkOwner',
'messages.delete',
'messages.respond',
'messages.send',
'turns.repeatGeneral',
'turns.setGeneral',
'turns.setGeneralBulk',
'turns.shiftGeneral',
'turns.reserved.repeatGeneral',
'turns.reserved.setGeneral',
'turns.reserved.setGeneralBulk',
'turns.reserved.setNation',
'turns.reserved.setNationBulk',
'turns.reserved.shiftGeneral',
'vote.closePoll',
'vote.createPoll',
'vote.submitVote',
@@ -23,16 +31,10 @@ const classifications = {
explicitNoRealtimeConsumer: [
'board.writeArticle',
'board.writeComment',
'diplomacy.destroyLetter',
'diplomacy.respondLetter',
'diplomacy.rollbackLetter',
'diplomacy.sendLetter',
'join.listPossessCandidates',
'messages.readLatest',
'turns.repeatNation',
'turns.setNation',
'turns.setNationBulk',
'turns.shiftNation',
'turns.reserved.repeatNation',
'turns.reserved.shiftNation',
'vote.addComment',
],
engineOwned: [
@@ -109,40 +111,71 @@ const listTypeScriptFiles = (directory: string): string[] =>
return entry.isFile() && entry.name.endsWith('.ts') ? [target] : [];
});
const extractMutationNames = (file: string): string[] => {
const source = readFileSync(file, 'utf8');
const names: string[] = [];
for (const mutation of source.matchAll(/\.mutation\s*\(/gu)) {
const prefix = source.slice(0, mutation.index);
const propertyCandidates = [...prefix.matchAll(/^ {4,8}([A-Za-z][A-Za-z0-9]*):/gmu)];
const exportedCandidates = [...prefix.matchAll(/^export const ([A-Za-z][A-Za-z0-9]*)\s*=/gmu)];
const property = propertyCandidates.at(-1);
const exported = exportedCandidates.at(-1);
const propertyIndex = property?.index ?? -1;
const exportedIndex = exported?.index ?? -1;
const name = propertyIndex > exportedIndex ? property?.[1] : exported?.[1];
if (!name) throw new Error(`Could not resolve mutation name in ${file}`);
names.push(name);
const countDeclaredMutations = (file: string): number =>
[...readFileSync(file, 'utf8').matchAll(/\.mutation\s*\(/gu)].length;
interface RuntimeProcedureDef {
type: string;
middlewares: readonly unknown[];
}
const readRuntimeProcedureDef = (procedure: unknown): RuntimeProcedureDef => {
if (typeof procedure !== 'function') {
throw new Error('Mounted tRPC procedure is not callable.');
}
return names;
const definition: unknown = Reflect.get(procedure, '_def');
if (typeof definition !== 'object' || definition === null) {
throw new Error('Mounted tRPC procedure has no runtime definition.');
}
const type: unknown = Reflect.get(definition, 'type');
const middlewares: unknown = Reflect.get(definition, 'middlewares');
if (typeof type !== 'string' || !Array.isArray(middlewares)) {
throw new Error('Mounted tRPC procedure has an unexpected runtime definition.');
}
return { type, middlewares };
};
const routePrefix = (file: string): string => {
const relative = path.relative(routerRoot, file);
const [top] = relative.split(path.sep);
if (!top) throw new Error(`Could not resolve router prefix for ${file}`);
return top.endsWith('.ts') ? path.basename(top, '.ts') : top;
};
const mountedProcedureDefs = new Map(
Object.entries(appRouter._def.procedures).map(
([name, procedure]) => [name, readRuntimeProcedureDef(procedure)] as const
)
);
const mountedMutationNames = (): string[] =>
[...mountedProcedureDefs]
.filter(([, definition]) => definition.type === 'mutation')
.map(([name]) => name)
.sort();
describe('game-api direct mutation journal inventory', () => {
it('requires every router mutation to retain an explicit ownership and realtime classification', () => {
const actual = listTypeScriptFiles(routerRoot)
.flatMap((file) => extractMutationNames(file).map((name) => `${routePrefix(file)}.${name}`))
.sort();
const actual = mountedMutationNames();
const declaredCount = listTypeScriptFiles(routerRoot).reduce(
(total, file) => total + countDeclaredMutations(file),
0
);
const classified = Object.values(classifications).flat().sort();
// Runtime router shape is authoritative for the public path. The raw declaration
// count independently catches mutations that were added to a router but never mounted.
expect(declaredCount).toBe(actual.length);
expect(new Set(classified).size).toBe(classified.length);
expect(classified).toHaveLength(87);
expect(actual).toEqual(classified);
});
it('keeps every mounted mutation authenticated except the two explicit session bootstrap paths', () => {
// auth.status is the smallest mounted procedure that carries the shared
// requireAuthMiddleware. Composed procedures retain the same middleware identity.
const authMiddleware = mountedProcedureDefs.get('auth.status')?.middlewares[0];
expect(authMiddleware).toBeDefined();
const unauthenticated = [...mountedProcedureDefs]
.filter(([, definition]) => definition.type === 'mutation')
.filter(([, definition]) => !definition.middlewares.includes(authMiddleware))
.map(([name]) => name)
.sort();
expect(unauthenticated).toEqual(['auth.exchangeGatewayToken', 'public.recordAccess']);
});
});
+26 -14
View File
@@ -4,6 +4,7 @@ import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
import { z } from 'zod';
import type { GameApiContext } from '../src/context.js';
import type { DatabaseClient } from '../src/context.js';
import { createApiInputPayloadIdentity } from '../src/inputEventBoundary.js';
import {
accessAuthedProcedure,
@@ -303,13 +304,35 @@ describe('general access tracking', () => {
const transactionClient = {
$queryRaw: vi.fn(async (query: unknown) => {
const sql = (query as { sql?: string }).sql ?? '';
if (sql.includes('FROM input_event')) {
return [
{
target: 'API',
eventType: 'board.writeArticle',
payload: createApiInputPayloadIdentity({ value: 'ok' }),
actorUserId: 'user-7',
status: 'PENDING',
result: null,
attempts: 0,
},
];
}
return sql.includes('read_model_revision')
? [{ domain: 'access.general', entityId: 7, revision: 1n, outboxId: 1n }]
: [{ id: 41 }];
}),
$executeRaw: vi.fn(async () => 1),
$executeRaw: vi.fn(async (query: unknown) => {
if (((query as { sql?: string }).sql ?? '').includes('INSERT INTO input_event')) {
events.push('input-event-create');
}
return 1;
}),
$executeRawUnsafe: vi.fn(async () => 0),
inputEvent: {
update: vi.fn(async () => ({})),
update: vi.fn(async (args: { data: { status: string } }) => {
if (args.data.status === 'FAILED') events.push('input-event-failed');
return {};
}),
},
};
const db = {
@@ -338,17 +361,6 @@ describe('general access tracking', () => {
},
})),
},
inputEvent: {
create: vi.fn(async () => {
events.push('input-event-create');
return {};
}),
update: vi.fn(async () => {
events.push('input-event-failed');
return {};
}),
updateMany: vi.fn(async () => ({ count: 0 })),
},
$transaction: vi.fn(async (callback: (client: typeof transactionClient) => Promise<unknown>) => {
transactionCount += 1;
events.push(transactionCount === 1 ? 'access-transaction' : 'business-transaction');
@@ -385,8 +397,8 @@ describe('general access tracking', () => {
expect(events).toEqual([
'input-parse',
'access-transaction',
'input-event-create',
'business-transaction',
'input-event-create',
'resolver',
'input-event-failed',
]);
@@ -773,6 +773,49 @@ describe('in-game my information ownership', () => {
});
});
it('dispatches vacation for the session-owned general with a stable ENGINE request identity', async () => {
const transaction = vi.fn(async () => {
throw new Error('API transaction must not run');
});
const requestCommand = vi.fn(async () => ({
type: 'vacation' as const,
ok: true as const,
generalId: 17,
}));
const fixture = createContext({
me: buildGeneral({ id: 17, userId: 'user-7' }),
requestCommand,
requestId: 'http-general-vacation',
transaction,
});
await expect(appRouter.createCaller(fixture.context).general.vacation()).resolves.toEqual({ ok: true });
expect(transaction).not.toHaveBeenCalled();
expect(requestCommand).toHaveBeenCalledWith({
type: 'vacation',
requestId: 'http-general-vacation:general.vacation:engine:0:vacation',
userId: 'user-7',
generalId: 17,
});
expect(fixture.db.general.findFirst).toHaveBeenCalledWith({ where: { userId: 'user-7' } });
});
it('maps the authoritative vacation rejection without an API-side mutation', async () => {
const requestCommand = vi.fn(async () => ({
type: 'vacation' as const,
ok: false as const,
generalId: 7,
reason: '자동 턴 사용 중에는 휴가할 수 없습니다.',
}));
const fixture = createContext({ requestCommand });
await expect(appRouter.createCaller(fixture.context).general.vacation()).rejects.toMatchObject({
code: 'BAD_REQUEST',
message: '자동 턴 사용 중에는 휴가할 수 없습니다.',
});
expect(fixture.db.general.update).not.toHaveBeenCalled();
});
it('gets the server-owned pre-start deletion status without accepting a general id', async () => {
const requestCommand = vi.fn(async () => ({
type: 'ensureDieOnPrestartStatus' as const,
+53
View File
@@ -112,6 +112,8 @@ const buildContext = (options: {
configConst?: Record<string, unknown>;
configMap?: Record<string, unknown>;
daemonResult?: TurnDaemonCommandResult;
requestId?: string;
transaction?: ReturnType<typeof vi.fn>;
}) => {
const auth = options.auth === undefined ? buildAuth() : options.auth;
const general = options.general === undefined ? buildGeneral() : options.general;
@@ -171,6 +173,7 @@ const buildContext = (options: {
throw new Error(`Unexpected raw query in inherit router fixture: ${sql}`);
});
const db = {
...(options.transaction ? { $transaction: options.transaction } : {}),
$queryRaw: queryRaw,
worldState: {
findFirst: vi.fn(async () => activeWorldState),
@@ -228,6 +231,7 @@ const buildContext = (options: {
battleSim: {} as GameApiContext['battleSim'],
profile: { id: 'che', scenario: 'default', name: 'che:default' },
auth,
...(options.requestId ? { requestId: options.requestId } : {}),
uploadDir: 'uploads',
uploadPath: '/uploads',
uploadPublicUrl: null,
@@ -640,6 +644,55 @@ describe('inherit router actor and permission boundaries', () => {
expect(fixture.logCreate).not.toHaveBeenCalled();
});
it('buys a random unique only for the authenticated owner with a stable ENGINE request identity', async () => {
const transaction = vi.fn(async () => {
throw new Error('API transaction must not run');
});
const fixture = buildContext({
auth: buildAuth('user-2'),
general: buildGeneral({ id: 17, userId: 'user-2' }),
requestId: 'http-inherit-random-unique',
transaction,
daemonResult: {
type: 'inheritanceAction',
ok: true,
action: 'buyRandomUnique',
generalId: 17,
remainPoint: 9_000,
},
});
await expect(appRouter.createCaller(fixture.context).inherit.buyRandomUnique()).resolves.toEqual({ ok: true });
expect(transaction).not.toHaveBeenCalled();
expect(fixture.requestCommand).toHaveBeenCalledWith({
type: 'inheritanceAction',
requestId: 'http-inherit-random-unique:inherit.buyRandomUnique:engine:0:inheritanceAction',
userId: 'user-2',
input: { action: 'buyRandomUnique' },
});
expect(fixture.pointUpsert).not.toHaveBeenCalled();
expect(fixture.logCreate).not.toHaveBeenCalled();
});
it('maps a random-unique daemon rejection without applying API-side inheritance changes', async () => {
const fixture = buildContext({
daemonResult: {
type: 'inheritanceAction',
ok: false,
action: 'buyRandomUnique',
code: 'BAD_REQUEST',
reason: '충분한 유산 포인트를 가지고 있지 않습니다.',
},
});
await expect(appRouter.createCaller(fixture.context).inherit.buyRandomUnique()).rejects.toMatchObject({
code: 'BAD_REQUEST',
message: '충분한 유산 포인트를 가지고 있지 않습니다.',
});
expect(fixture.pointUpsert).not.toHaveBeenCalled();
expect(fixture.logCreate).not.toHaveBeenCalled();
});
it('reveals a target owner to the caller without using the caller general id from input', async () => {
const fixture = buildContext({
inheritancePoint: 1500,
@@ -2,8 +2,12 @@ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import { z } from 'zod';
import { createGamePostgresConnector, type GamePrismaClient } from '@sammo-ts/infra';
import type { GameApiContext } from '../src/context.js';
import { DuplicateInputEventError, executeInputEvent } from '../src/inputEventBoundary.js';
import type { DatabaseClient, GameApiContext } from '../src/context.js';
import {
createApiInputPayloadIdentity,
DuplicateInputEventError,
executeInputEvent,
} from '../src/inputEventBoundary.js';
import { procedure, router } from '../src/trpc.js';
import {
ConflictingTurnDaemonCommandError,
@@ -74,6 +78,7 @@ integration('API input event boundary', () => {
db,
requestId,
eventType: 'test.success',
payload: { markerId },
actorUserId: 'user-7',
execute: async (transaction) => {
await transaction.inputEvent.create({
@@ -94,6 +99,8 @@ integration('API input event boundary', () => {
expect(event).toMatchObject({
status: 'SUCCEEDED',
actorUserId: 'user-7',
payload: createApiInputPayloadIdentity({ markerId }),
result: { ok: true },
attempts: 1,
});
expect(event.processingAt).toBeInstanceOf(Date);
@@ -168,6 +175,12 @@ integration('API input event boundary', () => {
const outboxes = await db.readModelOutbox.findMany({ select: { payload: true } });
expect(outboxes.some(({ payload }) => payloadHasGeneral(payload, journalGeneralIds[1]))).toBe(false);
expect(wake).not.toHaveBeenCalled();
expect(await db.inputEvent.findUniqueOrThrow({ where: { requestId: `${requestId}:mutate` } })).toMatchObject({
payload: createApiInputPayloadIdentity({ generalId: journalGeneralIds[1], fail: true }),
status: 'FAILED',
result: null,
attempts: 1,
});
});
it('rolls back business writes, records failure, and permits one explicit retry', async () => {
@@ -178,6 +191,7 @@ integration('API input event boundary', () => {
db,
requestId,
eventType: 'test.failure',
payload: { markerId },
execute: async (transaction) => {
await transaction.inputEvent.create({
data: {
@@ -194,6 +208,8 @@ integration('API input event boundary', () => {
expect(await db.inputEvent.findUnique({ where: { requestId: markerId } })).toBeNull();
expect(await db.inputEvent.findUniqueOrThrow({ where: { requestId } })).toMatchObject({
status: 'FAILED',
payload: createApiInputPayloadIdentity({ markerId }),
result: null,
attempts: 1,
error: 'injected transaction failure',
});
@@ -202,15 +218,17 @@ integration('API input event boundary', () => {
db,
requestId,
eventType: 'test.failure',
payload: { markerId },
execute: async () => ({ ok: true }),
});
expect(await db.inputEvent.findUniqueOrThrow({ where: { requestId } })).toMatchObject({
status: 'SUCCEEDED',
result: { ok: true },
attempts: 2,
});
});
it('rejects a concurrent duplicate idempotency key', async () => {
it('serializes an exact concurrent retry and replays the original result without re-executing business', async () => {
const requestId = 'integration:api:duplicate';
let releaseFirst: (() => void) | undefined;
let signalStarted: (() => void) | undefined;
@@ -224,25 +242,270 @@ integration('API input event boundary', () => {
db,
requestId,
eventType: 'test.duplicate',
payload: { value: 7 },
execute: async () => {
signalStarted?.();
await release;
return { ok: true };
return { ok: true, revision: 17 };
},
});
await started;
const duplicateExecute = vi.fn(async () => ({ ok: true, revision: 99 }));
const duplicate = executeInputEvent({
db,
requestId,
eventType: 'test.duplicate',
payload: { value: 7 },
execute: duplicateExecute,
});
releaseFirst?.();
await expect(Promise.all([first, duplicate])).resolves.toEqual([
{ ok: true, revision: 17 },
{ ok: true, revision: 17 },
]);
expect(duplicateExecute).not.toHaveBeenCalled();
expect(await db.inputEvent.findUniqueOrThrow({ where: { requestId } })).toMatchObject({
payload: createApiInputPayloadIdentity({ value: 7 }),
result: { ok: true, revision: 17 },
status: 'SUCCEEDED',
attempts: 1,
});
});
it('rejects request-id reuse with a changed payload, event type, or actor', async () => {
const requestId = 'integration:api:identity-conflict';
const original = await executeInputEvent({
db,
requestId,
eventType: 'test.identity',
payload: { value: 1, nested: { left: true, right: false } },
actorUserId: 'user-identity',
execute: async () => ({ ok: true, revision: 4 }),
});
expect(original).toEqual({ ok: true, revision: 4 });
const conflicts = [
{
eventType: 'test.identity',
payload: { value: 2, nested: { left: true, right: false } },
actorUserId: 'user-identity',
},
{
eventType: 'test.other-identity',
payload: { value: 1, nested: { left: true, right: false } },
actorUserId: 'user-identity',
},
{
eventType: 'test.identity',
payload: { value: 1, nested: { left: true, right: false } },
actorUserId: 'other-user',
},
];
for (const conflict of conflicts) {
const conflictingExecute = vi.fn(async () => ({ ok: false }));
await expect(
executeInputEvent({ db, requestId, ...conflict, execute: conflictingExecute })
).rejects.toBeInstanceOf(DuplicateInputEventError);
expect(conflictingExecute).not.toHaveBeenCalled();
}
expect(await db.inputEvent.findUniqueOrThrow({ where: { requestId } })).toMatchObject({
eventType: 'test.identity',
actorUserId: 'user-identity',
payload: createApiInputPayloadIdentity({ value: 1, nested: { left: true, right: false } }),
result: { ok: true, revision: 4 },
status: 'SUCCEEDED',
attempts: 1,
});
});
it('reclaims an exact PENDING row under lock and counts one execution attempt', async () => {
const requestId = 'integration:api:pending-reclaim';
const payload = { value: 'pending' };
await db.inputEvent.create({
data: {
requestId,
target: 'API',
eventType: 'test.pending',
payload: { ...createApiInputPayloadIdentity(payload) },
actorUserId: 'pending-user',
status: 'PENDING',
attempts: 3,
},
});
await expect(
executeInputEvent({
db,
requestId,
eventType: 'test.duplicate',
eventType: 'test.pending',
payload,
actorUserId: 'pending-user',
execute: async () => ({ ok: true, attempt: 4 }),
})
).resolves.toEqual({ ok: true, attempt: 4 });
expect(await db.inputEvent.findUniqueOrThrow({ where: { requestId } })).toMatchObject({
status: 'SUCCEEDED',
result: { ok: true, attempt: 4 },
attempts: 4,
});
});
it('adopts only a matching legacy FAILED placeholder and replaces it with the canonical digest', async () => {
const requestId = 'integration:api:legacy-failed';
const payload = { value: 'legacy-retry' };
await db.inputEvent.create({
data: {
requestId,
target: 'API',
eventType: 'test.legacy-failed',
payload: {},
actorUserId: 'legacy-user',
status: 'FAILED',
attempts: 2,
error: 'legacy failure',
completedAt: new Date(),
},
});
await expect(
executeInputEvent({
db,
requestId,
eventType: 'test.legacy-failed',
payload,
actorUserId: 'legacy-user',
execute: async () => ({ ok: true }),
})
).rejects.toBeInstanceOf(DuplicateInputEventError);
).resolves.toEqual({ ok: true });
expect(await db.inputEvent.findUniqueOrThrow({ where: { requestId } })).toMatchObject({
payload: createApiInputPayloadIdentity(payload),
status: 'SUCCEEDED',
result: { ok: true },
error: null,
attempts: 3,
});
});
releaseFirst?.();
await first;
it('fails closed on a committed legacy PROCESSING placeholder', async () => {
const requestId = 'integration:api:legacy-processing';
const processingAt = new Date(Date.now() - 60 * 60 * 1_000);
await db.inputEvent.create({
data: {
requestId,
target: 'API',
eventType: 'test.legacy-processing',
payload: {},
actorUserId: 'legacy-user',
status: 'PROCESSING',
attempts: 1,
processingAt,
},
});
const retryExecute = vi.fn(async () => ({ ok: true }));
await expect(
executeInputEvent({
db,
requestId,
eventType: 'test.legacy-processing',
payload: { value: 'cannot-prove-legacy-identity' },
actorUserId: 'legacy-user',
execute: retryExecute,
})
).rejects.toBeInstanceOf(DuplicateInputEventError);
expect(retryExecute).not.toHaveBeenCalled();
expect(await db.inputEvent.findUniqueOrThrow({ where: { requestId } })).toMatchObject({
payload: {},
status: 'PROCESSING',
attempts: 1,
processingAt,
});
});
it('commits FAILED before a blocked exact retry and preserves the exact attempt count after success', async () => {
const requestId = 'integration:api:failure-race';
const payload = { value: 'race' };
let releaseFailure: (() => void) | undefined;
let signalStarted: (() => void) | undefined;
const started = new Promise<void>((resolve) => {
signalStarted = resolve;
});
const release = new Promise<void>((resolve) => {
releaseFailure = resolve;
});
const failed = executeInputEvent({
db,
requestId,
eventType: 'test.failure-race',
payload,
execute: async () => {
signalStarted?.();
await release;
throw new Error('first attempt failed');
},
});
await started;
const retry = executeInputEvent({
db,
requestId,
eventType: 'test.failure-race',
payload,
execute: async () => ({ ok: true, attempt: 2 }),
});
releaseFailure?.();
await expect(failed).rejects.toThrow('first attempt failed');
await expect(retry).resolves.toEqual({ ok: true, attempt: 2 });
expect(await db.inputEvent.findUniqueOrThrow({ where: { requestId } })).toMatchObject({
status: 'SUCCEEDED',
result: { ok: true, attempt: 2 },
error: null,
attempts: 2,
});
});
it('does not let a late unexpected-failure recorder overwrite a transaction that actually committed', async () => {
const requestId = 'integration:api:ambiguous-commit';
const payload = { value: 'committed-before-client-error' };
const ambiguousCommitDb = new Proxy(db, {
get(target, property, receiver) {
if (property !== '$transaction') return Reflect.get(target, property, receiver);
return async (callback: (transaction: DatabaseClient) => Promise<unknown>) => {
await db.$transaction(async (transaction) => callback(transaction));
throw new Error('injected post-commit transport failure');
};
},
}) as unknown as DatabaseClient;
await expect(
executeInputEvent({
db: ambiguousCommitDb,
requestId,
eventType: 'test.ambiguous-commit',
payload,
execute: async () => ({ ok: true, revision: 8 }),
})
).rejects.toThrow('injected post-commit transport failure');
expect(await db.inputEvent.findUniqueOrThrow({ where: { requestId } })).toMatchObject({
status: 'SUCCEEDED',
result: { ok: true, revision: 8 },
error: null,
attempts: 1,
});
const replayExecute = vi.fn(async () => ({ ok: false }));
await expect(
executeInputEvent({
db,
requestId,
eventType: 'test.ambiguous-commit',
payload,
execute: replayExecute,
})
).resolves.toEqual({ ok: true, revision: 8 });
expect(replayExecute).not.toHaveBeenCalled();
});
it('reuses the same engine child event but rejects a changed retry payload', async () => {
@@ -0,0 +1,37 @@
import { describe, expect, it } from 'vitest';
import { createApiInputPayloadIdentity } from '../src/inputEventBoundary.js';
describe('API input-event payload identity', () => {
it('hashes canonical JSON independently of object key order', () => {
expect(
createApiInputPayloadIdentity({
second: [{ z: true, a: 1 }],
first: 'value',
})
).toEqual(
createApiInputPayloadIdentity({
first: 'value',
second: [{ a: 1, z: true }],
})
);
});
it('distinguishes changed values and array order', () => {
const original = createApiInputPayloadIdentity({ value: 1, items: ['a', 'b'] });
expect(createApiInputPayloadIdentity({ value: 2, items: ['a', 'b'] })).not.toEqual(original);
expect(createApiInputPayloadIdentity({ value: 1, items: ['b', 'a'] })).not.toEqual(original);
});
it('stores only a bounded digest envelope for a large or private payload', () => {
const privatePayload = { dataUrl: `data:image/png;base64,${'A'.repeat(100_000)}`, text: 'private-message' };
const identity = createApiInputPayloadIdentity(privatePayload);
expect(identity).toEqual({
version: 1,
digest: expect.stringMatching(/^sha256:[0-9a-f]{64}$/u),
});
expect(JSON.stringify(identity)).not.toContain('private-message');
expect(JSON.stringify(identity).length).toBeLessThan(128);
});
});
+56 -27
View File
@@ -2,46 +2,60 @@ import { describe, expect, it, vi } from 'vitest';
import { z } from 'zod';
import type { GameApiContext } from '../src/context.js';
import { createApiInputPayloadIdentity } from '../src/inputEventBoundary.js';
import { procedure, router } from '../src/trpc.js';
const testRouter = router({
mutate: procedure
.input(z.object({ fail: z.boolean().optional().default(false) }))
.mutation(({ ctx, input }) => {
(ctx as GameApiContext & { testOrder: string[] }).testOrder.push('handler');
ctx.changeJournal?.mark('front.general', 7);
if (input.fail) throw new Error('injected rollback');
return { ok: true };
}),
mutate: procedure.input(z.object({ fail: z.boolean().optional().default(false) })).mutation(({ ctx, input }) => {
(ctx as GameApiContext & { testOrder: string[] }).testOrder.push('handler');
ctx.changeJournal?.mark('front.general', 7);
if (input.fail) throw new Error('injected rollback');
return { ok: true };
}),
});
const createContext = () => {
const createContext = (payload: unknown = {}) => {
const order: string[] = [];
const queryRaw = vi.fn(async () => {
const queryRaw = vi.fn(async (query: { sql?: string }) => {
if (query.sql?.includes('FROM input_event')) {
order.push('locked');
return [
{
target: 'API',
eventType: 'mutate',
payload: createApiInputPayloadIdentity(payload),
actorUserId: null,
status: 'PENDING',
result: null,
attempts: 0,
},
];
}
order.push('journal');
return [{ domain: 'front.general', entityId: 7, revision: 1n, outboxId: 11n }];
});
const transaction = {
$queryRaw: queryRaw,
$executeRaw: vi.fn(async () => {
order.push('accepted');
return 1;
}),
$executeRawUnsafe: vi.fn(async (statement: string) => {
if (statement.startsWith('SAVEPOINT ')) order.push('savepoint');
else if (statement.startsWith('ROLLBACK TO ')) order.push('savepoint-rollback');
else if (statement.startsWith('RELEASE ')) order.push('savepoint-release');
return 0;
}),
inputEvent: {
update: vi.fn(async () => {
order.push('succeeded');
update: vi.fn(async (args: { data: { status: string } }) => {
if (args.data.status === 'PROCESSING') order.push('processing');
else if (args.data.status === 'SUCCEEDED') order.push('succeeded');
else if (args.data.status === 'FAILED') order.push('failed');
return {};
}),
},
};
const db = {
inputEvent: {
create: vi.fn(async () => {
order.push('accepted');
return {};
}),
updateMany: vi.fn(async () => ({ count: 0 })),
update: vi.fn(async () => {
order.push('failed');
return {};
}),
},
$transaction: vi.fn(async (callback: (db: typeof transaction) => Promise<unknown>) => {
order.push('transaction-begin');
try {
@@ -73,11 +87,15 @@ describe('API input-event change journal boundary', () => {
await expect(testRouter.createCaller(fixture.context).mutate({})).resolves.toEqual({ ok: true });
expect(fixture.order).toEqual([
'accepted',
'transaction-begin',
'accepted',
'locked',
'processing',
'savepoint',
'handler',
'journal',
'succeeded',
'savepoint-release',
'commit',
'wake',
]);
@@ -86,14 +104,25 @@ describe('API input-event change journal boundary', () => {
});
it('rolls back a handler mark without writing or scheduling an outbox row', async () => {
const fixture = createContext();
const fixture = createContext({ fail: true });
await expect(testRouter.createCaller(fixture.context).mutate({ fail: true })).rejects.toThrow(
'injected rollback'
);
expect(fixture.order).toEqual(['accepted', 'transaction-begin', 'handler', 'rollback', 'failed']);
expect(fixture.queryRaw).not.toHaveBeenCalled();
expect(fixture.order).toEqual([
'transaction-begin',
'accepted',
'locked',
'processing',
'savepoint',
'handler',
'savepoint-rollback',
'savepoint-release',
'failed',
'commit',
]);
expect(fixture.queryRaw).toHaveBeenCalledTimes(1);
expect(fixture.redisPublish).not.toHaveBeenCalled();
expect(fixture.wake).not.toHaveBeenCalled();
});
+1 -1
View File
@@ -987,7 +987,7 @@ describe('messages router missing-flow compatibility', () => {
);
expect(setup.messageUpdateMany).toHaveBeenCalledWith({
where: { id: { in: [31] } },
data: { validUntil: expect.any(Date) },
data: { validUntil: expect.any(Date), validUntilTick: 0n },
});
expect(setup.queryRaw).toHaveBeenCalledTimes(9);
});
@@ -0,0 +1,147 @@
import { existsSync, readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import path from 'node:path';
import { describe, expect, it } from 'vitest';
import { appRouter } from '../src/router.js';
const workspaceRoot = fileURLToPath(new URL('../../../', import.meta.url));
const manifestPath = path.join(workspaceRoot, 'docs/architecture/game-api-mutation-evidence.tsv');
const columns = [
'route',
'owner_boundary',
'ref_basis',
'actor_source',
'strongest_evidence',
'evidence_path',
'remaining_gap',
] as const;
type Column = (typeof columns)[number];
type ManifestRow = Record<Column, string>;
const allowedOwnerBoundaries = new Set([
'durable-journal',
'engine-owned',
'explicit-no-realtime-consumer',
'external-upload',
'mixed-saga',
'operational',
'read-only-mutation-transport',
'redis-projection',
'separate-access-journal',
'session-only',
]);
const allowedRefBases = new Set(['direct-endpoint', 'domain-command', 'core-only', 'read-only-transport']);
const allowedActorSources = new Set([
'gateway-token-user',
'optional-session-db-general',
'session-admin-role',
'session-user',
'session-user-db-general',
'session-user-engine-general',
]);
const allowedEvidenceLevels = new Set(['dynamic-ref', 'actual-db', 'redis', 'endpoint-unit', 'source-only']);
const expectedOwnerCounts: Record<string, number> = {
'durable-journal': 19,
'engine-owned': 38,
'explicit-no-realtime-consumer': 7,
'external-upload': 1,
'mixed-saga': 9,
operational: 3,
'read-only-mutation-transport': 2,
'redis-projection': 6,
'separate-access-journal': 1,
'session-only': 1,
};
const parseManifest = (): ManifestRow[] => {
const [header, ...lines] = readFileSync(manifestPath, 'utf8').trimEnd().split(/\r?\n/u);
if (header !== columns.join('\t')) {
throw new Error(`Unexpected mutation evidence manifest header: ${header ?? '<empty>'}`);
}
return lines.map((line, index) => {
const values = line.split('\t');
if (values.length !== columns.length || values.some((value) => value.length === 0)) {
throw new Error(`Invalid mutation evidence row at line ${index + 2}.`);
}
return Object.fromEntries(columns.map((column, valueIndex) => [column, values[valueIndex]])) as ManifestRow;
});
};
interface RuntimeProcedureDef {
type: string;
}
const readRuntimeProcedureDef = (procedure: unknown): RuntimeProcedureDef => {
if (typeof procedure !== 'function') {
throw new Error('Mounted tRPC procedure is not callable.');
}
const definition: unknown = Reflect.get(procedure, '_def');
if (typeof definition !== 'object' || definition === null) {
throw new Error('Mounted tRPC procedure has no runtime definition.');
}
const type: unknown = Reflect.get(definition, 'type');
if (typeof type !== 'string') {
throw new Error('Mounted tRPC procedure has an unexpected runtime definition.');
}
return { type };
};
const mountedMutationNames = (): string[] =>
Object.entries(appRouter._def.procedures)
.filter(([, procedure]) => readRuntimeProcedureDef(procedure).type === 'mutation')
.map(([name]) => name)
.sort();
describe('game-api mutation evidence manifest', () => {
it('lists every mounted mutation exactly once', () => {
const rows = parseManifest();
const manifestRoutes = rows.map(({ route }) => route);
expect(rows).toHaveLength(87);
expect(new Set(manifestRoutes).size).toBe(manifestRoutes.length);
expect(manifestRoutes).toEqual([...manifestRoutes].sort());
expect(manifestRoutes).toEqual(mountedMutationNames());
});
it('retains the bounded ownership taxonomy and allowed evidence vocabulary', () => {
const rows = parseManifest();
const ownerCounts = Object.fromEntries(
[...allowedOwnerBoundaries].map((owner) => [
owner,
rows.filter(({ owner_boundary }) => owner_boundary === owner).length,
])
);
expect(ownerCounts).toEqual(expectedOwnerCounts);
for (const row of rows) {
expect(allowedOwnerBoundaries.has(row.owner_boundary), row.route).toBe(true);
expect(allowedRefBases.has(row.ref_basis), row.route).toBe(true);
expect(allowedActorSources.has(row.actor_source), row.route).toBe(true);
expect(allowedEvidenceLevels.has(row.strongest_evidence), row.route).toBe(true);
expect(row.remaining_gap, row.route).toMatch(/^[a-z0-9-]+$/u);
expect(row.evidence_path.startsWith('app/') || row.evidence_path.startsWith('tools/')).toBe(true);
expect(existsSync(path.join(workspaceRoot, row.evidence_path)), row.route).toBe(true);
}
});
it('records the only unauthenticated mutation boundaries explicitly', () => {
const rowsByRoute = new Map(parseManifest().map((row) => [row.route, row]));
expect(rowsByRoute.get('auth.exchangeGatewayToken')?.actor_source).toBe('gateway-token-user');
expect(rowsByRoute.get('public.recordAccess')?.actor_source).toBe('optional-session-db-general');
const otherPublicActors = [...rowsByRoute]
.filter(([route]) => route !== 'auth.exchangeGatewayToken' && route !== 'public.recordAccess')
.filter(([, row]) => !row.actor_source.startsWith('session-'))
.map(([route]) => route);
expect(otherPublicActors).toEqual([]);
});
});
@@ -0,0 +1,26 @@
import { describe, expect, it } from 'vitest';
import type { GameApiContext } from '../src/context.js';
import { appRouter } from '../src/router.js';
const context = {
auth: null,
generalAccessTracking: true,
db: {},
profile: { id: 'che', name: 'che:default', scenario: 'default' },
profileStatusSource: { get: async () => 'RUNNING' as const },
} as unknown as GameApiContext;
describe('public.recordAccess endpoint', () => {
it('keeps anonymous page telemetry as an accepted no-op outside input_event', async () => {
await expect(appRouter.createCaller(context).public.recordAccess({ page: 'traffic' })).resolves.toEqual({
recorded: false,
});
});
it('rejects page names outside the server-owned Ref access inventory', async () => {
await expect(
appRouter.createCaller(context).public.recordAccess({ page: 'forged-page' } as never)
).rejects.toMatchObject({ code: 'BAD_REQUEST' });
});
});
+9
View File
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest';
import { scopeHttpIdempotencyKey } from '../src/requestId.js';
import { scopeApiInputEventRequestId } from '../src/trpc.js';
describe('HTTP idempotency request IDs', () => {
it('is stable for one principal and isolated across users and profiles', () => {
@@ -24,4 +25,12 @@ describe('HTTP idempotency request IDs', () => {
expect(scoped).toMatch(/^http:[0-9a-f]{64}$/u);
expect(scoped).toHaveLength(69);
});
it('keeps the first call compatible and isolates later calls in a same-path batch', () => {
expect(scopeApiInputEventRequestId('http:base', 'messages.send', 0)).toBe('http:base:messages.send');
expect(scopeApiInputEventRequestId('http:base', 'messages.send', 1)).toBe('http:base:messages.send:batch:1');
expect(scopeApiInputEventRequestId('http:base', 'messages.send', 2)).not.toBe(
scopeApiInputEventRequestId('http:base', 'messages.send', 1)
);
});
});
+271 -1
View File
@@ -24,7 +24,7 @@ const profile: GameProfile = {
name: 'che:default',
};
const buildWorldState = (joinMode = 'full'): WorldStateRow =>
const buildWorldState = (joinMode = 'full', killturn?: number): WorldStateRow =>
({
id: 1,
scenarioCode: 'default',
@@ -39,6 +39,7 @@ const buildWorldState = (joinMode = 'full'): WorldStateRow =>
scenarioMeta: {
startYear: 180,
},
...(killturn === undefined ? {} : { killturn }),
},
updatedAt: new Date('2026-01-01T00:00:00Z'),
}) as unknown as WorldStateRow;
@@ -114,6 +115,7 @@ const buildContext = (options?: {
nationTurns?: NationTurnRow[];
generalTurnWrites?: unknown[];
nationTurnWrites?: unknown[];
generalUpdates?: unknown[];
auth?: GameSessionTokenPayload | null;
currentAccountIcon?: unknown;
accountIconGet?: (userId: string) => Promise<unknown>;
@@ -128,6 +130,10 @@ const buildContext = (options?: {
let generalTurnRevision: number | undefined;
let nationTurnRevision: number | undefined;
const db = {
$queryRaw: async (query: unknown) => {
options?.generalUpdates?.push(query);
return options?.generalUpdates ? [{ id: options.general?.id ?? 0 }] : [];
},
worldState: {
findFirst: async () => {
if (options?.worldStateReads) {
@@ -1106,6 +1112,270 @@ describe('appRouter', () => {
expect(nationWrites).toHaveLength(2);
});
it('stores Ref-sanitized nation command strings in the reserved queue', async () => {
const general = buildGeneralRow({ id: 19, nationId: 3, officerLevel: 12 });
const nationWrites: unknown[] = [];
const caller = appRouter.createCaller(
buildContext({ state: buildWorldState(), general, nationTurnWrites: nationWrites })
);
const response = await caller.turns.reserved.setNation({
generalId: general.id,
turnIndex: 0,
action: 'che_국호변경',
args: { nationName: ' <신-국># ' },
expectedRevision: 0,
});
expect(response.turns[0]?.args).toEqual({ nationName: '&lt;신국&gt;' });
expect(nationWrites).toHaveLength(1);
expect(nationWrites[0]).toMatchObject({
data: expect.arrayContaining([
expect.objectContaining({ turnIdx: 0, arg: { nationName: '&lt;신국&gt;' } }),
]),
});
});
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' });
await expect(
malformedCaller.turns.reserved.setNationBulk({
generalId: general.id,
entries: [
{
turnList: [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: 'che_포상',
args: {},
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: 'che_포상', args: {} },
{ turnList: [1], action: 'not-a-command' },
],
expectedRevision: 0,
})
).rejects.toMatchObject({
code: 'PRECONDITION_FAILED',
message: '수뇌 턴 입력 불가능',
});
expect(bulkWrites).toHaveLength(0);
expect(bulkUpdates).toHaveLength(0);
const allowedCaller = appRouter.createCaller(
buildContext({
state: buildWorldState('full', 12),
general: buildGeneralRow({
...general,
penalty: {},
}),
})
);
await expect(
allowedCaller.turns.reserved.setNation({
generalId: general.id,
turnIndex: 0,
action: 'che_포상',
args: {},
expectedRevision: 0,
})
).rejects.toMatchObject({ code: 'BAD_REQUEST' });
});
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 () => {
const general = buildGeneralRow({ id: 19 });
const allowedWrites: unknown[] = [];
@@ -16,6 +16,7 @@ import {
} from '@sammo-ts/infra';
import { RedisAccessTokenStore } from '../src/auth/accessTokenStore.js';
import { createApiInputPayloadIdentity } from '../src/inputEventBoundary.js';
import { scopeHttpIdempotencyKey } from '../src/requestId.js';
import { createGameApiServer } from '../src/server.js';
import { WebPushOutboxWorker } from '../src/services/webPushOutboxWorker.js';
@@ -46,6 +47,7 @@ const matrixApiEventTypes = [
'messages.send',
'turns.reserved.setGeneral',
'turns.reserved.setNation',
'turns.reserved.setNationBulk',
] as const;
const fixtureActorUserIds = [userId, noGeneralUserId, sameNationUserId, foreignUserId, ordinaryUserId];
const secret = 'security-http-e2e-secret';
@@ -74,6 +76,7 @@ let disconnectDb: (() => Promise<void>) | null = null;
let redis: RedisConnector | null = null;
let accessTokenStore: RedisAccessTokenStore;
let createdFixtureWorld = false;
let reservationWorldId = fixtureWorldId;
let gatewayStatusServer: HttpServer | null = null;
let receivedGatewayWebPushEvents: Array<{ internalToken: string | null; body: unknown }> = [];
@@ -285,6 +288,7 @@ const readReservedMutationState = async () => ({
where: {
OR: [
{ domain: 'reserved.general', entityId: { in: fixtureGeneralIds } },
{ domain: 'general.content', entityId: { in: fixtureGeneralIds } },
{ domain: 'dashboard.global', entityId: 0 },
],
},
@@ -420,7 +424,12 @@ const readRealtimeRedisState = async (): Promise<Array<[string, string | null]>>
const expectApiInputEvent = async (
idempotencyKey: string,
procedure: string,
expected: { actorUserId: string; status: 'FAILED' | 'SUCCEEDED' } | null
expected: {
actorUserId: string;
status: 'FAILED' | 'SUCCEEDED';
payload?: unknown;
result?: unknown;
} | null
): Promise<void> => {
const events = await db.inputEvent.findMany({
// The HTTP boundary hashes the raw client key together with profile and
@@ -455,10 +464,16 @@ const expectApiInputEvent = async (
requestId,
target: 'API',
eventType: procedure,
payload: {},
payload:
expected.payload === undefined
? {
version: 1,
digest: expect.stringMatching(/^sha256:[0-9a-f]{64}$/u),
}
: createApiInputPayloadIdentity(expected.payload),
actorUserId: expected.actorUserId,
status: expected.status,
result: expected.status === 'SUCCEEDED' ? { ok: true } : null,
result: expected.status === 'SUCCEEDED' ? (expected.result ?? expect.objectContaining({ ok: true })) : null,
error: expected.status === 'SUCCEEDED' ? null : expect.any(String),
attempts: 1,
lockedBy: null,
@@ -509,20 +524,58 @@ const requestReservedGeneral = (accessToken: string | undefined, idempotencyKey:
idempotencyKey,
});
const requestReservedNation = (accessToken: string, idempotencyKey: string, targetGeneralId: number) =>
const requestReservedNation = (
accessToken: string,
idempotencyKey: string,
targetGeneralId: number,
command: { action: string; args: unknown } = { action: '휴식', args: {} }
) =>
requestTrpc('turns.reserved.setNation', {
method: 'POST',
input: {
generalId: targetGeneralId,
turnIndex: 0,
action: '휴식',
args: {},
action: command.action,
args: command.args,
expectedRevision: 0,
},
accessToken,
idempotencyKey,
});
const requestReservedNationBulk = (
accessToken: string,
idempotencyKey: string,
targetGeneralId: number,
command: { action: string; args: unknown } = { action: '휴식', args: {} }
) =>
requestTrpc('turns.reserved.setNationBulk', {
method: 'POST',
input: {
generalId: targetGeneralId,
entries: [{ turnList: [0, 1], action: command.action, args: command.args }],
expectedRevision: 0,
},
accessToken,
idempotencyKey,
});
type NationReservationKind = 'single' | 'bulk';
const nationReservationProcedure = (kind: NationReservationKind) =>
kind === 'single' ? 'turns.reserved.setNation' : 'turns.reserved.setNationBulk';
const requestNationReservation = (
kind: NationReservationKind,
accessToken: string,
idempotencyKey: string,
targetGeneralId = generalId,
command: { action: string; args: unknown } = { action: '휴식', args: {} }
) =>
kind === 'single'
? requestReservedNation(accessToken, idempotencyKey, targetGeneralId, command)
: requestReservedNationBulk(accessToken, idempotencyKey, targetGeneralId, command);
const ownershipDenialCases = [
{
label: 'authenticated user without a general',
@@ -638,7 +691,11 @@ integration('game API security over HTTP transport', () => {
},
],
});
if ((await db.worldState.count()) === 0) {
const existingWorlds = await db.worldState.findMany({
select: { id: true, scenarioCode: true },
orderBy: { id: 'asc' },
});
if (existingWorlds.length === 0) {
await db.worldState.create({
data: {
id: fixtureWorldId,
@@ -651,7 +708,26 @@ integration('game API security over HTTP transport', () => {
},
});
createdFixtureWorld = true;
} else if (
existingWorlds.length === 1 &&
existingWorlds[0]?.id === fixtureWorldId &&
existingWorlds[0].scenarioCode === 'security-http'
) {
// A previously interrupted run may leave our own fixture row. It
// remains owned by this suite and is removed during teardown.
createdFixtureWorld = true;
} else {
throw new Error(
`security transport fixture requires an empty schema or its owned world row, got ${JSON.stringify(existingWorlds)}`
);
}
const reservationWorlds = await db.worldState.findMany({ select: { id: true } });
if (reservationWorlds.length !== 1 || reservationWorlds[0]?.id !== fixtureWorldId) {
throw new Error(
`security transport fixture requires world ${fixtureWorldId}, got ${JSON.stringify(reservationWorlds)}`
);
}
reservationWorldId = reservationWorlds[0].id;
await db.trafficPeriodGeneral.deleteMany({ where: { generalId: { in: fixtureGeneralIds } } });
await db.trafficPeriod.deleteMany({ where: { worldStateId: fixtureWorldId } });
await db.readModelOutbox.deleteMany();
@@ -683,6 +759,7 @@ integration('game API security over HTTP transport', () => {
where: {
OR: [
{ domain: 'reserved.general', entityId: { in: fixtureGeneralIds } },
{ domain: 'general.content', entityId: { in: fixtureGeneralIds } },
{ domain: 'dashboard.global', entityId: 0 },
],
},
@@ -704,6 +781,14 @@ integration('game API security over HTTP transport', () => {
beforeEach(async () => {
await deleteMatrixInputEvents();
await db.general.update({
where: { id: generalId },
data: { npcState: 0, meta: {}, penalty: {} },
});
await db.worldState.update({
where: { id: reservationWorldId },
data: { meta: {} },
});
await db.trafficPeriodGeneral.deleteMany({ where: { generalId: { in: fixtureGeneralIds } } });
await db.trafficPeriod.deleteMany({ where: { worldStateId: fixtureWorldId } });
await db.generalAccessLog.deleteMany({ where: { generalId: { in: fixtureGeneralIds } } });
@@ -715,6 +800,7 @@ integration('game API security over HTTP transport', () => {
where: {
OR: [
{ domain: 'reserved.general', entityId: { in: fixtureGeneralIds } },
{ domain: 'general.content', entityId: { in: fixtureGeneralIds } },
{ domain: 'dashboard.global', entityId: 0 },
],
},
@@ -1051,6 +1137,261 @@ integration('game API security over HTTP transport', () => {
});
});
it.each(
(['single', 'bulk'] as const).flatMap((kind) =>
[
{ label: 'zero', value: 0 },
{ label: 'false', value: false },
{ label: 'null', value: null },
].map((penalty) => ({ kind, ...penalty }))
)
)(
'rejects $kind nation reservation when noChiefTurnInput is $label without committing queue or journal',
async ({ kind, label, value }) => {
const procedure = nationReservationProcedure(kind);
const idempotencyKey = `${mutationRequestPrefix}nation-penalty-${kind}-${label}`;
const accessToken = await createAccessToken(`matrix-nation-penalty-${kind}-${label}`, {});
await db.general.update({
where: { id: generalId },
data: {
meta: { killturn: 3, marker: 'penalty-preserved' },
penalty: { noChiefTurnInput: value },
},
});
await db.worldState.update({
where: { id: reservationWorldId },
data: { meta: { killturn: 12 } },
});
const result = await requestNationReservation(kind, accessToken, idempotencyKey, generalId, {
action: 'che_포상',
args: {},
});
expect(result.response.status).toBe(412);
expect(result.body).toMatchObject({
error: {
message: '수뇌 턴 입력 불가능',
data: { code: 'PRECONDITION_FAILED' },
},
});
expect(await db.nationTurn.count({ where: { nationId: ownerNationId, officerLevel: 12 } })).toBe(0);
expect(
await db.nationTurnRevision.findUnique({
where: { nationId_officerLevel: { nationId: ownerNationId, officerLevel: 12 } },
})
).toBeNull();
expect(await db.general.findUniqueOrThrow({ where: { id: generalId } })).toMatchObject({
meta: { killturn: 3, marker: 'penalty-preserved' },
penalty: { noChiefTurnInput: value },
});
expect(await db.readModelRevision.count()).toBe(0);
expect(await db.readModelOutbox.count()).toBe(0);
await expectApiInputEvent(idempotencyKey, procedure, {
actorUserId: userId,
status: 'FAILED',
});
}
);
it.each([
{ kind: 'single' as const, label: 'single' },
{ kind: 'bulk' as const, label: 'bulk' },
])('commits $label nation queue, JSONB killturn refill, and read-model journal together', async ({ kind }) => {
const procedure = nationReservationProcedure(kind);
const idempotencyKey = `${mutationRequestPrefix}nation-refill-${kind}`;
const accessToken = await createAccessToken(`matrix-nation-refill-${kind}`, {});
await db.general.update({
where: { id: generalId },
data: {
npcState: 0,
meta: { killturn: 3, marker: 'preserved-by-jsonb-set' },
penalty: {},
},
});
await db.worldState.update({
where: { id: reservationWorldId },
data: { meta: { killturn: 12, marker: 'world-preserved' } },
});
const result = await requestNationReservation(kind, accessToken, idempotencyKey);
expect(result.response.status).toBe(200);
expect(result.body).toMatchObject({ result: { data: { ok: true, revision: 1 } } });
expect(
await db.nationTurn.findMany({
where: { nationId: ownerNationId, officerLevel: 12 },
select: { turnIdx: true, actionCode: true, arg: true },
orderBy: { turnIdx: 'asc' },
})
).toEqual(
Array.from({ length: 12 }, (_, turnIdx) => ({
turnIdx,
actionCode: '휴식',
arg: {},
}))
);
expect(
await db.nationTurnRevision.findUniqueOrThrow({
where: { nationId_officerLevel: { nationId: ownerNationId, officerLevel: 12 } },
})
).toMatchObject({ revision: 1, leaseOwner: null, leaseExpiresAt: null });
expect(await db.general.findUniqueOrThrow({ where: { id: generalId } })).toMatchObject({
npcState: 0,
meta: { killturn: 12, marker: 'preserved-by-jsonb-set' },
});
expect(await db.worldState.findUniqueOrThrow({ where: { id: reservationWorldId } })).toMatchObject({
meta: { killturn: 12, marker: 'world-preserved' },
});
expect(
await db.readModelRevision.findMany({
select: { domain: true, entityId: true, revision: true },
orderBy: [{ domain: 'asc' }, { entityId: 'asc' }],
})
).toEqual([
{ domain: 'dashboard.global', entityId: 0, revision: 1n },
{ domain: 'general.content', entityId: generalId, revision: 1n },
]);
expect(await db.readModelOutbox.findMany({ select: { payload: true } })).toEqual([
{
payload: {
version: 1,
changes: [
['dashboard.global', 0, '1'],
['general.content', generalId, '1'],
],
},
},
]);
await expectApiInputEvent(idempotencyKey, procedure, {
actorUserId: userId,
status: 'SUCCEEDED',
});
});
it.each([
{
kind: 'single' as const,
label: 'already-higher user killturn',
npcState: 0,
currentKillturn: 20,
},
{
kind: 'bulk' as const,
label: 'NPC actor',
npcState: 2,
currentKillturn: 3,
},
])('keeps $label unchanged while committing its nation queue', async ({ kind, npcState, currentKillturn }) => {
const procedure = nationReservationProcedure(kind);
const idempotencyKey = `${mutationRequestPrefix}nation-refill-noop-${kind}`;
const accessToken = await createAccessToken(`matrix-nation-refill-noop-${kind}`, {});
await db.general.update({
where: { id: generalId },
data: {
npcState,
meta: { killturn: currentKillturn, marker: 'no-op-preserved' },
penalty: {},
},
});
await db.worldState.update({
where: { id: reservationWorldId },
data: { meta: { killturn: 12 } },
});
const result = await requestNationReservation(kind, accessToken, idempotencyKey);
expect(result.response.status).toBe(200);
expect(result.body).toMatchObject({ result: { data: { ok: true, revision: 1 } } });
expect(await db.nationTurn.count({ where: { nationId: ownerNationId, officerLevel: 12 } })).toBe(12);
expect(await db.general.findUniqueOrThrow({ where: { id: generalId } })).toMatchObject({
npcState,
meta: { killturn: currentKillturn, marker: 'no-op-preserved' },
});
expect(await db.readModelRevision.count()).toBe(0);
expect(await db.readModelOutbox.count()).toBe(0);
await expectApiInputEvent(idempotencyKey, procedure, {
actorUserId: userId,
status: 'SUCCEEDED',
});
});
it.each([
{ kind: 'single' as const, label: 'single' },
{ kind: 'bulk' as const, label: 'bulk' },
])(
'rolls back $label queue, killturn, and read-model revisions when journal persistence fails',
async ({ kind }) => {
const procedure = nationReservationProcedure(kind);
const idempotencyKey = `${mutationRequestPrefix}nation-refill-rollback-${kind}`;
const accessToken = await createAccessToken(`matrix-nation-refill-rollback-${kind}`, {});
await db.general.update({
where: { id: generalId },
data: {
npcState: 0,
meta: { killturn: 3, marker: 'rollback-preserved' },
penalty: {},
},
});
await db.worldState.update({
where: { id: reservationWorldId },
data: { meta: { killturn: 12 } },
});
await db.$executeRawUnsafe(`
CREATE FUNCTION security_transport_fail_read_model_outbox()
RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
RAISE EXCEPTION 'forced security transport read-model journal failure';
END;
$$
`);
await db.$executeRawUnsafe(`
CREATE TRIGGER security_transport_fail_read_model_outbox
BEFORE INSERT ON read_model_outbox
FOR EACH ROW
EXECUTE FUNCTION security_transport_fail_read_model_outbox()
`);
const result = await (async () => {
try {
return await requestNationReservation(kind, accessToken, idempotencyKey);
} finally {
await db.$executeRawUnsafe(
'DROP TRIGGER IF EXISTS security_transport_fail_read_model_outbox ON read_model_outbox'
);
await db.$executeRawUnsafe('DROP FUNCTION IF EXISTS security_transport_fail_read_model_outbox()');
}
})();
expect(result.response.status).toBe(500);
expect(result.body).toMatchObject({ error: { data: { code: 'INTERNAL_SERVER_ERROR' } } });
expect(await db.nationTurn.count({ where: { nationId: ownerNationId, officerLevel: 12 } })).toBe(0);
expect(
await db.nationTurnRevision.findUnique({
where: { nationId_officerLevel: { nationId: ownerNationId, officerLevel: 12 } },
})
).toBeNull();
expect(await db.general.findUniqueOrThrow({ where: { id: generalId } })).toMatchObject({
npcState: 0,
meta: { killturn: 3, marker: 'rollback-preserved' },
});
expect(await db.readModelRevision.count()).toBe(0);
expect(await db.readModelOutbox.count()).toBe(0);
await expectApiInputEvent(idempotencyKey, procedure, {
actorUserId: userId,
status: 'FAILED',
});
expect(
await db.inputEvent.findUniqueOrThrow({
where: { requestId: resolveScopedApiRequestId(idempotencyKey, procedure, userId) },
select: { error: true },
})
).toEqual({ error: expect.stringContaining('forced security transport read-model journal failure') });
}
);
it('commits an owned general reservation once with an authenticated actor and durable journal', async () => {
const idempotencyKey = `${mutationRequestPrefix}general-success`;
const accessToken = await createAccessToken('matrix-general-success', {});
@@ -1409,8 +1750,15 @@ integration('game API security over HTTP transport', () => {
expect(await readRealtimeRedisState()).toEqual(redisBefore);
}, 15_000);
it('commits an owned officer nation reservation and rejects duplicate idempotency replay without a second queue mutation', async () => {
it('commits an owned officer nation reservation and replays the durable response without a second queue mutation', async () => {
const idempotencyKey = `${mutationRequestPrefix}nation-success`;
const inputPayload = {
generalId,
turnIndex: 0,
action: '휴식',
args: {},
expectedRevision: 0,
};
const accessToken = await createAccessToken('matrix-nation-success', {});
const databaseBefore = await readReservedMutationState();
const durableBefore = await readDurableSchemaStateExcludingMatrixApiJournal();
@@ -1420,6 +1768,7 @@ integration('game API security over HTTP transport', () => {
const first = await requestReservedNation(accessToken, idempotencyKey, generalId);
expect(first.response.status).toBe(200);
expect(first.body).toMatchObject({ result: { data: { ok: true, revision: 1 } } });
const firstResult = (first.body as { result: { data: unknown } }).result.data;
expect(
await db.nationTurn.findMany({
where: { nationId: ownerNationId, officerLevel: 12 },
@@ -1443,6 +1792,8 @@ integration('game API security over HTTP transport', () => {
await expectApiInputEvent(idempotencyKey, 'turns.reserved.setNation', {
actorUserId: userId,
status: 'SUCCEEDED',
payload: inputPayload,
result: firstResult,
});
const committed = await readReservedMutationState();
@@ -1551,10 +1902,14 @@ integration('game API security over HTTP transport', () => {
where: { requestId: replayRequestId },
});
const replay = await requestReservedNation(accessToken, idempotencyKey, generalId);
expect(replay.response.status).toBe(409);
expect(replay.body).toMatchObject({ error: { data: { code: 'CONFLICT' } } });
expect(await readReservedMutationState()).toEqual(committed);
expect(await readDurableSchemaStateExcludingMatrixApiJournal()).toEqual(replayDurableBefore);
expect(replay.response.status).toBe(200);
expect(replay.body).toEqual(first.body);
const replayState = await readReservedMutationState();
expect({ ...replayState, generalAccessLogs: committed.generalAccessLogs }).toEqual(committed);
expectSingleActorActivity(replayState.generalAccessLogs);
expect(
withoutDurableTables(await readDurableSchemaStateExcludingMatrixApiJournal(), ['general_access_log'])
).toEqual(withoutDurableTables(replayDurableBefore, ['general_access_log']));
expect(await readRealtimeRedisState()).toEqual(replayRedisBefore);
expect(
await db.inputEvent.findUniqueOrThrow({
@@ -1565,6 +1920,8 @@ integration('game API security over HTTP transport', () => {
await expectApiInputEvent(idempotencyKey, 'turns.reserved.setNation', {
actorUserId: userId,
status: 'SUCCEEDED',
payload: inputPayload,
result: firstResult,
});
});
+105
View File
@@ -397,6 +397,111 @@ describe('tournament router permissions and mutations', () => {
await expect(adminCaller.tournament.getAdminStatus()).resolves.toEqual({ ok: true });
});
it('applies the admin role boundary to every tournament mutation', async () => {
const redis = new MemoryRedis();
const transport = new TournamentTransport();
const general = buildGeneral(1, 'user-1');
const caller = appRouter.createCaller(
buildContext({ redis, transport, generals: [general], userId: 'user-1', roles: ['user'] })
);
const state = {
stage: 1,
phase: 0,
type: 0,
auto: false,
openYear: 193,
openMonth: 1,
termSeconds: 60,
nextAt: '2026-07-26T01:00:00.000Z',
};
await expect(caller.tournament.setState(state)).rejects.toMatchObject({ code: 'FORBIDDEN' });
await expect(caller.tournament.patchState({ phase: 1 })).rejects.toMatchObject({ code: 'FORBIDDEN' });
await expect(caller.tournament.setParticipants([])).rejects.toMatchObject({ code: 'FORBIDDEN' });
await expect(caller.tournament.setMatches([])).rejects.toMatchObject({ code: 'FORBIDDEN' });
await expect(caller.tournament.setBettingEntries([])).rejects.toMatchObject({ code: 'FORBIDDEN' });
await expect(caller.tournament.seedParticipants({ generalIds: [general.id] })).rejects.toMatchObject({
code: 'FORBIDDEN',
});
await expect(caller.tournament.cancel()).rejects.toMatchObject({ code: 'FORBIDDEN' });
});
it('validates and executes every profile-scoped tournament admin mutation', async () => {
const redis = new MemoryRedis();
const transport = new TournamentTransport();
const general = buildGeneral(1, 'user-1');
const rival = buildGeneral(2, 'user-2');
const caller = appRouter.createCaller(
buildContext({
redis,
transport,
generals: [general, rival],
userId: 'user-1',
roles: ['admin.tournament:che:default'],
})
);
const state = {
stage: 6,
phase: 0,
type: 0,
auto: false,
openYear: 193,
openMonth: 1,
termSeconds: 60,
nextAt: '2026-07-26T01:00:00.000Z',
bettingCloseAt: '2099-01-01T00:00:00.000Z',
};
await expect(caller.tournament.setState(state)).resolves.toEqual({ ok: true });
await expect(caller.tournament.patchState({ phase: 2 })).resolves.toEqual({ ok: true });
await expect(
caller.tournament.setParticipants([
{
id: general.id,
name: general.name,
leadership: general.leadership,
strength: general.strength,
intel: general.intel,
level: 5,
groupId: 0,
},
])
).resolves.toEqual({ ok: true, count: 1 });
await expect(
caller.tournament.setMatches([
{
id: 1,
stage: 7,
roundIndex: 0,
attackerId: general.id,
defenderId: rival.id,
},
])
).resolves.toEqual({ ok: true, count: 1 });
await expect(
caller.tournament.setBettingEntries([
{ generalId: general.id, targetId: rival.id, amount: 100 },
])
).resolves.toEqual({ ok: true, count: 1 });
await expect(caller.tournament.seedParticipants({ generalIds: [general.id, rival.id] })).resolves.toEqual({
ok: true,
count: 2,
});
await expect(caller.tournament.cancel()).resolves.toEqual({ ok: true });
expect(transport.commands).toContainEqual({
type: 'tournamentRefund',
refunds: [{ generalId: general.id, amount: 100 }],
reason: 'cancel',
});
await expect(caller.tournament.getState()).resolves.toMatchObject({ stage: 0, phase: 0, auto: false });
await expect(caller.tournament.getSnapshot()).resolves.toMatchObject({
participants: [],
matches: [],
betCount: 0,
});
});
it('returns the legacy tournament rank ordering only to a user who owns a general', async () => {
const redis = new MemoryRedis();
const transport = new TournamentTransport();
+91
View File
@@ -324,6 +324,97 @@ describe('troop router permissions and mutations', () => {
});
});
it('joins a troop with the session-owned general and a stable ENGINE request identity', async () => {
const transaction = vi.fn(async () => {
throw new Error('API transaction must not run');
});
const fixture = buildContext({
me: buildGeneral({ id: 17, userId: 'user-1', troopId: 0 }),
requestId: 'http-troop-join',
transaction,
result: { type: 'troopJoin', ok: true, generalId: 17, troopId: 9 },
});
const input = { troopId: 9, userId: 'forged-user', generalId: 999 };
await expect(appRouter.createCaller(fixture.context).troop.join(input)).resolves.toEqual({ ok: true });
expect(transaction).not.toHaveBeenCalled();
expect(fixture.requestCommand).toHaveBeenCalledWith({
type: 'troopJoin',
requestId: 'http-troop-join:troop.join:engine:0:troopJoin',
userId: 'user-1',
generalId: 17,
troopId: 9,
});
});
it('maps an authoritative troop-join rejection without trusting client actor fields', async () => {
const fixture = buildContext({
me: buildGeneral({ id: 17, userId: 'user-1', troopId: 0 }),
result: {
type: 'troopJoin',
ok: false,
generalId: 17,
troopId: 9,
reason: '다른 국가의 부대입니다.',
},
});
const input = { troopId: 9, userId: 'forged-user', generalId: 999 };
await expect(appRouter.createCaller(fixture.context).troop.join(input)).rejects.toMatchObject({
code: 'PRECONDITION_FAILED',
message: '다른 국가의 부대입니다.',
});
expect(fixture.requestCommand).toHaveBeenCalledWith(
expect.objectContaining({ userId: 'user-1', generalId: 17, troopId: 9 })
);
});
it('exits a troop with the session-owned general and a stable ENGINE request identity', async () => {
const transaction = vi.fn(async () => {
throw new Error('API transaction must not run');
});
const fixture = buildContext({
me: buildGeneral({ id: 17, userId: 'user-1', troopId: 9 }),
requestId: 'http-troop-exit',
transaction,
result: { type: 'troopExit', ok: true, generalId: 17, wasLeader: false },
});
await expect(appRouter.createCaller(fixture.context).troop.exit()).resolves.toEqual({
ok: true,
wasLeader: false,
});
expect(transaction).not.toHaveBeenCalled();
expect(fixture.requestCommand).toHaveBeenCalledWith({
type: 'troopExit',
requestId: 'http-troop-exit:troop.exit:engine:0:troopExit',
userId: 'user-1',
generalId: 17,
});
});
it('maps an authoritative troop-exit rejection for the session-owned general', async () => {
const fixture = buildContext({
me: buildGeneral({ id: 17, userId: 'user-1', troopId: 0 }),
result: {
type: 'troopExit',
ok: false,
generalId: 17,
reason: '부대에 소속되어 있지 않습니다.',
},
});
await expect(appRouter.createCaller(fixture.context).troop.exit()).rejects.toMatchObject({
code: 'PRECONDITION_FAILED',
message: '부대에 소속되어 있지 않습니다.',
});
expect(fixture.requestCommand).toHaveBeenCalledWith({
type: 'troopExit',
userId: 'user-1',
generalId: 17,
});
});
it('maps an ENGINE actor-binding rejection to a forbidden API response', async () => {
const fixture = buildContext({
result: {
@@ -1858,7 +1858,9 @@ export const createReservedTurnHandler = async (options: {
currentGeneral.crew = 0;
currentGeneral.rice = 0;
logs.push(
createGeneralActionLog(currentGeneral.id, '군량이 모자라 병사들이 <R>소집해제</>되었습니다!')
createGeneralActionLog(currentGeneral.id, '군량이 모자라 병사들이 <R>소집해제</>되었습니다!', {
format: LogFormat.PLAIN,
})
);
preTurnContext.skill.activate('pre.소집해제');
}
@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest';
import { ConstantRNG, RandUtil } from '@sammo-ts/common';
import { LogFormat } from '@sammo-ts/logic';
import type { TurnSchedule } from '@sammo-ts/logic/turn/calendar.js';
import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
import { createTurnTestHarness } from './helpers/turnTestHarness.js';
@@ -358,7 +359,12 @@ describe('legacy general-turn execution contract', () => {
expect(updated.crew).toBe(0);
expect(updated.rice).toBe(0);
expect(harness.world.getCityById(1)!.population).toBe(10_200);
expect(harness.getCollectedLogs().some((log) => log.text.includes('소집해제'))).toBe(true);
expect(harness.getCollectedLogs()).toContainEqual(
expect.objectContaining({
text: expect.stringContaining('소집해제'),
format: LogFormat.PLAIN,
})
);
});
it('persists pre-turn stacking and applies the inherited 60-turn cooldown', async () => {
+117
View File
@@ -0,0 +1,117 @@
# API input-event 재실행·복구 계약
## 목적과 범위
game-api mutation은 HTTP `Idempotency-Key`를 profile·인증 actor와 함께 scope한 base ID,
tRPC procedure path, batch index에서 만든 operation key를 `input_event.request_id`
사용한다. 이 원장은 동일 요청이 네트워크
재시도나 응답 유실 때문에 다시 도착했을 때 업무 mutation을 두 번 실행하지 않고,
이미 성공한 응답을 그대로 재생하기 위한 durable 경계다.
이 계약은 `target = 'API'`인 tRPC mutation에만 적용한다. turn daemon의
`target = 'ENGINE'` 처리와 gameplay 계산·RNG 순서는 바꾸지 않는다.
## 요청 identity와 저장 경계
요청 identity는 다음 네 요소가 모두 같은 경우에만 일치한다.
- scoped request ID
- tRPC procedure path인 `event_type`
- 인증된 서버-side `actor_user_id`
- HTTP JSON decoding 뒤 `getRawInput()`이 돌려준 raw tRPC input의 canonical SHA-256 digest
raw input을 사용하므로 parser가 strip·transform하는 field도 operation identity에는 포함된다.
`payload`에는 원문 대신 다음처럼 고정 크기 identity envelope만 저장한다.
```json
{
"version": 1,
"digest": "sha256:<64 hexadecimal characters>"
}
```
object key 순서는 digest에 영향을 주지 않지만 배열 순서와 값은 영향을 준다.
따라서 `board.uploadImage`의 data URL, 토큰, 자유 입력처럼 크거나 민감할 수 있는
필드를 `input_event`에 한 번 더 영구 복제하지 않는다. digest만으로 원래 입력을
복원하거나 사후 감사할 수는 없다. 입력 원문 보존이 필요한 별도 기능은 목적에 맞는
접근 제어와 retention을 가진 저장소를 사용해야 한다.
성공한 업무의 실제 JSON 응답은 canonical JSON으로 `result`에 저장한다. 정확히 같은
재요청은 업무 code를 다시 호출하지 않고 이 값을 200 응답으로 재생한다. 응답 자체에
개인정보나 큰 payload가 들어갈 수 있으므로 `input_event` DB 접근 권한과 retention은
별도로 제한해야 한다. 이 변경은 result retention 정책을 새로 정하지 않는다.
## transaction과 상태 전이
각 요청은 하나의 PostgreSQL transaction에서 해당 row를 `FOR UPDATE`로 잠근다.
```text
없는 row -> PENDING 삽입 -> PROCESSING(attempts + 1)
-> SAVEPOINT
-> 업무 mutation + read-model journal
-> SUCCEEDED + 실제 result -> COMMIT
-> 업무 오류: ROLLBACK TO SAVEPOINT
-> FAILED + error -> COMMIT -> 오류 반환
```
`PROCESSING` 표시, 업무 mutation, `SUCCEEDED`와 result 저장은 같은 transaction에
있다. process나 DB connection이 commit 전에 사라지면 모두 rollback되어 새로 만든
row는 없어지거나 기존 PENDING/FAILED 상태로 되돌아간다. 업무 오류는 savepoint까지만
rollback해 업무 write를 남기지 않으면서 FAILED와 증가한 attempts를 durable하게
남긴다.
transaction 결과를 client가 받지 못한 ambiguous failure도 고려한다. 별도 실패
기록기는 row lock 아래 현재 상태를 다시 확인하며, 이미 commit된 SUCCEEDED나 다른
실행의 PROCESSING을 FAILED로 덮지 않는다.
상태별 처리 계약은 다음과 같다.
| 기존 상태 | identity 일치 | 처리 |
| ----------------------- | ------------- | ------------------------------------------------------------ |
| `SUCCEEDED` | 예 | 저장된 `result`를 200으로 재생, attempts 불변 |
| `SUCCEEDED` | 아니오 | `CONFLICT`(HTTP 409), 업무 미실행 |
| `PENDING` 또는 `FAILED` | 예 | row lock 아래 claim하고 attempts를 정확히 1 증가한 뒤 재실행 |
| `PENDING` 또는 `FAILED` | 아니오 | `CONFLICT`, 업무 미실행 |
| `PROCESSING` | 무관 | fail-closed `CONFLICT`, 자동 reclaim 금지 |
구버전이 `FAILED`, `payload = {}`, `result IS NULL`로 남긴 row는 event type과 actor가
같을 때만 현재 digest를 최초 1회 채택해 retry할 수 있다. 반면 구버전
`PROCESSING + payload = {}`는 identity와 활성 transaction 종료 여부를 증명할 수
없어 age나 lease를 기준으로 자동 reclaim하지 않는다. 구버전 SUCCEEDED placeholder도
원 응답을 복원할 수 없으므로 현재 digest와 일치하는 replay로 간주하지 않는다.
동일 HTTP batch 안에서 같은 procedure path가 여러 번 호출될 수 있다. index 0은 기존
호환 key인 `<base-request-id>:<path>`를 유지하고, 이후 호출은
`<base-request-id>:<path>:batch:<index>`를 사용해 서로 충돌하지 않게 한다.
## rolling deployment 전 확인
새 binary를 투입하기 전에 구 binary로 들어오는 mutation을 drain하고, 각 profile
schema에서 다음 read-only query 결과가 0인지 확인한다.
```sql
SELECT count(*)
FROM input_event
WHERE target = 'API'
AND status = 'PROCESSING';
```
0이 아니면 새 binary가 해당 row를 자동 복구하도록 두지 않는다. 구 process와 traffic을
먼저 완전히 drain한 뒤, request별 업무 commit 여부를 확인할 수 있는 offline
reconciliation 절차를 별도로 수행한다. 생성 시각이나 processing age만 보고 status를
바꾸거나 요청을 재실행하면 오래 실행 중인 구 transaction과 중복 mutation이 생길 수
있다.
## 검증 위치와 남은 경계
- `app/game-api/test/inputEventBoundary.test.ts`: canonical digest와 원문 비저장
- `app/game-api/test/inputEventBoundary.integration.test.ts`: 실제 PostgreSQL row
lock, replay, conflict, retry/attempts, legacy row, concurrent race와 commit ambiguity
- `app/game-api/test/securityTransport.integration.test.ts`: 실제 HTTP/tRPC 응답 replay,
durable payload identity/result와 업무 DB/Redis side effect 불변
- `app/game-api/test/requestId.test.ts`: 동일 path batch index 분리
현재 frontend가 사용자 동작별 stable idempotency key를 발급·재사용하는 계약은 이
범위에 포함되지 않는다. 따라서 client가 재시도 때 새 base request ID를 만들면 server
원장은 두 요청을 같은 operation으로 묶을 수 없다.
@@ -2,9 +2,10 @@
## 범위와 판정 규칙
`app/game-api/src/router/**`에서 `.mutation()`으로 선언한 86개 route를 2026-08-16
기준으로 전수 분류한다. 이 목록은 “mutation transport를 사용한다”와 “game DB를
변경한다”를 구분한다. 신규 route가 추가되
`app/game-api/src/router/**`에서 `.mutation()`으로 선언하고 실제 `appRouter`에 mount한
87개 route를 2026-08-24 기준으로 전수 분류한다. 이 목록은 “mutation transport를
사용한다”와 “game DB를 변경한다”를 구분한다. 신규 route가 추가되거나 선언한 router가
mount되지 않으면
`app/game-api/test/directMutationJournalInventory.test.ts`가 실패하므로 소유권과
실시간 소비자를 먼저 정해야 한다.
@@ -33,10 +34,10 @@ writer reconciliation을 포함한다. rolling deployment가 끝난 뒤에만
| 분류 | 수 | route |
| --- | ---: | --- |
| durable journal | 13 | `betting.bet`; `inherit.checkOwner`; `messages.delete`, `messages.respond`, `messages.send`; `turns.repeatGeneral`, `turns.setGeneral`, `turns.setGeneralBulk`, `turns.shiftGeneral`; `vote.closePoll`, `vote.createPoll`, `vote.submitVote`, `vote.updatePoll` |
| durable journal | 19 | `betting.bet`; `diplomacy.destroyLetter`, `diplomacy.respondLetter`, `diplomacy.rollbackLetter`, `diplomacy.sendLetter`; `inherit.checkOwner`; `messages.delete`, `messages.respond`, `messages.send`; `turns.reserved.repeatGeneral`, `turns.reserved.setGeneral`, `turns.reserved.setGeneralBulk`, `turns.reserved.setNation`, `turns.reserved.setNationBulk`, `turns.reserved.shiftGeneral`; `vote.closePoll`, `vote.createPoll`, `vote.submitVote`, `vote.updatePoll` |
| separate access journal | 1 | `public.recordAccess` |
| explicit no realtime consumer | 14 | `board.writeArticle`, `board.writeComment`; `diplomacy.destroyLetter`, `diplomacy.respondLetter`, `diplomacy.rollbackLetter`, `diplomacy.sendLetter`; `join.getSelectionPool`, `join.listPossessCandidates`; `messages.readLatest`; `turns.repeatNation`, `turns.setNation`, `turns.setNationBulk`, `turns.shiftNation`; `vote.addComment` |
| engine owned | 37 | `auction.bidBuyRice`, `auction.bidSellRice`, `auction.bidUnique`, `auction.openBuyRice`, `auction.openSellRice`, `auction.openUnique`; `general.adjustIcon`, `general.buildNationCandidate`, `general.dieOnPrestart`, `general.dropItem`, `general.ensureDieOnPrestartStatus`, `general.instantRetreat`, `general.setMySetting`, `general.vacation`; `inherit.openUniqueAuction`; `join.createGeneral`, `join.possessGeneral`, `join.reselectPoolGeneral`, `join.selectPoolGeneral`; `nation.appoint`, `nation.changePermission`, `nation.kick`, `nation.setBill`, `nation.setBlockScout`, `nation.setBlockWar`, `nation.setNotice`, `nation.setRate`, `nation.setScoutMsg`, `nation.setSecretLimit`; `npc.setGeneralPriority`, `npc.setNationPolicy`, `npc.setNationPriority`; `troop.create`, `troop.exit`, `troop.join`, `troop.kick`, `troop.rename` |
| explicit no realtime consumer | 7 | `board.writeArticle`, `board.writeComment`; `join.listPossessCandidates`; `messages.readLatest`; `turns.reserved.repeatNation`, `turns.reserved.shiftNation`; `vote.addComment` |
| engine owned | 38 | `auction.bidBuyRice`, `auction.bidSellRice`, `auction.bidUnique`, `auction.openBuyRice`, `auction.openSellRice`, `auction.openUnique`; `general.adjustIcon`, `general.buildNationCandidate`, `general.dieOnPrestart`, `general.dropItem`, `general.ensureDieOnPrestartStatus`, `general.instantRetreat`, `general.setMySetting`, `general.vacation`; `inherit.openUniqueAuction`; `join.createGeneral`, `join.getSelectionPool`, `join.possessGeneral`, `join.reselectPoolGeneral`, `join.selectPoolGeneral`; `nation.appoint`, `nation.changePermission`, `nation.kick`, `nation.setBill`, `nation.setBlockScout`, `nation.setBlockWar`, `nation.setNotice`, `nation.setRate`, `nation.setScoutMsg`, `nation.setSecretLimit`; `npc.setGeneralPriority`, `npc.setNationPolicy`, `npc.setNationPriority`; `troop.create`, `troop.exit`, `troop.join`, `troop.kick`, `troop.rename` |
| mixed saga | 9 | `inherit.buyHiddenBuff`, `inherit.buyRandomUnique`, `inherit.resetSpecialWar`, `inherit.resetStat`, `inherit.resetTurnTime`, `inherit.setNextSpecialWar`; `tournament.cancel`, `tournament.join`, `tournament.placeBet` |
| Redis projection | 6 | `tournament.patchState`, `tournament.seedParticipants`, `tournament.setBettingEntries`, `tournament.setMatches`, `tournament.setParticipants`, `tournament.setState` |
| operational | 3 | `turnDaemon.pause`, `turnDaemon.resume`, `turnDaemon.run` |
@@ -52,10 +53,12 @@ writer reconciliation을 포함한다. rolling deployment가 끝난 뒤에만
| --- | --- | --- | --- |
| `betting.bet` | `general.content:<actor>`, `betting:0` | 없음 | 본인 베팅/유산 지출과 베팅 aggregate source가 바뀐다. `betting`은 현재 별도 화면 source이고 main dashboard fan-out을 만들지 않는다. |
| `inherit.checkOwner` | 확인자·확인 대상의 `messages.mailbox:<general>` | 두 장수 mailbox viewer에게 ID 없는 `messagesInvalidated` | Ref처럼 확인 결과와 피확인 알림을 시스템 개인 메시지로 저장하며 포인트 차감·유산 로그·두 메시지·journal을 한 API input-event transaction에서 commit한다. |
| 외교 문서 4개 | 양국의 `messages.mailbox:<9000+nation>` | 양국 mailbox viewer에게 ID 없는 `messagesInvalidated` | Ref의 문서 전송·승인/거부·회수·파기 알림을 외교 메시지로 저장하고, 응답은 같은 문구의 국가 메시지도 외교 메시지 뒤에 저장한다. 문서 상태·2/4개 메시지·journal을 한 API input-event transaction에서 commit한다. |
| `messages.send` | 생성된 수신/송신 복사본의 `messages.mailbox:<mailbox>` | 해당 mailbox viewer에게 ID 없는 `messagesInvalidated` | 기존 pre-commit Redis `messageCreated`를 제거했다. outbox publish 뒤에도 browser에는 mailbox/message/sender/time/revision이 노출되지 않는다. |
| `messages.delete` | 실제로 만료한 송신/수신 mailbox | 동일 | sender copy만 지우는 수동 외교 메시지는 그 mailbox만 표시한다. |
| `messages.respond` | 영향 mailbox, `records.general`, 실제 외교 변경 국가의 `nation.content`, front-state patch 도시의 `city.content`, 필요 시 `map.world`, transitive aggregate용 `dashboard.global` | mailbox boolean 및 해당 dashboard slice | 실패 로그도 commit되면 actor 개인 기록을 표시한다. 외교 수락이 실제 diplomacy/city/nation dependency를 바꿀 때만 broad source key를 표시한다. |
| general reserved turn 4개 | `reserved.general:<general>`, `dashboard.global:0` | 본인 reserved-turn slice | queue row와 CAS revision을 쓴 같은 API transaction에서 표시한다. global key는 troop leader 첫 예약턴에 의존하는 다른 장수 context를 위한 source-only 표식이다. nation reserved turns는 main SSE consumer가 없어 명시적 no-op이다. |
| general reserved turn 4개 | `reserved.general:<general>`, `dashboard.global:0` | 본인 reserved-turn slice | queue row와 CAS revision을 쓴 같은 API transaction에서 표시한다. global key는 troop leader 첫 예약턴에 의존하는 다른 장수 context를 위한 source-only 표식이다. |
| `turns.reserved.setNation`, `setNationBulk` | `general.content:<actor>`, `dashboard.global:0` | actor의 dashboard general slice | Ref가 성공한 사용자 수뇌 입력 때 `killturn`을 world 기본 이상으로 보충하는 side effect를 queue write와 같은 API transaction에 저장한다. repeat/shift는 이 side effect가 없어 no-op이다. |
| vote 4개 | 기존 `front.general`/`front.global` | front-status boolean | vote producer 작업에서 pre-commit publish를 journal로 이미 치환했다. 댓글은 active survey 제목을 바꾸지 않아 별도 화면 no-op이다. |
| `public.recordAccess` | `access.general:<general>` | 없음 | Ref 순서상 gameplay transaction 밖의 별도 access transaction에 저장한다. |
@@ -68,8 +71,9 @@ public dashboard event로 내보내지 않는다. browser wake-up은 정밀 enti
- 게시글/댓글은 현재 게시판 화면에서 사용자 action 뒤 직접 다시 읽으며 main SSE
listener가 없다. `board.*` domain을 임의로 추가하지 않는다.
- 외교 문서(`diplomacyLetter`)는 외교 문서 화면 전용이고 현재 SSE consumer가 없다.
전쟁/불가침 상태를 실제 변경하는 `messages.respond`와 구분한다.
- 외교 문서(`diplomacyLetter`) 자체는 외교 문서 화면 전용이고 현재 SSE consumer가 없다.
다만 Ref가 함께 쓰는 외교/국가 메시지는 양국 메시지 panel의 durable mailbox journal로
전달한다. 전쟁/불가침 상태를 실제 변경하는 `messages.respond`와도 구분한다.
- `messages.readLatest`는 본인의 읽음 cursor다. 요청한 tab이 이미 최신 cursor를 알고
있으므로 자기 자신에게 다시 wake-up을 보내지 않는다.
- nation reserved turn, selection-pool reservation과 possession 후보는 각각 전용
@@ -0,0 +1,88 @@
route owner_boundary ref_basis actor_source strongest_evidence evidence_path remaining_gap
auction.bidBuyRice engine-owned domain-command session-user-engine-general actual-db tools/integration-tests/test/auctionFlow.test.ts no-dynamic-ref
auction.bidSellRice engine-owned domain-command session-user-engine-general endpoint-unit app/game-api/test/auctionRouter.test.ts no-route-actual-db
auction.bidUnique engine-owned domain-command session-user-engine-general actual-db tools/integration-tests/test/auctionFlow.test.ts no-dynamic-ref
auction.openBuyRice engine-owned domain-command session-user-engine-general actual-db tools/integration-tests/test/auctionFlow.test.ts no-dynamic-ref
auction.openSellRice engine-owned domain-command session-user-engine-general endpoint-unit app/game-api/test/auctionRouter.test.ts no-route-actual-db
auction.openUnique engine-owned domain-command session-user-engine-general actual-db tools/integration-tests/test/auctionFlow.test.ts no-dynamic-ref
auth.exchangeGatewayToken session-only core-only gateway-token-user redis tools/integration-tests/test/orchestrator.e2e.test.ts no-ref-counterpart
battle.prepareSimulation read-only-mutation-transport read-only-transport session-user endpoint-unit app/game-api/test/battleSimRouter.test.ts no-route-worker-e2e
battle.simulate read-only-mutation-transport read-only-transport session-user endpoint-unit app/game-api/test/battleSimRouter.test.ts no-route-worker-e2e
betting.bet durable-journal direct-endpoint session-user-db-general actual-db app/game-api/test/nationBettingRouter.integration.test.ts no-dynamic-ref
board.uploadImage external-upload direct-endpoint session-user-db-general endpoint-unit app/game-api/test/boardRouter.test.ts no-real-store-e2e
board.writeArticle explicit-no-realtime-consumer direct-endpoint session-user-db-general endpoint-unit app/game-api/test/boardRouter.test.ts no-route-actual-db
board.writeComment explicit-no-realtime-consumer direct-endpoint session-user-db-general endpoint-unit app/game-api/test/boardRouter.test.ts no-route-actual-db
diplomacy.destroyLetter durable-journal direct-endpoint session-user-db-general actual-db app/game-api/test/diplomacyDocumentMessages.integration.test.ts no-dynamic-ref
diplomacy.respondLetter durable-journal direct-endpoint session-user-db-general actual-db app/game-api/test/diplomacyDocumentMessages.integration.test.ts no-dynamic-ref
diplomacy.rollbackLetter durable-journal direct-endpoint session-user-db-general actual-db app/game-api/test/diplomacyDocumentMessages.integration.test.ts no-dynamic-ref
diplomacy.sendLetter durable-journal direct-endpoint session-user-db-general actual-db app/game-api/test/diplomacyDocumentMessages.integration.test.ts no-dynamic-ref
general.adjustIcon engine-owned domain-command session-user-engine-general endpoint-unit app/game-api/test/router.test.ts no-route-actual-db
general.buildNationCandidate engine-owned domain-command session-user-engine-general endpoint-unit app/game-api/test/inGameMenuPermissions.test.ts no-route-actual-db
general.dieOnPrestart engine-owned domain-command session-user-engine-general endpoint-unit app/game-api/test/inGameMenuPermissions.test.ts no-route-actual-db
general.dropItem engine-owned domain-command session-user-engine-general endpoint-unit app/game-api/test/inGameMenuPermissions.test.ts no-route-actual-db
general.ensureDieOnPrestartStatus engine-owned domain-command session-user-engine-general endpoint-unit app/game-api/test/inGameMenuPermissions.test.ts no-route-actual-db
general.instantRetreat engine-owned domain-command session-user-engine-general endpoint-unit app/game-api/test/inGameMenuPermissions.test.ts no-route-actual-db
general.setMySetting engine-owned domain-command session-user-engine-general endpoint-unit app/game-api/test/inGameMenuPermissions.test.ts no-route-actual-db
general.vacation engine-owned domain-command session-user-engine-general endpoint-unit app/game-api/test/inGameMenuPermissions.test.ts no-route-actual-db
inherit.buyHiddenBuff mixed-saga domain-command session-user-engine-general endpoint-unit app/game-api/test/inheritRouter.test.ts no-route-actual-db
inherit.buyRandomUnique mixed-saga domain-command session-user-engine-general endpoint-unit app/game-api/test/inheritRouter.test.ts no-route-actual-db
inherit.checkOwner durable-journal domain-command session-user-engine-general actual-db app/game-api/test/inheritOwnerMessages.integration.test.ts no-dynamic-ref
inherit.openUniqueAuction engine-owned domain-command session-user-engine-general actual-db tools/integration-tests/test/auctionFlow.test.ts no-dynamic-ref
inherit.resetSpecialWar mixed-saga domain-command session-user-engine-general endpoint-unit app/game-api/test/inheritRouter.test.ts no-route-actual-db
inherit.resetStat mixed-saga domain-command session-user-engine-general endpoint-unit app/game-api/test/inheritRouter.test.ts no-route-actual-db
inherit.resetTurnTime mixed-saga domain-command session-user-engine-general endpoint-unit app/game-api/test/inheritRouter.test.ts no-route-actual-db
inherit.setNextSpecialWar mixed-saga domain-command session-user-engine-general endpoint-unit app/game-api/test/inheritRouter.test.ts no-route-actual-db
join.createGeneral engine-owned direct-endpoint session-user actual-db app/game-api/test/createGeneral.integration.test.ts no-dynamic-ref
join.getSelectionPool engine-owned direct-endpoint session-user actual-db app/game-api/test/selectPool.integration.test.ts no-dynamic-ref
join.listPossessCandidates explicit-no-realtime-consumer direct-endpoint session-user actual-db app/game-api/test/npcPossession.integration.test.ts no-dynamic-ref
join.possessGeneral engine-owned direct-endpoint session-user actual-db app/game-api/test/npcPossession.integration.test.ts no-dynamic-ref
join.reselectPoolGeneral engine-owned direct-endpoint session-user actual-db app/game-api/test/selectPool.integration.test.ts no-dynamic-ref
join.selectPoolGeneral engine-owned direct-endpoint session-user actual-db app/game-api/test/selectPool.integration.test.ts no-dynamic-ref
messages.delete durable-journal direct-endpoint session-user-db-general endpoint-unit app/game-api/test/messagesRouter.test.ts no-route-actual-db
messages.readLatest explicit-no-realtime-consumer direct-endpoint session-user-db-general endpoint-unit app/game-api/test/messagesRouter.test.ts no-route-actual-db
messages.respond durable-journal direct-endpoint session-user-db-general dynamic-ref tools/integration-tests/test/instantDiplomacyCoreReference.integration.test.ts no-route-actual-db
messages.send durable-journal direct-endpoint session-user-db-general endpoint-unit app/game-api/test/messagesRouter.test.ts no-route-actual-db
nation.appoint engine-owned domain-command session-user-engine-general endpoint-unit app/game-api/test/nationPersonnelRouter.test.ts no-route-actual-db
nation.changePermission engine-owned domain-command session-user-engine-general endpoint-unit app/game-api/test/nationPersonnelRouter.test.ts no-route-actual-db
nation.kick engine-owned domain-command session-user-engine-general endpoint-unit app/game-api/test/nationPersonnelRouter.test.ts no-route-actual-db
nation.setBill engine-owned domain-command session-user-engine-general endpoint-unit app/game-api/test/nationSettingRouter.test.ts no-route-actual-db
nation.setBlockScout engine-owned domain-command session-user-engine-general endpoint-unit app/game-api/test/scoutBlockRouter.test.ts no-route-actual-db
nation.setBlockWar engine-owned domain-command session-user-engine-general endpoint-unit app/game-api/test/nationSettingRouter.test.ts no-route-actual-db
nation.setNotice engine-owned domain-command session-user-engine-general endpoint-unit app/game-api/test/nationSettingRouter.test.ts no-route-actual-db
nation.setRate engine-owned domain-command session-user-engine-general endpoint-unit app/game-api/test/nationSettingRouter.test.ts no-route-actual-db
nation.setScoutMsg engine-owned domain-command session-user-engine-general endpoint-unit app/game-api/test/nationSettingRouter.test.ts no-route-actual-db
nation.setSecretLimit engine-owned domain-command session-user-engine-general endpoint-unit app/game-api/test/nationSettingRouter.test.ts no-route-actual-db
npc.setGeneralPriority engine-owned domain-command session-user-engine-general endpoint-unit app/game-api/test/npcPolicyRouter.test.ts no-route-actual-db
npc.setNationPolicy engine-owned domain-command session-user-engine-general endpoint-unit app/game-api/test/npcPolicyRouter.test.ts no-route-actual-db
npc.setNationPriority engine-owned domain-command session-user-engine-general endpoint-unit app/game-api/test/npcPolicyRouter.test.ts no-route-actual-db
public.recordAccess separate-access-journal direct-endpoint optional-session-db-general endpoint-unit app/game-api/test/publicRecordAccessRouter.test.ts no-route-actual-db
tournament.cancel mixed-saga core-only session-admin-role endpoint-unit app/game-api/test/tournamentRouter.test.ts db-redis-not-atomic
tournament.join mixed-saga direct-endpoint session-user-db-general redis tools/integration-tests/test/tournamentLifecycle.test.ts db-redis-not-atomic
tournament.patchState redis-projection core-only session-admin-role endpoint-unit app/game-api/test/tournamentRouter.test.ts no-route-real-redis
tournament.placeBet mixed-saga direct-endpoint session-user-db-general redis tools/integration-tests/test/tournamentLifecycle.test.ts db-redis-not-atomic
tournament.seedParticipants redis-projection core-only session-admin-role endpoint-unit app/game-api/test/tournamentRouter.test.ts no-route-real-redis
tournament.setBettingEntries redis-projection core-only session-admin-role endpoint-unit app/game-api/test/tournamentRouter.test.ts no-route-real-redis
tournament.setMatches redis-projection core-only session-admin-role endpoint-unit app/game-api/test/tournamentRouter.test.ts no-route-real-redis
tournament.setParticipants redis-projection core-only session-admin-role endpoint-unit app/game-api/test/tournamentRouter.test.ts no-route-real-redis
tournament.setState redis-projection core-only session-admin-role endpoint-unit app/game-api/test/tournamentRouter.test.ts no-route-real-redis
troop.create engine-owned domain-command session-user-engine-general endpoint-unit app/game-api/test/troopRouter.test.ts no-route-actual-db
troop.exit engine-owned domain-command session-user-engine-general endpoint-unit app/game-api/test/troopRouter.test.ts no-route-actual-db
troop.join engine-owned domain-command session-user-engine-general endpoint-unit app/game-api/test/troopRouter.test.ts no-route-actual-db
troop.kick engine-owned domain-command session-user-engine-general endpoint-unit app/game-api/test/troopRouter.test.ts no-route-actual-db
troop.rename engine-owned domain-command session-user-engine-general endpoint-unit app/game-api/test/troopRouter.test.ts no-route-actual-db
turnDaemon.pause operational core-only session-admin-role endpoint-unit app/game-api/test/router.test.ts no-runtime-e2e
turnDaemon.resume operational core-only session-admin-role endpoint-unit app/game-api/test/router.test.ts no-runtime-e2e
turnDaemon.run operational core-only session-admin-role actual-db tools/integration-tests/test/orchestrator.e2e.test.ts no-ref-counterpart
turns.reserved.repeatGeneral durable-journal direct-endpoint session-user-db-general endpoint-unit app/game-api/test/router.test.ts no-route-actual-db
turns.reserved.repeatNation explicit-no-realtime-consumer direct-endpoint session-user-db-general endpoint-unit app/game-api/test/router.test.ts no-route-actual-db
turns.reserved.setGeneral durable-journal direct-endpoint session-user-db-general actual-db tools/integration-tests/test/initialization.test.ts no-dynamic-ref
turns.reserved.setGeneralBulk durable-journal direct-endpoint session-user-db-general endpoint-unit app/game-api/test/router.test.ts no-route-actual-db
turns.reserved.setNation durable-journal direct-endpoint session-user-db-general endpoint-unit app/game-api/test/router.test.ts no-route-actual-db
turns.reserved.setNationBulk durable-journal direct-endpoint session-user-db-general endpoint-unit app/game-api/test/router.test.ts no-route-actual-db
turns.reserved.shiftGeneral durable-journal direct-endpoint session-user-db-general endpoint-unit app/game-api/test/router.test.ts no-route-actual-db
turns.reserved.shiftNation explicit-no-realtime-consumer direct-endpoint session-user-db-general endpoint-unit app/game-api/test/router.test.ts no-route-actual-db
vote.addComment explicit-no-realtime-consumer direct-endpoint session-user-db-general actual-db app/game-api/test/voteCommentTimestamp.integration.test.ts no-dynamic-ref
vote.closePoll durable-journal direct-endpoint session-user-db-general endpoint-unit app/game-api/test/voteRouter.test.ts no-route-actual-db
vote.createPoll durable-journal direct-endpoint session-user-db-general endpoint-unit app/game-api/test/voteRouter.test.ts no-route-actual-db
vote.submitVote durable-journal direct-endpoint session-user-db-general endpoint-unit app/game-api/test/voteRouter.test.ts no-route-actual-db
vote.updatePoll durable-journal direct-endpoint session-user-db-general endpoint-unit app/game-api/test/voteRouter.test.ts no-route-actual-db
1 route owner_boundary ref_basis actor_source strongest_evidence evidence_path remaining_gap
2 auction.bidBuyRice engine-owned domain-command session-user-engine-general actual-db tools/integration-tests/test/auctionFlow.test.ts no-dynamic-ref
3 auction.bidSellRice engine-owned domain-command session-user-engine-general endpoint-unit app/game-api/test/auctionRouter.test.ts no-route-actual-db
4 auction.bidUnique engine-owned domain-command session-user-engine-general actual-db tools/integration-tests/test/auctionFlow.test.ts no-dynamic-ref
5 auction.openBuyRice engine-owned domain-command session-user-engine-general actual-db tools/integration-tests/test/auctionFlow.test.ts no-dynamic-ref
6 auction.openSellRice engine-owned domain-command session-user-engine-general endpoint-unit app/game-api/test/auctionRouter.test.ts no-route-actual-db
7 auction.openUnique engine-owned domain-command session-user-engine-general actual-db tools/integration-tests/test/auctionFlow.test.ts no-dynamic-ref
8 auth.exchangeGatewayToken session-only core-only gateway-token-user redis tools/integration-tests/test/orchestrator.e2e.test.ts no-ref-counterpart
9 battle.prepareSimulation read-only-mutation-transport read-only-transport session-user endpoint-unit app/game-api/test/battleSimRouter.test.ts no-route-worker-e2e
10 battle.simulate read-only-mutation-transport read-only-transport session-user endpoint-unit app/game-api/test/battleSimRouter.test.ts no-route-worker-e2e
11 betting.bet durable-journal direct-endpoint session-user-db-general actual-db app/game-api/test/nationBettingRouter.integration.test.ts no-dynamic-ref
12 board.uploadImage external-upload direct-endpoint session-user-db-general endpoint-unit app/game-api/test/boardRouter.test.ts no-real-store-e2e
13 board.writeArticle explicit-no-realtime-consumer direct-endpoint session-user-db-general endpoint-unit app/game-api/test/boardRouter.test.ts no-route-actual-db
14 board.writeComment explicit-no-realtime-consumer direct-endpoint session-user-db-general endpoint-unit app/game-api/test/boardRouter.test.ts no-route-actual-db
15 diplomacy.destroyLetter durable-journal direct-endpoint session-user-db-general actual-db app/game-api/test/diplomacyDocumentMessages.integration.test.ts no-dynamic-ref
16 diplomacy.respondLetter durable-journal direct-endpoint session-user-db-general actual-db app/game-api/test/diplomacyDocumentMessages.integration.test.ts no-dynamic-ref
17 diplomacy.rollbackLetter durable-journal direct-endpoint session-user-db-general actual-db app/game-api/test/diplomacyDocumentMessages.integration.test.ts no-dynamic-ref
18 diplomacy.sendLetter durable-journal direct-endpoint session-user-db-general actual-db app/game-api/test/diplomacyDocumentMessages.integration.test.ts no-dynamic-ref
19 general.adjustIcon engine-owned domain-command session-user-engine-general endpoint-unit app/game-api/test/router.test.ts no-route-actual-db
20 general.buildNationCandidate engine-owned domain-command session-user-engine-general endpoint-unit app/game-api/test/inGameMenuPermissions.test.ts no-route-actual-db
21 general.dieOnPrestart engine-owned domain-command session-user-engine-general endpoint-unit app/game-api/test/inGameMenuPermissions.test.ts no-route-actual-db
22 general.dropItem engine-owned domain-command session-user-engine-general endpoint-unit app/game-api/test/inGameMenuPermissions.test.ts no-route-actual-db
23 general.ensureDieOnPrestartStatus engine-owned domain-command session-user-engine-general endpoint-unit app/game-api/test/inGameMenuPermissions.test.ts no-route-actual-db
24 general.instantRetreat engine-owned domain-command session-user-engine-general endpoint-unit app/game-api/test/inGameMenuPermissions.test.ts no-route-actual-db
25 general.setMySetting engine-owned domain-command session-user-engine-general endpoint-unit app/game-api/test/inGameMenuPermissions.test.ts no-route-actual-db
26 general.vacation engine-owned domain-command session-user-engine-general endpoint-unit app/game-api/test/inGameMenuPermissions.test.ts no-route-actual-db
27 inherit.buyHiddenBuff mixed-saga domain-command session-user-engine-general endpoint-unit app/game-api/test/inheritRouter.test.ts no-route-actual-db
28 inherit.buyRandomUnique mixed-saga domain-command session-user-engine-general endpoint-unit app/game-api/test/inheritRouter.test.ts no-route-actual-db
29 inherit.checkOwner durable-journal domain-command session-user-engine-general actual-db app/game-api/test/inheritOwnerMessages.integration.test.ts no-dynamic-ref
30 inherit.openUniqueAuction engine-owned domain-command session-user-engine-general actual-db tools/integration-tests/test/auctionFlow.test.ts no-dynamic-ref
31 inherit.resetSpecialWar mixed-saga domain-command session-user-engine-general endpoint-unit app/game-api/test/inheritRouter.test.ts no-route-actual-db
32 inherit.resetStat mixed-saga domain-command session-user-engine-general endpoint-unit app/game-api/test/inheritRouter.test.ts no-route-actual-db
33 inherit.resetTurnTime mixed-saga domain-command session-user-engine-general endpoint-unit app/game-api/test/inheritRouter.test.ts no-route-actual-db
34 inherit.setNextSpecialWar mixed-saga domain-command session-user-engine-general endpoint-unit app/game-api/test/inheritRouter.test.ts no-route-actual-db
35 join.createGeneral engine-owned direct-endpoint session-user actual-db app/game-api/test/createGeneral.integration.test.ts no-dynamic-ref
36 join.getSelectionPool engine-owned direct-endpoint session-user actual-db app/game-api/test/selectPool.integration.test.ts no-dynamic-ref
37 join.listPossessCandidates explicit-no-realtime-consumer direct-endpoint session-user actual-db app/game-api/test/npcPossession.integration.test.ts no-dynamic-ref
38 join.possessGeneral engine-owned direct-endpoint session-user actual-db app/game-api/test/npcPossession.integration.test.ts no-dynamic-ref
39 join.reselectPoolGeneral engine-owned direct-endpoint session-user actual-db app/game-api/test/selectPool.integration.test.ts no-dynamic-ref
40 join.selectPoolGeneral engine-owned direct-endpoint session-user actual-db app/game-api/test/selectPool.integration.test.ts no-dynamic-ref
41 messages.delete durable-journal direct-endpoint session-user-db-general endpoint-unit app/game-api/test/messagesRouter.test.ts no-route-actual-db
42 messages.readLatest explicit-no-realtime-consumer direct-endpoint session-user-db-general endpoint-unit app/game-api/test/messagesRouter.test.ts no-route-actual-db
43 messages.respond durable-journal direct-endpoint session-user-db-general dynamic-ref tools/integration-tests/test/instantDiplomacyCoreReference.integration.test.ts no-route-actual-db
44 messages.send durable-journal direct-endpoint session-user-db-general endpoint-unit app/game-api/test/messagesRouter.test.ts no-route-actual-db
45 nation.appoint engine-owned domain-command session-user-engine-general endpoint-unit app/game-api/test/nationPersonnelRouter.test.ts no-route-actual-db
46 nation.changePermission engine-owned domain-command session-user-engine-general endpoint-unit app/game-api/test/nationPersonnelRouter.test.ts no-route-actual-db
47 nation.kick engine-owned domain-command session-user-engine-general endpoint-unit app/game-api/test/nationPersonnelRouter.test.ts no-route-actual-db
48 nation.setBill engine-owned domain-command session-user-engine-general endpoint-unit app/game-api/test/nationSettingRouter.test.ts no-route-actual-db
49 nation.setBlockScout engine-owned domain-command session-user-engine-general endpoint-unit app/game-api/test/scoutBlockRouter.test.ts no-route-actual-db
50 nation.setBlockWar engine-owned domain-command session-user-engine-general endpoint-unit app/game-api/test/nationSettingRouter.test.ts no-route-actual-db
51 nation.setNotice engine-owned domain-command session-user-engine-general endpoint-unit app/game-api/test/nationSettingRouter.test.ts no-route-actual-db
52 nation.setRate engine-owned domain-command session-user-engine-general endpoint-unit app/game-api/test/nationSettingRouter.test.ts no-route-actual-db
53 nation.setScoutMsg engine-owned domain-command session-user-engine-general endpoint-unit app/game-api/test/nationSettingRouter.test.ts no-route-actual-db
54 nation.setSecretLimit engine-owned domain-command session-user-engine-general endpoint-unit app/game-api/test/nationSettingRouter.test.ts no-route-actual-db
55 npc.setGeneralPriority engine-owned domain-command session-user-engine-general endpoint-unit app/game-api/test/npcPolicyRouter.test.ts no-route-actual-db
56 npc.setNationPolicy engine-owned domain-command session-user-engine-general endpoint-unit app/game-api/test/npcPolicyRouter.test.ts no-route-actual-db
57 npc.setNationPriority engine-owned domain-command session-user-engine-general endpoint-unit app/game-api/test/npcPolicyRouter.test.ts no-route-actual-db
58 public.recordAccess separate-access-journal direct-endpoint optional-session-db-general endpoint-unit app/game-api/test/publicRecordAccessRouter.test.ts no-route-actual-db
59 tournament.cancel mixed-saga core-only session-admin-role endpoint-unit app/game-api/test/tournamentRouter.test.ts db-redis-not-atomic
60 tournament.join mixed-saga direct-endpoint session-user-db-general redis tools/integration-tests/test/tournamentLifecycle.test.ts db-redis-not-atomic
61 tournament.patchState redis-projection core-only session-admin-role endpoint-unit app/game-api/test/tournamentRouter.test.ts no-route-real-redis
62 tournament.placeBet mixed-saga direct-endpoint session-user-db-general redis tools/integration-tests/test/tournamentLifecycle.test.ts db-redis-not-atomic
63 tournament.seedParticipants redis-projection core-only session-admin-role endpoint-unit app/game-api/test/tournamentRouter.test.ts no-route-real-redis
64 tournament.setBettingEntries redis-projection core-only session-admin-role endpoint-unit app/game-api/test/tournamentRouter.test.ts no-route-real-redis
65 tournament.setMatches redis-projection core-only session-admin-role endpoint-unit app/game-api/test/tournamentRouter.test.ts no-route-real-redis
66 tournament.setParticipants redis-projection core-only session-admin-role endpoint-unit app/game-api/test/tournamentRouter.test.ts no-route-real-redis
67 tournament.setState redis-projection core-only session-admin-role endpoint-unit app/game-api/test/tournamentRouter.test.ts no-route-real-redis
68 troop.create engine-owned domain-command session-user-engine-general endpoint-unit app/game-api/test/troopRouter.test.ts no-route-actual-db
69 troop.exit engine-owned domain-command session-user-engine-general endpoint-unit app/game-api/test/troopRouter.test.ts no-route-actual-db
70 troop.join engine-owned domain-command session-user-engine-general endpoint-unit app/game-api/test/troopRouter.test.ts no-route-actual-db
71 troop.kick engine-owned domain-command session-user-engine-general endpoint-unit app/game-api/test/troopRouter.test.ts no-route-actual-db
72 troop.rename engine-owned domain-command session-user-engine-general endpoint-unit app/game-api/test/troopRouter.test.ts no-route-actual-db
73 turnDaemon.pause operational core-only session-admin-role endpoint-unit app/game-api/test/router.test.ts no-runtime-e2e
74 turnDaemon.resume operational core-only session-admin-role endpoint-unit app/game-api/test/router.test.ts no-runtime-e2e
75 turnDaemon.run operational core-only session-admin-role actual-db tools/integration-tests/test/orchestrator.e2e.test.ts no-ref-counterpart
76 turns.reserved.repeatGeneral durable-journal direct-endpoint session-user-db-general endpoint-unit app/game-api/test/router.test.ts no-route-actual-db
77 turns.reserved.repeatNation explicit-no-realtime-consumer direct-endpoint session-user-db-general endpoint-unit app/game-api/test/router.test.ts no-route-actual-db
78 turns.reserved.setGeneral durable-journal direct-endpoint session-user-db-general actual-db tools/integration-tests/test/initialization.test.ts no-dynamic-ref
79 turns.reserved.setGeneralBulk durable-journal direct-endpoint session-user-db-general endpoint-unit app/game-api/test/router.test.ts no-route-actual-db
80 turns.reserved.setNation durable-journal direct-endpoint session-user-db-general endpoint-unit app/game-api/test/router.test.ts no-route-actual-db
81 turns.reserved.setNationBulk durable-journal direct-endpoint session-user-db-general endpoint-unit app/game-api/test/router.test.ts no-route-actual-db
82 turns.reserved.shiftGeneral durable-journal direct-endpoint session-user-db-general endpoint-unit app/game-api/test/router.test.ts no-route-actual-db
83 turns.reserved.shiftNation explicit-no-realtime-consumer direct-endpoint session-user-db-general endpoint-unit app/game-api/test/router.test.ts no-route-actual-db
84 vote.addComment explicit-no-realtime-consumer direct-endpoint session-user-db-general actual-db app/game-api/test/voteCommentTimestamp.integration.test.ts no-dynamic-ref
85 vote.closePoll durable-journal direct-endpoint session-user-db-general endpoint-unit app/game-api/test/voteRouter.test.ts no-route-actual-db
86 vote.createPoll durable-journal direct-endpoint session-user-db-general endpoint-unit app/game-api/test/voteRouter.test.ts no-route-actual-db
87 vote.submitVote durable-journal direct-endpoint session-user-db-general endpoint-unit app/game-api/test/voteRouter.test.ts no-route-actual-db
88 vote.updatePoll durable-journal direct-endpoint session-user-db-general endpoint-unit app/game-api/test/voteRouter.test.ts no-route-actual-db
+1 -1
View File
@@ -254,7 +254,7 @@ transaction commit 뒤에는 dispatcher wake-up만 시도한다.
설문의 pre-commit Redis publish는 actor/global front-status journal mark로 바꿨다.
메시지도 pre-commit `messageCreated`를 제거하고 mailbox outbox 전달 뒤 viewer-safe
`messagesInvalidated`만 공개한다. 국가 설정, 베팅, 외교 응답과 장수 예약명령 direct writer는
86개 mutation inventory test가 등록/명시적 비대상 분류를 고정한다.
실제 `appRouter`에 mount된 87개 mutation inventory test가 등록/명시적 비대상 분류를 고정한다.
현재 일부 `authedProcedure`/`accessAuthedProcedure` mutation은 API interactive
transaction을 잡은 채 `turnDaemon.requestCommand()`의 별도 ENGINE transaction 완료를
@@ -104,6 +104,12 @@ DB auto ID, 생성 시각처럼 의미 없는 차이는 comparator에서 이름
명시합니다. 의미 field를 ignore하거나 숫자 허용 범위를 넓혀 mismatch를
숨기지 않습니다.
Core가 의도적으로 full precision을 유지하는 `nation.tech``city.trust`를 Ref의
MariaDB `FLOAT` snapshot과 비교할 때는 raw 차이 경로를 fixture별로 먼저 고정합니다.
그 뒤 test-only binary32 저장·6자리 읽기 projection을 Core의 절대 before/after 값에
적용해 Ref delta와 정확히 일치하는지 다시 검사합니다. 제품 상태를 양자화하거나
전역 tolerance·전역 ignore로 다른 차이를 숨기지 않습니다.
JSON missing·`{}`·`[]`는 snapshot 원형에서 구분합니다. 일반 message option 부재의
Ref `[]`와 Core `{}`만 의미상 같게 보며, actionable diplomacy의 `option=null` sentinel은
부재로 합치지 않습니다. Ref log prefix와 Core format은 독립적으로 해석하고, 서로 다른
+13
View File
@@ -84,6 +84,19 @@ runner는 test 시작 전에 실패합니다. 지원 mode에 marker가 하나도
실행 group의 marker 정규식이 비어도 전체 파일로 선택 범위를 넓히지 않고
실패합니다.
DB marker가 없는 Ref 조건부 suite와 저장 trace 비교 suite는
`tools/conditional-integration-file-registry.tsv`에서 파일별 환경 요구를 관리합니다.
Runner는 이 registry를 실제 `describe.skipIf()` gate와 exact-set으로 대조합니다.
`TURN_DIFFERENTIAL_REFERENCE=1`일 때만 Ref runtime 그룹을 실행하고, 저장 trace 비교는
`TURN_REFERENCE_TRACE``TURN_CORE_TRACE`가 모두 있을 때만 실행합니다. 한 trace만
주입한 경우에는 suite를 조용히 skip하지 않고 설정 오류로 실패합니다. DB/Redis marker가
있는 파일을 이 registry에 중복 등록할 수도 없습니다.
조건부 실행 결과는 Vitest의 file status만 세지 않습니다. 한 파일 안의 guard test가
통과해 file status가 `passed`여도 실제 조건부 assertion 하나가 `skipped`, `pending`,
`todo` 또는 `disabled`라면 runner가 실패합니다. 따라서 실행 대상으로 등록한 기능 test가
일부라도 조용히 생략된 mixed file을 0-skip 결과로 보고할 수 없습니다.
`external_fixture` mode는 격리 빈 schema로 만들 수 없는 명시적 예외입니다.
현재 `CURRENT_SEASON_FIXTURE_DATABASE_URL`은 별도 Ref 현 시즌 importer가 만든
read-only snapshot을 요구하므로 조건부 runner의 pass/skip 집계에 포함하지
+1
View File
@@ -13,6 +13,7 @@
"test:prepare": "pnpm --filter @sammo-ts/infra prisma:generate && pnpm --filter @sammo-ts/common build && pnpm --filter @sammo-ts/logic build && pnpm --filter @sammo-ts/infra build && pnpm --filter @sammo-ts/game-engine build && pnpm --filter @sammo-ts/game-api build && pnpm --filter @sammo-ts/gateway-api build",
"test:integration": "pnpm --filter @sammo-ts/integration-tests test:integration",
"test:integration:conditional": "./tools/run-conditional-integration.sh",
"test:conditional-integration-registry": "node --test tools/check-conditional-integration-files.test.mjs",
"build": "turbo build",
"typecheck": "turbo typecheck",
"tsc7": "node_modules/@typescript/native/bin/tsc",
@@ -238,6 +238,7 @@ export class ActionDefinition<
{
scope: LogScope.SYSTEM,
category: LogCategory.HISTORY,
format: LogFormat.YEAR_MONTH,
}
);
}
@@ -225,6 +225,10 @@ describe('typed item lifecycle events', () => {
format: LogFormat.MONTH,
},
]);
expect(logs.at(-1)).toMatchObject({
message: expect.stringContaining('【판매】'),
format: LogFormat.YEAR_MONTH,
});
expect(outcome.effects).toContainEqual(
expect.objectContaining({
type: 'general:patch',
@@ -0,0 +1,178 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const scriptDirectory = path.dirname(fileURLToPath(import.meta.url));
const defaultWorkspaceRoot = path.resolve(scriptDirectory, '..');
const defaultRegistryPath = path.join(scriptDirectory, 'conditional-integration-file-registry.tsv');
export const supportedRequirements = new Set([
'reference_command',
'reference_full_lifecycle',
'reference_instant_diplomacy',
'reference_monthly',
'reference_snapshot',
'saved_trace_pair',
]);
const databaseMarkerPattern = /process\.env\.[A-Z0-9_]+_DATABASE_URL/u;
const redisMarkerPattern = /process\.env\.REDIS_URL/u;
const integrationFilePattern = /^test\/[A-Za-z0-9._/-]+\.integration\.test\.ts$/u;
export const parseConditionalIntegrationFileRegistry = (source) => {
const entries = [];
const seen = new Set();
for (const [index, rawLine] of source.split(/\r?\n/u).entries()) {
const line = rawLine.trim();
if (line === '' || line.startsWith('#')) continue;
const fields = rawLine.split('\t');
if (fields.length !== 2) {
throw new Error(`line ${index + 1} must contain exactly one tab-separated file and requirement`);
}
const [file, requirement] = fields.map((field) => field.trim());
if (!integrationFilePattern.test(file)) {
throw new Error(`line ${index + 1} has an invalid integration test path: ${file}`);
}
if (!supportedRequirements.has(requirement)) {
throw new Error(`line ${index + 1} has an unsupported environment requirement: ${requirement}`);
}
if (seen.has(file)) {
throw new Error(`line ${index + 1} duplicates integration test path: ${file}`);
}
seen.add(file);
entries.push({ file, requirement });
}
return entries;
};
const listIntegrationSources = async (testDirectory) => {
const sources = [];
const visit = async (directory) => {
for (const entry of await fs.readdir(directory, { withFileTypes: true })) {
const absolute = path.join(directory, entry.name);
if (entry.isDirectory()) {
await visit(absolute);
} else if (entry.isFile() && entry.name.endsWith('.integration.test.ts')) {
sources.push({
file: path.relative(path.dirname(testDirectory), absolute).split(path.sep).join('/'),
source: await fs.readFile(absolute, 'utf8'),
});
}
}
};
await visit(testDirectory);
return sources.sort((left, right) => left.file.localeCompare(right.file));
};
const isNonDatabaseConditionalReference = (source) => {
if (!source.includes('describe.skipIf')) return false;
if (databaseMarkerPattern.test(source) || redisMarkerPattern.test(source)) return false;
return (
source.includes('TURN_DIFFERENTIAL_REFERENCE') ||
(source.includes('TURN_REFERENCE_TRACE') && source.includes('TURN_CORE_TRACE'))
);
};
const validateRequirementEvidence = ({ file, requirement, source }) => {
const errors = [];
const requireToken = (token) => {
if (!source.includes(token)) errors.push(`${file}: ${requirement} requires source token ${token}`);
};
if (requirement.startsWith('reference_')) {
requireToken('TURN_DIFFERENTIAL_REFERENCE');
}
switch (requirement) {
case 'reference_command':
requireToken('runReferenceTurnCommandTrace');
break;
case 'reference_full_lifecycle':
requireToken('turn_full_lifecycle_trace.php');
break;
case 'reference_instant_diplomacy':
requireToken('instant_diplomacy_response_trace.php');
break;
case 'reference_monthly':
requireToken('monthly_event_trace.php');
break;
case 'reference_snapshot':
requireToken('readReferenceDatabaseSnapshot');
break;
case 'saved_trace_pair':
requireToken('TURN_REFERENCE_TRACE');
requireToken('TURN_CORE_TRACE');
break;
}
return errors;
};
export const validateConditionalIntegrationFileRegistry = async ({
workspaceRoot = defaultWorkspaceRoot,
registryPath = defaultRegistryPath,
} = {}) => {
const packageRoot = path.join(workspaceRoot, 'tools/integration-tests');
const testDirectory = path.join(packageRoot, 'test');
const registrySource = await fs.readFile(registryPath, 'utf8');
const entries = parseConditionalIntegrationFileRegistry(registrySource);
const sources = await listIntegrationSources(testDirectory);
const sourceByFile = new Map(sources.map((entry) => [entry.file, entry.source]));
const discovered = sources
.filter(({ source }) => isNonDatabaseConditionalReference(source))
.map(({ file }) => file);
const registered = entries.map(({ file }) => file);
const errors = [];
const missing = discovered.filter((file) => !registered.includes(file));
const stale = registered.filter((file) => !discovered.includes(file));
if (missing.length > 0) errors.push(`unregistered non-database conditional suite(s): ${missing.join(', ')}`);
if (stale.length > 0) errors.push(`stale non-database conditional suite(s): ${stale.join(', ')}`);
for (const entry of entries) {
const source = sourceByFile.get(entry.file);
if (source === undefined) continue;
if (databaseMarkerPattern.test(source) || redisMarkerPattern.test(source)) {
errors.push(`${entry.file}: file registry overlaps a database/Redis marker suite`);
}
errors.push(...validateRequirementEvidence({ ...entry, source }));
}
if (errors.length > 0) {
throw new Error(
`invalid conditional integration file registry:\n${errors.map((error) => ` ${error}`).join('\n')}`
);
}
return entries;
};
export const selectEnabledConditionalIntegrationFiles = (entries, environment = process.env) => {
const hasReferenceTrace = Boolean(environment.TURN_REFERENCE_TRACE);
const hasCoreTrace = Boolean(environment.TURN_CORE_TRACE);
if (hasReferenceTrace !== hasCoreTrace) {
throw new Error('TURN_REFERENCE_TRACE and TURN_CORE_TRACE must be provided together');
}
return {
referenceFiles:
environment.TURN_DIFFERENTIAL_REFERENCE === '1'
? entries.filter(({ requirement }) => requirement.startsWith('reference_')).map(({ file }) => file)
: [],
savedTraceFiles:
hasReferenceTrace && hasCoreTrace
? entries.filter(({ requirement }) => requirement === 'saved_trace_pair').map(({ file }) => file)
: [],
};
};
const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
if (isMain) {
try {
const entries = await validateConditionalIntegrationFileRegistry();
selectEnabledConditionalIntegrationFiles(entries);
process.stdout.write(`conditional integration file registry is valid (${entries.length} files)\n`);
} catch (error) {
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
process.exitCode = 1;
}
}
@@ -0,0 +1,65 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
parseConditionalIntegrationFileRegistry,
selectEnabledConditionalIntegrationFiles,
validateConditionalIntegrationFileRegistry,
} from './check-conditional-integration-files.mjs';
test('keeps every non-database conditional Ref suite in one requirement group', async () => {
const entries = await validateConditionalIntegrationFileRegistry();
const counts = Object.fromEntries(
[...new Set(entries.map(({ requirement }) => requirement))]
.sort()
.map((requirement) => [requirement, entries.filter((entry) => entry.requirement === requirement).length])
);
assert.deepEqual(counts, {
reference_command: 5,
reference_full_lifecycle: 1,
reference_instant_diplomacy: 1,
reference_monthly: 1,
reference_snapshot: 1,
saved_trace_pair: 1,
});
});
test('rejects duplicate files and unsupported requirements', () => {
assert.throws(
() =>
parseConditionalIntegrationFileRegistry(
'test/example.integration.test.ts\treference_command\n' +
'test/example.integration.test.ts\treference_command\n'
),
/duplicates integration test path/u
);
assert.throws(
() => parseConditionalIntegrationFileRegistry('test/example.integration.test.ts\tunknown\n'),
/unsupported environment requirement/u
);
});
test('enables Ref suites only with the Ref runtime and saved traces only as a pair', async () => {
const entries = await validateConditionalIntegrationFileRegistry();
assert.deepEqual(selectEnabledConditionalIntegrationFiles(entries, {}), {
referenceFiles: [],
savedTraceFiles: [],
});
assert.equal(
selectEnabledConditionalIntegrationFiles(entries, { TURN_DIFFERENTIAL_REFERENCE: '1' }).referenceFiles.length,
9
);
assert.deepEqual(
selectEnabledConditionalIntegrationFiles(entries, {
TURN_REFERENCE_TRACE: '/tmp/ref.json',
TURN_CORE_TRACE: '/tmp/core.json',
}).savedTraceFiles,
['test/turnTraceFiles.integration.test.ts']
);
assert.throws(
() => selectEnabledConditionalIntegrationFiles(entries, { TURN_REFERENCE_TRACE: '/tmp/ref.json' }),
/must be provided together/u
);
});
@@ -0,0 +1,11 @@
# Test file Environment requirement
test/instantDiplomacyCoreReference.integration.test.ts reference_instant_diplomacy
test/instantDiplomacyReference.integration.test.ts reference_command
test/monthlyDisasterCoreReference.integration.test.ts reference_monthly
test/turnCommandCoreReference.integration.test.ts reference_command
test/turnCommandFullLifecycle.integration.test.ts reference_full_lifecycle
test/turnCommandGeneralMatrix.integration.test.ts reference_command
test/turnCommandNationMatrix.integration.test.ts reference_command
test/turnCommandReference.integration.test.ts reference_command
test/turnSnapshotReference.integration.test.ts reference_snapshot
test/turnTraceFiles.integration.test.ts saved_trace_pair
1 # Test file Environment requirement
2 test/instantDiplomacyCoreReference.integration.test.ts reference_instant_diplomacy
3 test/instantDiplomacyReference.integration.test.ts reference_command
4 test/monthlyDisasterCoreReference.integration.test.ts reference_monthly
5 test/turnCommandCoreReference.integration.test.ts reference_command
6 test/turnCommandFullLifecycle.integration.test.ts reference_full_lifecycle
7 test/turnCommandGeneralMatrix.integration.test.ts reference_command
8 test/turnCommandNationMatrix.integration.test.ts reference_command
9 test/turnCommandReference.integration.test.ts reference_command
10 test/turnSnapshotReference.integration.test.ts reference_snapshot
11 test/turnTraceFiles.integration.test.ts saved_trace_pair
@@ -28,13 +28,24 @@ interface EntityIdentity {
semantic: boolean;
}
const entityIdentity = (value: Record<string, unknown>, index: number): EntityIdentity => {
const entityIdentity = (value: Record<string, unknown>, index: number, path: string): EntityIdentity => {
if (
(typeof value.generalId === 'number' || typeof value.generalId === 'string') &&
typeof value.type === 'string'
) {
return { key: `${String(value.generalId)}:${value.type}`, semantic: true };
}
if (
(path === 'world.generalCooldowns' || path === 'world.nationCooldowns') &&
typeof value.actionName === 'string'
) {
for (const key of ['generalId', 'nationId']) {
const candidate = value[key];
if (typeof candidate === 'number' || typeof candidate === 'string') {
return { key: `${String(candidate)}:${value.actionName}`, semantic: true };
}
}
}
for (const key of ['id', 'generalId', 'nationId', 'fromNationId']) {
const candidate = value[key];
if (typeof candidate === 'number' || typeof candidate === 'string') {
@@ -62,7 +73,7 @@ const flatten = (value: unknown, path: string, output: FlatSnapshot): void => {
path === 'logs' || path === 'messages'
? { key: String(index), semantic: false }
: typeof entry === 'object' && entry !== null && !Array.isArray(entry)
? entityIdentity(entry as Record<string, unknown>, index)
? entityIdentity(entry as Record<string, unknown>, index, path)
: { key: String(index), semantic: false };
if (identity.semantic) {
const firstIndex = semanticKeys.get(identity.key);
@@ -70,6 +70,8 @@ export interface TurnCommandFixtureRequest {
initMonth?: number;
year?: number;
month?: number;
develCost?: number;
isUnited?: 0 | 1 | 2 | 3;
hiddenSeed?: string;
scenarioEffect?: string | null;
staticEventHandlers?: Record<string, string[]>;
@@ -331,6 +333,9 @@ const buildGeneral = (row: Record<string, unknown>, fallbackTurnTime: Date): Tur
: row.hasOwner === true
? 'turn-differential-owner'
: null,
inheritancePoints: {
active_action: readNumber(row, 'inheritActiveActionPoints'),
},
penalty: row.penalty,
triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} },
meta: {
@@ -374,6 +379,7 @@ const buildGeneral = (row: Record<string, unknown>, fallbackTurnTime: Date): Tur
belong: readNumber(row, 'belong', readNumber(meta, 'belong')),
permission: readString(row, 'permission', readString(meta, 'permission', 'normal')),
block: readNumber(row, 'blockState', readNumber(meta, 'block')),
inherit_active_action: readNumber(row, 'inheritActiveActionPoints') / 3,
},
...(lastTurn ? { lastTurn } : {}),
...(typeof row.turnTick === 'number' ? { turnTick: row.turnTick } : {}),
@@ -0,0 +1,51 @@
import type { CanonicalTurnSnapshot } from './canonical.js';
const roundHalfEven = (value: number): number => {
const lower = Math.floor(value);
const fraction = value - lower;
const tolerance = Number.EPSILON * Math.max(1, Math.abs(value)) * 4;
if (Math.abs(fraction - 0.5) <= tolerance) {
return lower % 2 === 0 ? lower : lower + 1;
}
return Math.round(value);
};
/**
* Test-only projection for a MariaDB FLOAT read through the Ref PHP service.
* Core product state intentionally keeps JavaScript/PostgreSQL precision; this
* oracle is only used to prove that an explicitly enumerated raw difference is
* caused by Ref's binary32 write and six-significant-digit read boundary.
*/
export const projectRefFloatRead = (value: number): number => {
const stored = Math.fround(value);
if (!Number.isFinite(stored) || stored === 0) {
return stored;
}
const sign = stored < 0 ? -1 : 1;
const absolute = Math.abs(stored);
const exponent = Math.floor(Math.log10(absolute));
const scale = 10 ** (5 - exponent);
return sign * (roundHalfEven(absolute * scale) / scale);
};
export interface RefFloatSnapshotProjection {
cityTrust?: boolean;
nationTech?: boolean;
}
const projectField = (row: Record<string, unknown>, field: string): Record<string, unknown> => {
const value = row[field];
if (typeof value !== 'number' || !Number.isFinite(value)) {
return row;
}
return { ...row, [field]: projectRefFloatRead(value) };
};
export const projectSnapshotThroughRefFloatRead = (
snapshot: CanonicalTurnSnapshot,
projection: RefFloatSnapshotProjection
): CanonicalTurnSnapshot => ({
...snapshot,
cities: projection.cityTrust ? snapshot.cities.map((city) => projectField(city, 'trust')) : snapshot.cities,
nations: projection.nationTech ? snapshot.nations.map((nation) => projectField(nation, 'tech')) : snapshot.nations,
});
@@ -675,6 +675,7 @@ describe('auction integration flow', () => {
directTransport.requestCommand(
{
type: 'auctionBid',
userId: validBidder.userId,
auctionId: auction.id,
generalId: validBidder.generalId,
amount: 400,
@@ -717,6 +718,7 @@ describe('auction integration flow', () => {
directTransport.requestCommand(
{
type: 'auctionBid',
userId: spareBidder.userId,
auctionId: auction.id,
generalId: spareBidder.generalId,
amount: 400,
@@ -18,8 +18,9 @@ const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL;
const persistence = describe.skipIf(!databaseUrl);
const schedule: TurnSchedule = { entries: [{ startMinute: 0, tickMinutes: 10 }] };
const buildGeneral = (id: number, cityId: number, troopId: number): TurnGeneral => ({
const buildGeneral = (id: number, cityId: number, troopId: number, userId: string | null = null): TurnGeneral => ({
id,
userId,
name: `fixture-general-${id}`,
nationId: 1,
cityId,
@@ -115,8 +116,10 @@ integration('scenario 911 troop join static event parity', () => {
lastTurnTime: new Date('0180-01-01T00:00:00Z'),
meta: { killturn: 24 },
};
const actorUserId = 'troop-static-event-parity-user';
const actor = buildGeneral(1, 3, 0, actorUserId);
const snapshot: TurnWorldSnapshot = {
generals: [buildGeneral(1, 3, 0), buildGeneral(2, 70, 2)],
generals: [actor, buildGeneral(2, 70, 2)],
cities: [buildCity(3, '출발지'), buildCity(70, String(destination?.name))],
nations: [
{
@@ -166,7 +169,9 @@ integration('scenario 911 troop join static event parity', () => {
const world = new InMemoryTurnWorld(state, snapshot, { schedule });
const handler = createTurnDaemonCommandHandler({ world });
await expect(handler.handle({ type: 'troopJoin', generalId: 1, troopId: 2 })).resolves.toMatchObject({
await expect(
handler.handle({ type: 'troopJoin', userId: actorUserId, generalId: 1, troopId: 2 })
).resolves.toMatchObject({
ok: true,
});
expect(world.getGeneralById(1)).toMatchObject({
@@ -233,7 +238,8 @@ persistence('scenario 911 troop join static event persistence', () => {
},
environment: { mapName: 'miniche', unitSet: 'che_except_siege' },
};
const actor = buildGeneral(actorId, sourceCityId, 0);
const actorUserId = 'troop-static-event-persistence-user';
const actor = buildGeneral(actorId, sourceCityId, 0, actorUserId);
const leader = buildGeneral(leaderId, destinationCityId, leaderId);
actor.name = '가입장수';
actor.nationId = nationId;
@@ -285,6 +291,7 @@ persistence('scenario 911 troop join static event persistence', () => {
await db.general.createMany({
data: [actor, leader].map((general) => ({
id: general.id,
userId: general.userId,
name: general.name,
nationId: general.nationId,
cityId: general.cityId,
@@ -366,7 +373,7 @@ persistence('scenario 911 troop join static event persistence', () => {
try {
await expect(
handler.handle({ type: 'troopJoin', generalId: actorId, troopId: leaderId })
handler.handle({ type: 'troopJoin', userId: actorUserId, generalId: actorId, troopId: leaderId })
).resolves.toMatchObject({
ok: true,
});
@@ -379,6 +386,7 @@ persistence('scenario 911 troop join static event persistence', () => {
});
expect(await db.general.findUniqueOrThrow({ where: { id: actorId } })).toMatchObject({
userId: actorUserId,
troopId: leaderId,
cityId: destinationCityId,
});
@@ -3,8 +3,10 @@ import path from 'node:path';
import { describe, expect, it } from 'vitest';
import { compareTurnSnapshotDeltas } from '../src/turn-differential/compare.js';
import type { CanonicalTurnSnapshot } from '../src/turn-differential/canonical.js';
import { compareTurnSnapshotDeltas, type SnapshotDifference } from '../src/turn-differential/compare.js';
import { runCoreTurnCommandTrace, type TurnCommandFixtureRequest } from '../src/turn-differential/coreCommandTrace.js';
import { projectSnapshotThroughRefFloatRead } from '../src/turn-differential/legacyNumericProjection.js';
import { orderedSemanticLogStreams } from '../src/turn-differential/logProjection.js';
import {
projectSemanticTurnMessages,
@@ -53,6 +55,89 @@ const timestampMillis = (value: unknown): number => {
const semanticLogSignatures = (logs: Array<Record<string, unknown>>): string[] => orderedSemanticLogStreams(logs);
const conquestTechDifferences: SnapshotDifference[] = [
{
path: 'nations[1].tech',
reference: 0.009999999999990905,
core: 0.006600000000048567,
},
];
const defenderTechDifferences: SnapshotDifference[] = [
{
path: 'nations[1].tech',
reference: 3.6200000000000045,
core: 3.623399999999947,
},
{
path: 'nations[2].tech',
reference: 5.190000000000055,
core: 5.19056999999998,
},
];
const multipleDefendersTechDifferences: SnapshotDifference[] = [
{
path: 'nations[1].tech',
reference: 3.2100000000000364,
core: 3.210239999999999,
},
{
path: 'nations[2].tech',
reference: 5.110000000000014,
core: 5.1083999999999605,
},
];
const twoNationConquestTechDifferences: SnapshotDifference[] = [
...conquestTechDifferences,
{
path: 'nations[2].tech',
reference: 0.009999999999990905,
core: 0.009900000000016007,
},
];
const expectedSortieRawDifferences: Record<string, SnapshotDifference[]> = {
'live sortie conquest': conquestTechDifferences,
'live sortie collapsed nation conflict cleanup': conquestTechDifferences,
'live sortie against a defending general': defenderTechDifferences,
'live sortie against multiple defending generals': multipleDefendersTechDifferences,
'live sortie supply retreat': [],
'live sortie noncapital conquest': twoNationConquestTechDifferences,
'live sortie emergency capital': twoNationConquestTechDifferences,
'live sortie conflict arbitration': twoNationConquestTechDifferences,
'live sortie tied conflict': twoNationConquestTechDifferences,
'live sortie outer lifecycle: conquest': conquestTechDifferences,
'live sortie outer lifecycle: collapsed nation conflict cleanup': conquestTechDifferences,
'live sortie outer lifecycle: defender': defenderTechDifferences,
'live sortie outer lifecycle: multiple defenders': multipleDefendersTechDifferences,
'live sortie outer lifecycle: supply retreat': [],
'live sortie outer lifecycle: noncapital conquest': twoNationConquestTechDifferences,
'live sortie outer lifecycle: emergency capital': twoNationConquestTechDifferences,
'live sortie outer lifecycle: conflict arbitration': twoNationConquestTechDifferences,
'live sortie outer lifecycle: tied conflict': twoNationConquestTechDifferences,
'collapse scout positive': conquestTechDifferences,
};
const expectSortieDeltaParity = (
reference: { before: CanonicalTurnSnapshot; after: CanonicalTurnSnapshot },
core: { before: CanonicalTurnSnapshot; after: CanonicalTurnSnapshot },
ignoredPathPatterns: RegExp[],
expectedRawDifferences: SnapshotDifference[]
): void => {
const rawDifferences = compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, {
ignoredPathPatterns,
});
expect(rawDifferences).toEqual(expectedRawDifferences);
expect(
compareTurnSnapshotDeltas(
reference.before,
reference.after,
projectSnapshotThroughRefFloatRead(core.before, { nationTech: true }),
projectSnapshotThroughRefFloatRead(core.after, { nationTech: true }),
{ ignoredPathPatterns }
)
).toEqual([]);
};
const readFixture = (relativePath: string): TurnCommandFixtureRequest => {
const stackRoot = path.join(workspaceRoot!, 'docker_compose_files/reference');
const fixture = JSON.parse(
@@ -281,16 +366,23 @@ integration('core ↔ legacy command-boundary differential', () => {
);
expect(semanticLogSignatures(core.after.logs)).toEqual(semanticLogSignatures(referenceAddedLogs));
}
expect(
compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, {
ignoredPathPatterns:
request.action === 'che_출병'
? request.includeLifecycle
? comparedLifecycleIgnoredPaths
: ignoredLifecyclePaths
: [...ignoredLifecyclePaths, /^generals\[[^\]]+\]\.killTurn(?:\.|$)/],
})
).toEqual([]);
const ignoredPathPatterns =
request.action === 'che_출병'
? request.includeLifecycle
? comparedLifecycleIgnoredPaths
: ignoredLifecyclePaths
: [...ignoredLifecyclePaths, /^generals\[[^\]]+\]\.killTurn(?:\.|$)/];
if (request.action === 'che_출병') {
const expectedRawDifferences = expectedSortieRawDifferences[label];
expect(expectedRawDifferences, `missing raw numeric contract for ${label}`).toBeDefined();
expectSortieDeltaParity(reference, core, ignoredPathPatterns, expectedRawDifferences!);
} else {
expect(
compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, {
ignoredPathPatterns,
})
).toEqual([]);
}
},
120_000
);
@@ -345,10 +437,11 @@ integration('core ↔ legacy command-boundary differential', () => {
unreadPrivateDelta: 1,
hasUnreadMessage: true,
});
expect(
compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, {
ignoredPathPatterns: ignoredLifecyclePaths,
})
).toEqual([]);
expectSortieDeltaParity(
reference,
core,
ignoredLifecyclePaths,
expectedSortieRawDifferences['collapse scout positive']!
);
}, 120_000);
});
@@ -2,12 +2,17 @@ import { describe, expect, it } from 'vitest';
import { asRecord, GAME_TICKS_PER_TURN, LEGACY_RANK_DATA_TYPES } from '@sammo-ts/common';
import { GENERAL_TURN_COMMAND_KEYS } from '@sammo-ts/logic';
import { compareTurnSnapshotDeltas } from '../src/turn-differential/compare.js';
import type { CanonicalTurnSnapshot } from '../src/turn-differential/canonical.js';
import { compareTurnSnapshotDeltas, type SnapshotDifference } from '../src/turn-differential/compare.js';
import { runCoreTurnCommandTrace, type TurnCommandFixtureRequest } from '../src/turn-differential/coreCommandTrace.js';
import {
normalizeStoredTurnLogText as normalizeStoredLogText,
orderedSemanticLogStreams,
} from '../src/turn-differential/logProjection.js';
import {
projectSnapshotThroughRefFloatRead,
type RefFloatSnapshotProjection,
} from '../src/turn-differential/legacyNumericProjection.js';
import {
projectSemanticTurnMessages,
projectSemanticUnreadMessageDeltas,
@@ -64,6 +69,28 @@ const successfulLifecycleIgnoredPaths = [
/^nations\[[^\]]+\]\.meta(?:\.|$)/,
];
const expectRefFloatProjectedDeltaParity = (
reference: { before: CanonicalTurnSnapshot; after: CanonicalTurnSnapshot },
core: { before: CanonicalTurnSnapshot; after: CanonicalTurnSnapshot },
ignoredPathPatterns: RegExp[],
expectedRawDifferences: SnapshotDifference[],
projection: RefFloatSnapshotProjection
): void => {
const rawDifferences = compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, {
ignoredPathPatterns,
});
expect(rawDifferences).toEqual(expectedRawDifferences);
expect(
compareTurnSnapshotDeltas(
reference.before,
reference.after,
projectSnapshotThroughRefFloatRead(core.before, projection),
projectSnapshotThroughRefFloatRead(core.after, projection),
{ ignoredPathPatterns }
)
).toEqual([]);
};
const general = (id: number, nationId: number, cityId: number, officerLevel: number): Record<string, unknown> => ({
id,
nationId,
@@ -136,6 +163,8 @@ const buildRequest = (
startYear: 180,
year: 190,
month: 1,
develCost: 18,
isUnited: 0,
hiddenSeed: 'turn-command-general-matrix-v1',
freezeClock: true,
...fixturePatches.world,
@@ -506,11 +535,26 @@ integration('general command success matrix', () => {
expect(actorTurnAt(reference.after.generalTurns, 0)).toBeUndefined();
expect(actorTurnAt(core.after.generalTurns, 0)).toBeUndefined();
}
expect(
compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, {
ignoredPathPatterns: successfulLifecycleIgnoredPaths,
})
).toEqual([]);
expectRefFloatProjectedDeltaParity(
reference,
core,
successfulLifecycleIgnoredPaths,
action === 'che_출병'
? [
{
path: 'nations[1].tech',
reference: { $snapshotState: 'missing' },
core: 0.004800000000045657,
},
{
path: 'nations[2].tech',
reference: 0.009999999999990905,
core: 0.009000000000014552,
},
]
: [],
{ nationTech: action === 'che_출병' }
);
// Logs and messages live outside the generic state-delta graph.
// Assert both for every registered success case so a command cannot
@@ -575,6 +619,7 @@ type GeneralActiveActionInheritanceCase = {
name: string;
action: string;
args?: Record<string, unknown>;
initialPoint?: number;
expectedPointDelta?: number;
};
@@ -582,6 +627,7 @@ const generalActiveActionInheritanceCases: GeneralActiveActionInheritanceCase[]
{
name: 'ordinary training does not count as a legacy active action',
action: 'che_훈련',
initialPoint: 6,
expectedPointDelta: 0,
},
{
@@ -616,8 +662,11 @@ const readActiveActionPoints = (snapshot: { generals: Array<Record<string, unkno
integration('general active-action inheritance point parity', () => {
it.each(generalActiveActionInheritanceCases)(
'$name',
async ({ name, action, args, expectedPointDelta }) => {
const request = buildRequest(action, args);
async ({ name, action, args, initialPoint = 0, expectedPointDelta }) => {
const request = buildRequest(action, args, {
ownerId: 2_000_000_001,
inheritActiveActionPoints: initialPoint,
});
request.setup!.world!.hiddenSeed = `general-active-action-${name}`;
const reference = runReferenceTurnCommandTraceRequest(
workspaceRoot!,
@@ -628,6 +677,8 @@ integration('general active-action inheritance point parity', () => {
readActiveActionPoints(reference.after, 1) - readActiveActionPoints(reference.before, 1);
const corePointDelta = readActiveActionPoints(core.after, 1) - readActiveActionPoints(core.before, 1);
expect(readActiveActionPoints(reference.before, 1)).toBe(initialPoint);
expect(readActiveActionPoints(core.before, 1)).toBe(initialPoint);
expect(reference.execution.outcome).toMatchObject({ completed: true });
expect(core.execution.outcome).toMatchObject({
requestedAction: action,
@@ -2638,11 +2689,21 @@ integration('general command in-action failure matrix', () => {
failureLogTexts(reference.after.logs, failureText).map(legacyActionLogBody)
);
expect(core.rng).toEqual(reference.rng);
expect(
compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, {
ignoredPathPatterns: ignoredLifecyclePaths,
})
).toEqual([]);
expectRefFloatProjectedDeltaParity(
reference,
core,
ignoredLifecyclePaths,
action === 'che_주민선정'
? [
{
path: 'cities[3].trust',
reference: 2.9643999999999977,
core: 2.9644093559690674,
},
]
: [],
{ cityTrust: action === 'che_주민선정' }
);
},
120_000
);
@@ -3038,11 +3099,21 @@ integration('general sabotage successful effect matrix', () => {
if ('actor' in expected) {
expect(findById(reference.after.generals, 1)).toMatchObject(expected.actor);
}
expect(
compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, {
ignoredPathPatterns: ignoredLifecyclePaths,
})
).toEqual([]);
expectRefFloatProjectedDeltaParity(
reference,
core,
ignoredLifecyclePaths,
action === 'che_선동'
? [
{
path: 'cities[70].trust',
reference: -9.8934,
core: -9.893404080791214,
},
]
: [],
{ cityTrust: action === 'che_선동' }
);
if (process.env.TURN_DIFFERENTIAL_SABOTAGE_EVIDENCE === '1') {
process.stderr.write(
@@ -3100,11 +3171,21 @@ integration('general sabotage stat progression matrix', () => {
expect(core.rng).toEqual(reference.rng);
expect(reference.after.generals.find((entry) => entry.id === 1)?.[stat]).toBe(101);
expect(core.after.generals.find((entry) => entry.id === 1)?.[stat]).toBe(101);
expect(
compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, {
ignoredPathPatterns: ignoredLifecyclePaths,
})
).toEqual([]);
expectRefFloatProjectedDeltaParity(
reference,
core,
ignoredLifecyclePaths,
action === 'che_선동'
? [
{
path: 'cities[70].trust',
reference: -9.8934,
core: -9.893404080791214,
},
]
: [],
{ cityTrust: action === 'che_선동' }
);
},
120_000
);
@@ -3162,11 +3243,21 @@ integration('general sabotage probability clamp matrix', () => {
: { operation: 'nextBits', arguments: { bits: 1 } }
);
expect(core.rng).toEqual(reference.rng);
expect(
compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, {
ignoredPathPatterns: ignoredLifecyclePaths,
})
).toEqual([]);
expectRefFloatProjectedDeltaParity(
reference,
core,
ignoredLifecyclePaths,
action === 'che_선동' && boundary === 'max'
? [
{
path: 'cities[70].trust',
reference: -11.155500000000004,
core: -11.15547143003728,
},
]
: [],
{ cityTrust: action === 'che_선동' && boundary === 'max' }
);
},
120_000
);
@@ -3325,11 +3416,21 @@ integration('general sabotage injury boundary matrix', () => {
injuryLogTexts(reference.after.logs).map(legacyInjuryLogBody)
);
expect(core.rng).toEqual(reference.rng);
expect(
compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, {
ignoredPathPatterns: ignoredLifecyclePaths,
})
).toEqual([]);
expectRefFloatProjectedDeltaParity(
reference,
core,
ignoredLifecyclePaths,
action === 'che_선동'
? [
{
path: 'cities[70].trust',
reference: -4.810900000000004,
core: -4.810929741150531,
},
]
: [],
{ cityTrust: action === 'che_선동' }
);
},
120_000
);
@@ -205,6 +205,8 @@ const buildRequest = (
startYear: 180,
year: 190,
month: 1,
develCost: 18,
isUnited: 0,
hiddenSeed: 'turn-command-nation-matrix-v1',
freezeClock: true,
...fixturePatches.world,
@@ -819,6 +821,7 @@ integration('legacy nation lifecycle comparison guard', () => {
type NationActiveActionInheritanceCase = {
name: string;
request: NationMatrixCase;
initialPoint?: number;
expectedPointDelta: number;
};
@@ -826,6 +829,7 @@ const nationActiveActionInheritanceCases: NationActiveActionInheritanceCase[] =
{
name: 'ordinary award does not count as a legacy active action',
request: ['che_포상', { isGold: true, amount: 100, destGeneralID: 3 }],
initialPoint: 6,
expectedPointDelta: 0,
},
{
@@ -857,8 +861,18 @@ const readNationActorActiveActionPoints = (
integration('nation active-action inheritance point parity', () => {
it.each(nationActiveActionInheritanceCases)(
'$name',
async ({ name, request: [action, args, fixturePatches], expectedPointDelta }) => {
const request = buildRequest(action, args, fixturePatches);
async ({ name, request: [action, args, fixturePatches], initialPoint = 0, expectedPointDelta }) => {
const request = buildRequest(action, args, {
...fixturePatches,
generals: {
...fixturePatches?.generals,
1: {
...fixturePatches?.generals?.[1],
ownerId: 2_000_000_001,
inheritActiveActionPoints: initialPoint,
},
},
});
request.setup!.world!.hiddenSeed = `nation-active-action-${name}`;
const reference = runReferenceTurnCommandTraceRequest(
workspaceRoot!,
@@ -871,6 +885,8 @@ integration('nation active-action inheritance point parity', () => {
const corePointDelta =
readNationActorActiveActionPoints(core.after, 1) - readNationActorActiveActionPoints(core.before, 1);
expect(readNationActorActiveActionPoints(reference.before, 1)).toBe(initialPoint);
expect(readNationActorActiveActionPoints(core.before, 1)).toBe(initialPoint);
expect(reference.execution.outcome).toMatchObject({ completed: true });
expect(core.execution.outcome).toMatchObject({
requestedAction: action,
@@ -1902,7 +1918,7 @@ integration('nation volunteer-recruitment constraints, creation values, RNG, and
expect(readNumericField(created, 'killTurn')).toBeGreaterThanOrEqual(64);
expect(readNumericField(created, 'killTurn')).toBeLessThanOrEqual(70);
}
const addedLogs = snapshot.after.logs.slice(snapshot.before.logs.length);
const addedLogs = addedReferenceLogs(snapshot.before, snapshot.after.logs);
expect(addedLogs.map((entry) => entry.text)).toEqual(
expect.arrayContaining([expect.stringContaining('의병모집 발동')])
);
@@ -2965,7 +2981,7 @@ integration('nation seizure zero target balance parity', () => {
request as unknown as Record<string, unknown>
);
const core = await runCoreTurnCommandTrace(request, reference.before);
const referenceLogs = reference.after.logs.slice(reference.before.logs.length);
const referenceLogs = addedReferenceLogs(reference.before, reference.after.logs);
expect(reference.execution.outcome).toMatchObject({ completed: true });
expect(core.execution.outcome).toMatchObject({
@@ -3227,7 +3243,7 @@ integration('nation material aid resource boundaries', () => {
request as unknown as Record<string, unknown>
);
const core = await runCoreTurnCommandTrace(request, reference.before);
const referenceLogs = reference.after.logs.slice(reference.before.logs.length);
const referenceLogs = addedReferenceLogs(reference.before, reference.after.logs);
expect(referenceLogs.map((entry) => entry.text)).toEqual(
expect.arrayContaining([expect.stringContaining('쌀<C>0</>를 지원')])
@@ -3394,7 +3410,7 @@ integration('nation material aid accumulated assistance and officer logs', () =>
request as unknown as Record<string, unknown>
);
const core = await runCoreTurnCommandTrace(request, reference.before);
const referenceLogs = reference.after.logs.slice(reference.before.logs.length);
const referenceLogs = addedReferenceLogs(reference.before, reference.after.logs);
const sourceOfficerLog = {
generalId: 3,
text: expect.stringContaining('<D><b>타국</b></>으로 금<C>100</> 쌀<C>200</>을 지원했습니다.'),
@@ -3574,7 +3590,7 @@ integration('nation population move value and resource boundaries', () => {
}
if (amount === 0) {
const zeroMoveText = '인구 <C>0</>명을 옮겼습니다.';
expect(reference.after.logs.slice(reference.before.logs.length).map((entry) => entry.text)).toEqual(
expect(addedReferenceLogs(reference.before, reference.after.logs).map((entry) => entry.text)).toEqual(
expect.arrayContaining([expect.stringContaining(zeroMoveText)])
);
expect(core.after.logs.map((entry) => entry.text)).toEqual(
@@ -4629,7 +4645,7 @@ integration('nation random capital constraints, candidates, RNG, and city reset
readNumericField(readGeneralMeta(coreGeneralBefore), 'inherit_active_action')
);
const noCandidateText = '이동할 수 있는 도시가 없습니다.';
expect(reference.after.logs.slice(reference.before.logs.length).map((entry) => entry.text)).toEqual(
expect(addedReferenceLogs(reference.before, reference.after.logs).map((entry) => entry.text)).toEqual(
expect.arrayContaining([expect.stringContaining(noCandidateText)])
);
expect(core.after.logs.map((entry) => entry.text)).toEqual(
@@ -4771,6 +4787,11 @@ integration('nation event research turn, reserve, and duplicate-state boundaries
gold: gold ?? requiredGold,
rice: rice ?? requiredRice,
meta: {
// The Ref CLI decodes an otherwise empty JSON object into a
// PHP array and persists it as `[]`. Keep a semantically inert
// key so this matrix observes the research mutation rather
// than an object-to-array fixture transport artifact.
matrix_fixture: 'research-boundary',
...(setAuxValue ? { [config.auxKey]: auxValue } : {}),
},
turnLastByOfficerLevel: {
@@ -5055,7 +5076,7 @@ integration('nation mobilize-people target, delay, and city-effect boundaries',
if (expectedWall !== undefined) {
expect(readNumericField(cityAfter, 'wall')).toBe(expectedWall);
}
const addedLogs = snapshot.after.logs.slice(snapshot.before.logs.length);
const addedLogs = addedReferenceLogs(snapshot.before, snapshot.after.logs);
expect(addedLogs.map((entry) => entry.text)).toEqual(
expect.arrayContaining([expect.stringContaining('백성동원 발동')])
);
@@ -5349,7 +5370,7 @@ integration('nation degrade-relations target, diplomacy, front, and cooldown bou
expect(readNumericField(sourceCityAfter, 'frontState')).toBe(expectedSourceFront);
expect(readNumericField(destCityAfter, 'frontState')).toBe(expectedDestFront);
const addedLogs = snapshot.after.logs.slice(snapshot.before.logs.length);
const addedLogs = addedReferenceLogs(snapshot.before, snapshot.after.logs);
expect(addedLogs.map((entry) => entry.text)).toEqual(
expect.arrayContaining([expect.stringContaining('이호경식 발동')])
);
@@ -5656,7 +5677,7 @@ integration('nation surprise-attack target, diplomacy-term, front, and cooldown
expect(readNumericField(sourceCityAfter, 'frontState')).toBe(expectedSourceFront);
expect(readNumericField(destCityAfter, 'frontState')).toBe(expectedDestFront);
const addedLogs = snapshot.after.logs.slice(snapshot.before.logs.length);
const addedLogs = addedReferenceLogs(snapshot.before, snapshot.after.logs);
expect(addedLogs.map((entry) => entry.text)).toEqual(
expect.arrayContaining([expect.stringContaining('급습 발동')])
);
@@ -5964,7 +5985,7 @@ integration('nation desperate-survival multistep, diplomacy, effects, and cooldo
expect(readNumericField(foreignAfter, 'atmos')).toBe(readNumericField(foreignBefore, 'atmos'));
if (completed) {
const addedLogs = snapshot.after.logs.slice(snapshot.before.logs.length);
const addedLogs = addedReferenceLogs(snapshot.before, snapshot.after.logs);
expect(addedLogs.map((entry) => entry.text)).toEqual(
expect.arrayContaining([expect.stringContaining('필사즉생 발동')])
);
@@ -6284,7 +6305,7 @@ integration('nation deception target, multistep, movement, RNG, and cooldown bou
);
if (completed) {
const addedLogs = snapshot.after.logs.slice(snapshot.before.logs.length);
const addedLogs = addedReferenceLogs(snapshot.before, snapshot.after.logs);
expect(addedLogs.map((entry) => entry.text)).toEqual(
expect.arrayContaining([expect.stringContaining('허보 발동')])
);
@@ -6735,7 +6756,7 @@ integration('nation scorched-earth constraints, multistep, city values, officers
expect(
readNumericField(auxAfter, 'did_특성초토화') - readNumericField(auxBefore, 'did_특성초토화')
).toBe(readNumericField(cityBefore, 'level') >= 8 ? 1 : 0);
expect(snapshot.after.logs.slice(snapshot.before.logs.length).map((entry) => entry.text)).toEqual(
expect(addedReferenceLogs(snapshot.before, snapshot.after.logs).map((entry) => entry.text)).toEqual(
expect.arrayContaining([
expect.stringContaining('초토화했습니다'),
expect.stringContaining('초토화</> 명령'),
@@ -6,6 +6,10 @@ import {
compareTurnSnapshotDeltas,
compareTurnSnapshots,
} from '../src/turn-differential/compare.js';
import {
projectRefFloatRead,
projectSnapshotThroughRefFloatRead,
} from '../src/turn-differential/legacyNumericProjection.js';
const snapshot = (
engine: 'ref' | 'core2026',
@@ -29,6 +33,23 @@ const snapshot = (
});
describe('turn snapshot differential comparator', () => {
it('projects only explicitly selected Core numeric state through the Ref FLOAT boundary', () => {
expect(projectRefFloatRead(1_000.0048)).toBe(1_000);
expect(projectRefFloatRead(1_000.009)).toBe(1_000.01);
expect(projectRefFloatRead(70.10659591920879)).toBe(70.1066);
const core = snapshot('core2026', {
cities: [{ id: 1, trust: 70.10659591920879, agriculture: 123.456789 }],
nations: [{ id: 1, tech: 1_000.009, gold: 123.456789 }],
});
const projected = projectSnapshotThroughRefFloatRead(core, { cityTrust: true, nationTech: true });
expect(projected.cities[0]).toEqual({ id: 1, trust: 70.1066, agriculture: 123.456789 });
expect(projected.nations[0]).toEqual({ id: 1, tech: 1_000.01, gold: 123.456789 });
expect(core.cities[0]?.trust).toBe(70.10659591920879);
expect(core.nations[0]?.tech).toBe(1_000.009);
});
it('compares entity arrays by semantic identity instead of database row order', () => {
const reference = snapshot('ref', {
cities: [
@@ -198,6 +219,116 @@ describe('turn snapshot differential comparator', () => {
);
});
it('keys multiple cooldowns for one owner by owner and action name', () => {
const referenceBefore = snapshot('ref', {
world: {
year: 183,
month: 1,
tickMinutes: 10,
turnTime: '0183-01-01T00:00:00.000Z',
isUnited: 0,
generalCooldowns: [
{ generalId: 1, actionName: '일반 행동', nextAvailableTurn: 0 },
{ generalId: 1, actionName: '특수 행동', nextAvailableTurn: 0 },
],
nationCooldowns: [
{ nationId: 1, actionName: '피장파장', nextAvailableTurn: 0 },
{ nationId: 1, actionName: '이호경식', nextAvailableTurn: 0 },
],
},
});
const coreBefore = snapshot('core2026', {
world: {
year: 183,
month: 1,
tickMinutes: 10,
turnTime: '0183-01-01T00:00:00.000Z',
isUnited: 0,
generalCooldowns: [
{ generalId: 1, actionName: '특수 행동', nextAvailableTurn: 0 },
{ generalId: 1, actionName: '일반 행동', nextAvailableTurn: 0 },
],
nationCooldowns: [
{ nationId: 1, actionName: '이호경식', nextAvailableTurn: 0 },
{ nationId: 1, actionName: '피장파장', nextAvailableTurn: 0 },
],
},
});
const reference = snapshot('ref', {
world: {
year: 183,
month: 1,
tickMinutes: 10,
turnTime: '0183-01-01T00:00:00.000Z',
isUnited: 0,
generalCooldowns: [
{ generalId: 1, actionName: '일반 행동', nextAvailableTurn: 3 },
{ generalId: 1, actionName: '특수 행동', nextAvailableTurn: 7 },
],
nationCooldowns: [
{ nationId: 1, actionName: '피장파장', nextAvailableTurn: 9 },
{ nationId: 1, actionName: '이호경식', nextAvailableTurn: 11 },
],
},
});
const core = snapshot('core2026', {
world: {
year: 183,
month: 1,
tickMinutes: 10,
turnTime: '0183-01-01T00:00:00.000Z',
isUnited: 0,
generalCooldowns: [
{ generalId: 1, actionName: '특수 행동', nextAvailableTurn: 7 },
{ generalId: 1, actionName: '일반 행동', nextAvailableTurn: 3 },
],
nationCooldowns: [
{ nationId: 1, actionName: '이호경식', nextAvailableTurn: 11 },
{ nationId: 1, actionName: '피장파장', nextAvailableTurn: 9 },
],
},
});
expect(compareTurnSnapshots(reference, core)).toEqual([]);
expect(compareTurnSnapshotDeltas(referenceBefore, reference, coreBefore, core)).toEqual([]);
const mutant = snapshot('core2026', {
...core,
world: {
...core.world,
nationCooldowns: [
{ nationId: 1, actionName: '이호경식', nextAvailableTurn: 11 },
{ nationId: 1, actionName: '피장파장', nextAvailableTurn: 10 },
],
},
});
expect(compareTurnSnapshots(reference, mutant)).toContainEqual({
path: 'world.nationCooldowns[1:피장파장].nextAvailableTurn',
reference: 9,
core: 10,
});
expect(compareTurnSnapshotDeltas(referenceBefore, reference, coreBefore, mutant)).toEqual([
{
path: 'world.nationCooldowns[1:피장파장].nextAvailableTurn',
reference: 9,
core: 10,
},
]);
const duplicate = snapshot('ref', {
world: {
...reference.world,
nationCooldowns: [
{ nationId: 1, actionName: '피장파장', nextAvailableTurn: 9 },
{ nationId: 1, actionName: '피장파장', nextAvailableTurn: 10 },
],
},
});
expect(() => compareTurnSnapshots(duplicate, core)).toThrowError(
'Duplicate semantic entity key "1:피장파장" at "world.nationCooldowns": indexes 0 and 1'
);
});
it('reports exact changed paths for general and nation command state', () => {
const reference = snapshot('ref', {
diplomacy: [{ fromNationId: 1, toNationId: 2, state: 1, term: 24 }],
+125 -11
View File
@@ -11,6 +11,7 @@ usage() {
script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
workspace_root=$(CDPATH= cd -- "$script_dir/.." && pwd)
registry_file="$script_dir/conditional-integration-registry.tsv"
file_registry="$script_dir/conditional-integration-file-registry.tsv"
env_file=${1:-"$workspace_root/.env.ci"}
if [ ! -f "$env_file" ]; then
@@ -21,6 +22,10 @@ if [ ! -f "$registry_file" ]; then
echo "missing integration marker registry: $registry_file" >&2
exit 66
fi
if [ ! -f "$file_registry" ]; then
echo "missing integration file registry: $file_registry" >&2
exit 66
fi
env_file=$(CDPATH= cd -- "$(dirname -- "$env_file")" && pwd)/$(basename -- "$env_file")
# .env.ci is generated by the sam_rebuild development Compose helper and is a
@@ -369,6 +374,102 @@ markers_for_mode() {
paste -sd '|' -
}
registered_reference_files() {
awk -F '\t' '$2 ~ /^reference_/ { print $1 }' "$file_registry"
}
files_for_requirement() {
requirement=$1
awk -F '\t' -v requirement="$requirement" '$2 == requirement { print $1 }' "$file_registry"
}
resolve_stack_relative_path() {
candidate_path=$1
case "$candidate_path" in
/*) printf '%s\n' "$candidate_path" ;;
*) printf '%s/%s\n' "$reference_stack" "$candidate_path" ;;
esac
}
require_reference_file() {
required_path=$1
requirement=$2
if [ ! -f "$required_path" ]; then
echo "$requirement requires a reference file that is not available: $required_path" >&2
exit 69
fi
}
validate_reference_file_requirements() {
if [ -n "${REF_COMPARE_SOURCE_ROOT:-}" ]; then
reference_source_root=$(
cd "$workspace_root/tools/integration-tests"
CDPATH= cd -- "$REF_COMPARE_SOURCE_ROOT"
pwd
)
else
reference_source_root="$reference_workspace_root/ref/sam"
fi
requirements=$(awk -F '\t' '$2 ~ /^reference_/ { print $2 }' "$file_registry" | sort -u)
for requirement in $requirements; do
case "$requirement" in
reference_command)
command_runner=${TURN_DIFFERENTIAL_RUNNER_SCRIPT:-"$reference_source_root/hwe/compare/turn_command_trace.php"}
require_reference_file "$(resolve_stack_relative_path "$command_runner")" "$requirement"
;;
reference_full_lifecycle)
require_reference_file \
"$reference_source_root/hwe/compare/turn_full_lifecycle_trace.php" \
"$requirement"
;;
reference_instant_diplomacy)
require_reference_file \
"$reference_source_root/hwe/compare/instant_diplomacy_response_trace.php" \
"$requirement"
;;
reference_monthly)
monthly_runner=${MONTHLY_DIFFERENTIAL_RUNNER_SCRIPT:-"$reference_workspace_root/ref/sam/hwe/compare/monthly_event_trace.php"}
require_reference_file "$(resolve_stack_relative_path "$monthly_runner")" "$requirement"
;;
reference_snapshot)
require_reference_file \
"$reference_workspace_root/ref/sam/hwe/compare/turn_state_snapshot.php" \
"$requirement"
;;
*)
echo "unsupported reference integration requirement: $requirement" >&2
exit 65
;;
esac
done
}
run_saved_trace_pair_tests() {
reference_trace=${TURN_REFERENCE_TRACE:-}
core_trace=${TURN_CORE_TRACE:-}
if [ -z "$reference_trace" ] && [ -z "$core_trace" ]; then
return
fi
if [ -z "$reference_trace" ] || [ -z "$core_trace" ]; then
echo "TURN_REFERENCE_TRACE and TURN_CORE_TRACE must be provided together" >&2
exit 64
fi
if ! (cd "$workspace_root/tools/integration-tests" && [ -f "$reference_trace" ] && [ -f "$core_trace" ]); then
echo "TURN_REFERENCE_TRACE and TURN_CORE_TRACE must both name readable trace files" >&2
exit 66
fi
trace_files=$(files_for_requirement saved_trace_pair)
if [ -z "$trace_files" ]; then
echo "saved_trace_pair has no registered integration test" >&2
exit 65
fi
# Registry paths cannot contain whitespace.
# shellcheck disable=SC2086
run_vitest tools/integration-tests "saved_trace_pair" $trace_files
}
record_vitest_result() {
label=$1
result_file=$2
@@ -376,21 +477,21 @@ record_vitest_result() {
import fs from "node:fs";
const result = JSON.parse(fs.readFileSync(process.argv[1], "utf8"));
const files = Array.isArray(result.testResults) ? result.testResults : [];
const isSkipped = ({ assertionResults = [], status }) =>
status === "pending" ||
(assertionResults.length > 0 &&
assertionResults.every(({ status: assertionStatus }) =>
assertionStatus === "pending" || assertionStatus === "todo"
));
const skipped = files.filter(isSkipped).length;
const skippedStatuses = new Set(["disabled", "pending", "skipped", "todo"]);
const hasSkippedAssertion = ({ assertionResults = [], status }) =>
skippedStatuses.has(status) ||
assertionResults.some(({ status: assertionStatus }) => skippedStatuses.has(assertionStatus));
const skipped = files.filter(hasSkippedAssertion).length;
const failed = files.filter(({ status }) => status === "failed").length;
const passed = files.length - skipped - failed;
const passed = files.filter(
(file) => file.status !== "failed" && !hasSkippedAssertion(file)
).length;
process.stdout.write(`${passed}\t${skipped}\t${failed}`);
' "$result_file")
printf '%s\t%s\n' "$label" "$counts" >>"$summary_file"
skipped=$(printf '%s' "$counts" | cut -f2)
if [ "$skipped" -ne 0 ]; then
echo "$label left $skipped integration test file(s) skipped" >&2
echo "$label left $skipped integration test file(s) with skipped assertion(s)" >&2
return 1
fi
}
@@ -469,6 +570,7 @@ run_redis_only_tests() {
export PATH
cd "$workspace_root"
node "$script_dir/check-conditional-integration-files.mjs"
validate_marker_registry
pnpm install --frozen-lockfile
@@ -680,6 +782,18 @@ if [ "${TURN_DIFFERENTIAL_REFERENCE:-}" = "1" ]; then
exit 69
fi
export TURN_DIFFERENTIAL_WORKSPACE_ROOT=$reference_workspace_root
export TURN_DIFFERENTIAL_STACK_DIR=$reference_stack
validate_reference_file_requirements
reference_files=$(registered_reference_files)
if [ -z "$reference_files" ]; then
echo "no non-database reference integration files are registered" >&2
exit 65
fi
# Registry paths cannot contain whitespace.
# shellcheck disable=SC2086
run_vitest tools/integration-tests "reference_runtime" $reference_files
create_owned_schema "$npc_possession_differential_schema"
npc_possession_differential_database_url=$(build_database_url "$npc_possession_differential_schema")
(
@@ -689,8 +803,6 @@ if [ "${TURN_DIFFERENTIAL_REFERENCE:-}" = "1" ]; then
pnpm --filter @sammo-ts/infra prisma:db:push:game
)
export NPC_POSSESSION_DIFFERENTIAL_DATABASE_URL=$npc_possession_differential_database_url
export TURN_DIFFERENTIAL_WORKSPACE_ROOT=$reference_workspace_root
export TURN_DIFFERENTIAL_STACK_DIR=$reference_stack
run_marked_tests tools/integration-tests \
"$(markers_for_mode reference_npc_possession)" \
"npc_possession_reference"
@@ -740,6 +852,8 @@ if [ "${TURN_DIFFERENTIAL_REFERENCE:-}" = "1" ]; then
export DATABASE_URL=$database_url
fi
run_saved_trace_pair_tests
all_database_markers=$(cut -f1 "$validated_registry_file" | paste -sd '|' -)
run_redis_only_tests "$all_database_markers"