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