merge: Game API 명령 전수 Ref 호환 검증을 반영한다
This commit is contained in:
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
}),
|
||||
}),
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.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));
|
||||
};
|
||||
|
||||
@@ -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 })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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: '<신국>',
|
||||
nested: ['AB', ''],
|
||||
});
|
||||
await expect(
|
||||
parseReservedTurnArgs('nation', 'che_국호변경', {
|
||||
nationName: ' <신-국># ',
|
||||
})
|
||||
).resolves.toEqual({ nationName: '<신국>' });
|
||||
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] } } });
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
@@ -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)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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: '<신국>' });
|
||||
expect(nationWrites).toHaveLength(1);
|
||||
expect(nationWrites[0]).toMatchObject({
|
||||
data: expect.arrayContaining([
|
||||
expect.objectContaining({ turnIdx: 0, arg: { nationName: '<신국>' } }),
|
||||
]),
|
||||
});
|
||||
});
|
||||
|
||||
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,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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
|
||||
|
@@ -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은 독립적으로 해석하고, 서로 다른
|
||||
|
||||
@@ -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 집계에 포함하지
|
||||
|
||||
@@ -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
|
||||
|
@@ -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 }],
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user