diff --git a/app/game-api/src/inputEventBoundary.ts b/app/game-api/src/inputEventBoundary.ts index 4c3c8ad1..1a0993dd 100644 --- a/app/game-api/src/inputEventBoundary.ts +++ b/app/game-api/src/inputEventBoundary.ts @@ -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 = + { kind: 'executed'; value: T } | { kind: 'replayed'; value: T } | { kind: 'failed'; error: unknown }; + +type SavepointDatabaseClient = InfraDatabaseClient & { + $executeRawUnsafe(query: string): Promise; +}; + 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).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 => { + 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 => { + const rows = await db.$queryRaw( + 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 => { + 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 => { + 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 (options: { db: DatabaseClient; requestId: string; eventType: string; + payload: unknown; actorUserId?: string | null; execute(db: DatabaseClient): Promise; }): Promise => { - 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; 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; }; diff --git a/app/game-api/src/messages/store.ts b/app/game-api/src/messages/store.ts index cdf664c4..308f1d80 100644 --- a/app/game-api/src/messages/store.ts +++ b/app/game-api/src/messages/store.ts @@ -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 => { 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), }, }); }; diff --git a/app/game-api/src/messages/targets.ts b/app/game-api/src/messages/targets.ts index 699324a1..7990f85f 100644 --- a/app/game-api/src/messages/targets.ts +++ b/app/game-api/src/messages/targets.ts @@ -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), }); diff --git a/app/game-api/src/router/diplomacy/index.ts b/app/game-api/src/router/diplomacy/index.ts index 8aa53cf2..2c7d4614 100644 --- a/app/game-api/src/router/diplomacy/index.ts +++ b/app/game-api/src/router/diplomacy/index.ts @@ -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; + +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, + 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; + src: MessageTarget; + dest: MessageTarget; + text: string; + time: Date; + includeNational?: boolean; +}): Promise => { + 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[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 }; }), }); diff --git a/app/game-api/src/router/turns/index.ts b/app/game-api/src/router/turns/index.ts index ad740fa7..8d47ccbf 100644 --- a/app/game-api/src/router/turns/index.ts +++ b/app/game-api/src/router/turns/index.ts @@ -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 => { + 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 (mutation: () => Promise): Promise => { 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 => { + 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>( + 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 }; }), }), diff --git a/app/game-api/src/trpc.ts b/app/game-api/src/trpc.ts index 34dd502d..a67aa250 100644 --- a/app/game-api/src/trpc.ts +++ b/app/game-api/src/trpc.ts @@ -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> | 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({ diff --git a/app/game-api/src/turns/commandInput.ts b/app/game-api/src/turns/commandInput.ts index fa12257e..ea99df55 100644 --- a/app/game-api/src/turns/commandInput.ts +++ b/app/game-api/src/turns/commandInput.ts @@ -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; + 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> => { await assertReservedTurnActionAvailable(scope, action, scenarioConst); - return parseRegisteredTurnArgs(scope, action, rawArgs); + return parseRegisteredTurnArgs(scope, action, sanitizeReservedTurnArgs(rawArgs)); }; diff --git a/app/game-api/test/auctionRouter.test.ts b/app/game-api/test/auctionRouter.test.ts index 748a689b..8735dbb9 100644 --- a/app/game-api/test/auctionRouter.test.ts +++ b/app/game-api/test/auctionRouter.test.ts @@ -82,10 +82,14 @@ const buildContext = (options: { requestId?: string; transaction?: ReturnType; clockTick?: number; + daemonResult?: Awaited>; }) => { 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 }) + ); + }); }); diff --git a/app/game-api/test/commandInput.test.ts b/app/game-api/test/commandInput.test.ts index a6b143d2..e12f032c 100644 --- a/app/game-api/test/commandInput.test.ts +++ b/app/game-api/test/commandInput.test.ts @@ -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능동' diff --git a/app/game-api/test/diplomacyDocumentMessages.integration.test.ts b/app/game-api/test/diplomacyDocumentMessages.integration.test.ts new file mode 100644 index 00000000..2cc8ae99 --- /dev/null +++ b/app/game-api/test/diplomacyDocumentMessages.integration.test.ts @@ -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) | undefined; + + const deleteFixtureOutboxes = async (): Promise => { + 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 => { + 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 => { + 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 => { + 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; + dest: Pick; + }): Promise => { + 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) => { + 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, + }); + }); +}); diff --git a/app/game-api/test/diplomacyHtmlTransport.integration.test.ts b/app/game-api/test/diplomacyHtmlTransport.integration.test.ts index 64cccf11..13bf2196 100644 --- a/app/game-api/test/diplomacyHtmlTransport.integration.test.ts +++ b/app/game-api/test/diplomacyHtmlTransport.integration.test.ts @@ -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 => { } }; +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 => { + await db.message.deleteMany({ where: { mailbox: { in: [...fixtureMailboxes] } } }); await db.diplomacyLetter.deleteMany({ where: { OR: [ @@ -77,6 +99,15 @@ const cleanup = async (): Promise => { ], }, }); + 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] } } }); diff --git a/app/game-api/test/diplomacyRouter.test.ts b/app/game-api/test/diplomacyRouter.test.ts index a595b3a7..935ba208 100644 --- a/app/game-api/test/diplomacyRouter.test.ts +++ b/app/game-api/test/diplomacyRouter.test.ts @@ -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 = 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 = 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 () => { diff --git a/app/game-api/test/directMutationJournalInventory.test.ts b/app/game-api/test/directMutationJournalInventory.test.ts index dd05d617..c295a8a5 100644 --- a/app/game-api/test/directMutationJournalInventory.test.ts +++ b/app/game-api/test/directMutationJournalInventory.test.ts @@ -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']); + }); }); diff --git a/app/game-api/test/generalAccessTracking.test.ts b/app/game-api/test/generalAccessTracking.test.ts index 15d017f0..bd9330dc 100644 --- a/app/game-api/test/generalAccessTracking.test.ts +++ b/app/game-api/test/generalAccessTracking.test.ts @@ -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) => { 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', ]); diff --git a/app/game-api/test/inGameMenuPermissions.test.ts b/app/game-api/test/inGameMenuPermissions.test.ts index f2edd74a..d4320144 100644 --- a/app/game-api/test/inGameMenuPermissions.test.ts +++ b/app/game-api/test/inGameMenuPermissions.test.ts @@ -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, diff --git a/app/game-api/test/inheritRouter.test.ts b/app/game-api/test/inheritRouter.test.ts index 118e7062..ae9af24c 100644 --- a/app/game-api/test/inheritRouter.test.ts +++ b/app/game-api/test/inheritRouter.test.ts @@ -112,6 +112,8 @@ const buildContext = (options: { configConst?: Record; configMap?: Record; daemonResult?: TurnDaemonCommandResult; + requestId?: string; + transaction?: ReturnType; }) => { 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, diff --git a/app/game-api/test/inputEventBoundary.integration.test.ts b/app/game-api/test/inputEventBoundary.integration.test.ts index a45e8f17..d1dfb8cf 100644 --- a/app/game-api/test/inputEventBoundary.integration.test.ts +++ b/app/game-api/test/inputEventBoundary.integration.test.ts @@ -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((resolve) => { + signalStarted = resolve; + }); + const release = new Promise((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) => { + 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 () => { diff --git a/app/game-api/test/inputEventBoundary.test.ts b/app/game-api/test/inputEventBoundary.test.ts new file mode 100644 index 00000000..9e992eb8 --- /dev/null +++ b/app/game-api/test/inputEventBoundary.test.ts @@ -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); + }); +}); diff --git a/app/game-api/test/inputEventJournal.test.ts b/app/game-api/test/inputEventJournal.test.ts index c54b4b1d..4046fb47 100644 --- a/app/game-api/test/inputEventJournal.test.ts +++ b/app/game-api/test/inputEventJournal.test.ts @@ -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) => { 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(); }); diff --git a/app/game-api/test/messagesRouter.test.ts b/app/game-api/test/messagesRouter.test.ts index 5eebe448..c3a3e72f 100644 --- a/app/game-api/test/messagesRouter.test.ts +++ b/app/game-api/test/messagesRouter.test.ts @@ -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); }); diff --git a/app/game-api/test/mutationEvidenceManifest.test.ts b/app/game-api/test/mutationEvidenceManifest.test.ts new file mode 100644 index 00000000..b678614e --- /dev/null +++ b/app/game-api/test/mutationEvidenceManifest.test.ts @@ -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; + +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 = { + '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 ?? ''}`); + } + + 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([]); + }); +}); diff --git a/app/game-api/test/publicRecordAccessRouter.test.ts b/app/game-api/test/publicRecordAccessRouter.test.ts new file mode 100644 index 00000000..487a6ce7 --- /dev/null +++ b/app/game-api/test/publicRecordAccessRouter.test.ts @@ -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' }); + }); +}); diff --git a/app/game-api/test/requestId.test.ts b/app/game-api/test/requestId.test.ts index be0b67e6..59928971 100644 --- a/app/game-api/test/requestId.test.ts +++ b/app/game-api/test/requestId.test.ts @@ -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) + ); + }); }); diff --git a/app/game-api/test/router.test.ts b/app/game-api/test/router.test.ts index 7834bdfc..02d5df3d 100644 --- a/app/game-api/test/router.test.ts +++ b/app/game-api/test/router.test.ts @@ -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; @@ -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[] = []; diff --git a/app/game-api/test/securityTransport.integration.test.ts b/app/game-api/test/securityTransport.integration.test.ts index 5f6820c2..b4ebe304 100644 --- a/app/game-api/test/securityTransport.integration.test.ts +++ b/app/game-api/test/securityTransport.integration.test.ts @@ -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) | 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> 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 => { 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, }); }); diff --git a/app/game-api/test/tournamentRouter.test.ts b/app/game-api/test/tournamentRouter.test.ts index d0b08e2a..b4635eec 100644 --- a/app/game-api/test/tournamentRouter.test.ts +++ b/app/game-api/test/tournamentRouter.test.ts @@ -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(); diff --git a/app/game-api/test/troopRouter.test.ts b/app/game-api/test/troopRouter.test.ts index 3516194c..c0258395 100644 --- a/app/game-api/test/troopRouter.test.ts +++ b/app/game-api/test/troopRouter.test.ts @@ -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: { diff --git a/app/game-engine/src/turn/reservedTurnHandler.ts b/app/game-engine/src/turn/reservedTurnHandler.ts index c787e0c4..0c6f1a42 100644 --- a/app/game-engine/src/turn/reservedTurnHandler.ts +++ b/app/game-engine/src/turn/reservedTurnHandler.ts @@ -1858,7 +1858,9 @@ export const createReservedTurnHandler = async (options: { currentGeneral.crew = 0; currentGeneral.rice = 0; logs.push( - createGeneralActionLog(currentGeneral.id, '군량이 모자라 병사들이 소집해제되었습니다!') + createGeneralActionLog(currentGeneral.id, '군량이 모자라 병사들이 소집해제되었습니다!', { + format: LogFormat.PLAIN, + }) ); preTurnContext.skill.activate('pre.소집해제'); } diff --git a/app/game-engine/test/generalTurnLegacyCompatibility.test.ts b/app/game-engine/test/generalTurnLegacyCompatibility.test.ts index 81ff443e..f4355368 100644 --- a/app/game-engine/test/generalTurnLegacyCompatibility.test.ts +++ b/app/game-engine/test/generalTurnLegacyCompatibility.test.ts @@ -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 () => { diff --git a/docs/architecture/api-input-event-replay.md b/docs/architecture/api-input-event-replay.md new file mode 100644 index 00000000..955dca89 --- /dev/null +++ b/docs/architecture/api-input-event-replay.md @@ -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인 `:`를 유지하고, 이후 호출은 +`::batch:`를 사용해 서로 충돌하지 않게 한다. + +## 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으로 묶을 수 없다. diff --git a/docs/architecture/game-api-direct-mutation-journal-inventory.md b/docs/architecture/game-api-direct-mutation-journal-inventory.md index fd2f478a..34e2d467 100644 --- a/docs/architecture/game-api-direct-mutation-journal-inventory.md +++ b/docs/architecture/game-api-direct-mutation-journal-inventory.md @@ -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:`, `betting:0` | 없음 | 본인 베팅/유산 지출과 베팅 aggregate source가 바뀐다. `betting`은 현재 별도 화면 source이고 main dashboard fan-out을 만들지 않는다. | | `inherit.checkOwner` | 확인자·확인 대상의 `messages.mailbox:` | 두 장수 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 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:`, `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:`, `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:`, `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:` | 없음 | 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 후보는 각각 전용 diff --git a/docs/architecture/game-api-mutation-evidence.tsv b/docs/architecture/game-api-mutation-evidence.tsv new file mode 100644 index 00000000..f25e2261 --- /dev/null +++ b/docs/architecture/game-api-mutation-evidence.tsv @@ -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 diff --git a/docs/architecture/realtime-change-journal.md b/docs/architecture/realtime-change-journal.md index 103e5924..9132b87a 100644 --- a/docs/architecture/realtime-change-journal.md +++ b/docs/architecture/realtime-change-journal.md @@ -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 완료를 diff --git a/docs/architecture/turn-state-differential-testing.md b/docs/architecture/turn-state-differential-testing.md index d46e50d3..48be9459 100644 --- a/docs/architecture/turn-state-differential-testing.md +++ b/docs/architecture/turn-state-differential-testing.md @@ -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은 독립적으로 해석하고, 서로 다른 diff --git a/docs/integration-tests.md b/docs/integration-tests.md index f1c458e2..f77dcff2 100644 --- a/docs/integration-tests.md +++ b/docs/integration-tests.md @@ -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 집계에 포함하지 diff --git a/package.json b/package.json index 0892a389..015e70fc 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/logic/src/actions/turn/general/che_장비매매.ts b/packages/logic/src/actions/turn/general/che_장비매매.ts index 979c09e8..1b596fde 100644 --- a/packages/logic/src/actions/turn/general/che_장비매매.ts +++ b/packages/logic/src/actions/turn/general/che_장비매매.ts @@ -238,6 +238,7 @@ export class ActionDefinition< { scope: LogScope.SYSTEM, category: LogCategory.HISTORY, + format: LogFormat.YEAR_MONTH, } ); } diff --git a/packages/logic/test/itemActionEvents.test.ts b/packages/logic/test/itemActionEvents.test.ts index 4c720c76..b29ac120 100644 --- a/packages/logic/test/itemActionEvents.test.ts +++ b/packages/logic/test/itemActionEvents.test.ts @@ -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', diff --git a/tools/check-conditional-integration-files.mjs b/tools/check-conditional-integration-files.mjs new file mode 100644 index 00000000..6a63fe42 --- /dev/null +++ b/tools/check-conditional-integration-files.mjs @@ -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; + } +} diff --git a/tools/check-conditional-integration-files.test.mjs b/tools/check-conditional-integration-files.test.mjs new file mode 100644 index 00000000..91c3619e --- /dev/null +++ b/tools/check-conditional-integration-files.test.mjs @@ -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 + ); +}); diff --git a/tools/conditional-integration-file-registry.tsv b/tools/conditional-integration-file-registry.tsv new file mode 100644 index 00000000..9414b8c1 --- /dev/null +++ b/tools/conditional-integration-file-registry.tsv @@ -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 diff --git a/tools/integration-tests/src/turn-differential/compare.ts b/tools/integration-tests/src/turn-differential/compare.ts index f97d9bf8..2e920aa3 100644 --- a/tools/integration-tests/src/turn-differential/compare.ts +++ b/tools/integration-tests/src/turn-differential/compare.ts @@ -28,13 +28,24 @@ interface EntityIdentity { semantic: boolean; } -const entityIdentity = (value: Record, index: number): EntityIdentity => { +const entityIdentity = (value: Record, 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, index) + ? entityIdentity(entry as Record, index, path) : { key: String(index), semantic: false }; if (identity.semantic) { const firstIndex = semanticKeys.get(identity.key); diff --git a/tools/integration-tests/src/turn-differential/coreCommandTrace.ts b/tools/integration-tests/src/turn-differential/coreCommandTrace.ts index df3e18f4..e41698af 100644 --- a/tools/integration-tests/src/turn-differential/coreCommandTrace.ts +++ b/tools/integration-tests/src/turn-differential/coreCommandTrace.ts @@ -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; @@ -331,6 +333,9 @@ const buildGeneral = (row: Record, 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, 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 } : {}), diff --git a/tools/integration-tests/src/turn-differential/legacyNumericProjection.ts b/tools/integration-tests/src/turn-differential/legacyNumericProjection.ts new file mode 100644 index 00000000..7b542a40 --- /dev/null +++ b/tools/integration-tests/src/turn-differential/legacyNumericProjection.ts @@ -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, field: string): Record => { + 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, +}); diff --git a/tools/integration-tests/test/auctionFlow.test.ts b/tools/integration-tests/test/auctionFlow.test.ts index e111fa96..8d9476da 100644 --- a/tools/integration-tests/test/auctionFlow.test.ts +++ b/tools/integration-tests/test/auctionFlow.test.ts @@ -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, diff --git a/tools/integration-tests/test/troopStaticEvent.integration.test.ts b/tools/integration-tests/test/troopStaticEvent.integration.test.ts index 6a73e3db..c9bbcc04 100644 --- a/tools/integration-tests/test/troopStaticEvent.integration.test.ts +++ b/tools/integration-tests/test/troopStaticEvent.integration.test.ts @@ -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, }); diff --git a/tools/integration-tests/test/turnCommandCoreReference.integration.test.ts b/tools/integration-tests/test/turnCommandCoreReference.integration.test.ts index dda7323e..0ff4a489 100644 --- a/tools/integration-tests/test/turnCommandCoreReference.integration.test.ts +++ b/tools/integration-tests/test/turnCommandCoreReference.integration.test.ts @@ -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>): 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 = { + '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); }); diff --git a/tools/integration-tests/test/turnCommandGeneralMatrix.integration.test.ts b/tools/integration-tests/test/turnCommandGeneralMatrix.integration.test.ts index 65abf2ce..1c77729b 100644 --- a/tools/integration-tests/test/turnCommandGeneralMatrix.integration.test.ts +++ b/tools/integration-tests/test/turnCommandGeneralMatrix.integration.test.ts @@ -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 => ({ 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; + 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 { 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 ); diff --git a/tools/integration-tests/test/turnCommandNationMatrix.integration.test.ts b/tools/integration-tests/test/turnCommandNationMatrix.integration.test.ts index 4cdfa499..642004ed 100644 --- a/tools/integration-tests/test/turnCommandNationMatrix.integration.test.ts +++ b/tools/integration-tests/test/turnCommandNationMatrix.integration.test.ts @@ -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 ); 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 ); 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('쌀0를 지원')]) @@ -3394,7 +3410,7 @@ integration('nation material aid accumulated assistance and officer logs', () => request as unknown as Record ); 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('타국으로 금100 쌀200을 지원했습니다.'), @@ -3574,7 +3590,7 @@ integration('nation population move value and resource boundaries', () => { } if (amount === 0) { const zeroMoveText = '인구 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('초토화 명령'), diff --git a/tools/integration-tests/test/turnSnapshotComparator.test.ts b/tools/integration-tests/test/turnSnapshotComparator.test.ts index e1f64f78..d7a49ada 100644 --- a/tools/integration-tests/test/turnSnapshotComparator.test.ts +++ b/tools/integration-tests/test/turnSnapshotComparator.test.ts @@ -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 }], diff --git a/tools/run-conditional-integration.sh b/tools/run-conditional-integration.sh index 7439da19..c461fa31 100755 --- a/tools/run-conditional-integration.sh +++ b/tools/run-conditional-integration.sh @@ -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"