Merge branch 'main' into audit/lint-test-baseline-20260726
This commit is contained in:
@@ -1,13 +1,14 @@
|
|||||||
import { TRPCError } from '@trpc/server';
|
import { TRPCError } from '@trpc/server';
|
||||||
import { asRecord } from '@sammo-ts/common';
|
import { asRecord } from '@sammo-ts/common';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
import type { GameApiContext } from '../../context.js';
|
import type { GameApiContext } from '../../context.js';
|
||||||
import { zWorldStateConfig, zWorldStateMeta } from '../../context.js';
|
import { zWorldStateConfig, zWorldStateMeta } from '../../context.js';
|
||||||
import { loadMapLayout } from '../../maps/mapLayout.js';
|
import { loadMapLayout } from '../../maps/mapLayout.js';
|
||||||
import { loadPublicMap } from '../../maps/worldMap.js';
|
import { loadPublicMap } from '../../maps/worldMap.js';
|
||||||
import { procedure, router } from '../../trpc.js';
|
import { accessPages, recordGeneralAccess } from '../../services/generalAccess.js';
|
||||||
|
import { procedure, router, sessionActivityProcedure } from '../../trpc.js';
|
||||||
import { loadTraitNames } from '../nation/shared.js';
|
import { loadTraitNames } from '../nation/shared.js';
|
||||||
import { z } from 'zod';
|
|
||||||
|
|
||||||
type WorldTrendSnapshot = {
|
type WorldTrendSnapshot = {
|
||||||
year: number;
|
year: number;
|
||||||
@@ -231,6 +232,11 @@ const sortNpcList = <T extends {
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const publicRouter = router({
|
export const publicRouter = router({
|
||||||
|
recordAccess: sessionActivityProcedure
|
||||||
|
.input(z.object({ page: z.enum(accessPages) }))
|
||||||
|
.mutation(async ({ ctx, input }) => ({
|
||||||
|
recorded: await recordGeneralAccess(ctx, input.page),
|
||||||
|
})),
|
||||||
getMapLayout: procedure.query(async ({ ctx }) => {
|
getMapLayout: procedure.query(async ({ ctx }) => {
|
||||||
return loadMapLayout(ctx.profile.scenario);
|
return loadMapLayout(ctx.profile.scenario);
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -0,0 +1,184 @@
|
|||||||
|
import { asRecord } from '@sammo-ts/common';
|
||||||
|
import { GamePrisma } from '@sammo-ts/infra';
|
||||||
|
|
||||||
|
import type { GameApiContext } from '../context.js';
|
||||||
|
|
||||||
|
export const accessPages = [
|
||||||
|
'front-info',
|
||||||
|
'nation-info',
|
||||||
|
'nation-cities',
|
||||||
|
'global-info',
|
||||||
|
'current-city',
|
||||||
|
'diplomacy',
|
||||||
|
'nation-generals',
|
||||||
|
'nation-personnel',
|
||||||
|
'nation-finance',
|
||||||
|
'battle-center',
|
||||||
|
'board',
|
||||||
|
'best-general',
|
||||||
|
'hall-of-fame',
|
||||||
|
'dynasty',
|
||||||
|
'yearbook',
|
||||||
|
'nation-betting',
|
||||||
|
'traffic',
|
||||||
|
'npc-list',
|
||||||
|
'my-page',
|
||||||
|
'npc-control',
|
||||||
|
'tournament',
|
||||||
|
'betting',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type AccessPage = (typeof accessPages)[number];
|
||||||
|
|
||||||
|
export const accessPageWeights: Record<AccessPage, number> = {
|
||||||
|
'front-info': 1,
|
||||||
|
'nation-info': 1,
|
||||||
|
'nation-cities': 1,
|
||||||
|
'global-info': 1,
|
||||||
|
'current-city': 1,
|
||||||
|
diplomacy: 1,
|
||||||
|
'nation-generals': 1,
|
||||||
|
'nation-personnel': 1,
|
||||||
|
'nation-finance': 1,
|
||||||
|
'battle-center': 1,
|
||||||
|
board: 1,
|
||||||
|
'best-general': 1,
|
||||||
|
'hall-of-fame': 1,
|
||||||
|
dynasty: 1,
|
||||||
|
yearbook: 1,
|
||||||
|
'nation-betting': 1,
|
||||||
|
traffic: 1,
|
||||||
|
'npc-list': 2,
|
||||||
|
'my-page': 1,
|
||||||
|
'npc-control': 1,
|
||||||
|
tournament: 1,
|
||||||
|
betting: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
const adminRoles = new Set(['superuser', 'admin', 'admin.superuser']);
|
||||||
|
|
||||||
|
const readFiniteNumber = (value: unknown): number | null =>
|
||||||
|
typeof value === 'number' && Number.isFinite(value) ? value : null;
|
||||||
|
|
||||||
|
const readDate = (value: unknown): Date | null => {
|
||||||
|
if (typeof value !== 'string' && !(value instanceof Date)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const parsed = new Date(value);
|
||||||
|
return Number.isNaN(parsed.getTime()) ? null : parsed;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const resolveAccessWindows = (
|
||||||
|
now: Date,
|
||||||
|
tickSeconds: number,
|
||||||
|
worldMeta: unknown
|
||||||
|
): { dayStartedAt: Date; scoreStartedAt: Date } => {
|
||||||
|
const dayStartedAt = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
|
||||||
|
const meta = asRecord(worldMeta);
|
||||||
|
const tickStartedAt = readDate(meta.lastTurnTime) ?? readDate(meta.turntime);
|
||||||
|
const fallbackTickMs = Math.max(1, Math.floor(tickSeconds)) * 1_000;
|
||||||
|
const scoreStartedAt =
|
||||||
|
tickStartedAt && tickStartedAt.getTime() <= now.getTime()
|
||||||
|
? tickStartedAt
|
||||||
|
: new Date(now.getTime() - fallbackTickMs);
|
||||||
|
return { dayStartedAt, scoreStartedAt };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const upsertGeneralAccess = async (
|
||||||
|
db: Pick<GameApiContext['db'], '$executeRaw'>,
|
||||||
|
input: {
|
||||||
|
generalId: number;
|
||||||
|
userId: string;
|
||||||
|
weight: number;
|
||||||
|
now: Date;
|
||||||
|
dayStartedAt: Date;
|
||||||
|
scoreStartedAt: Date;
|
||||||
|
}
|
||||||
|
): Promise<void> => {
|
||||||
|
await db.$executeRaw(
|
||||||
|
GamePrisma.sql`
|
||||||
|
INSERT INTO general_access_log (
|
||||||
|
general_id,
|
||||||
|
user_id,
|
||||||
|
last_refresh,
|
||||||
|
refresh,
|
||||||
|
refresh_total,
|
||||||
|
refresh_score,
|
||||||
|
refresh_score_total
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
${input.generalId},
|
||||||
|
${input.userId},
|
||||||
|
${input.now},
|
||||||
|
${input.weight},
|
||||||
|
${input.weight},
|
||||||
|
${input.weight},
|
||||||
|
${input.weight}
|
||||||
|
)
|
||||||
|
ON CONFLICT (general_id) DO UPDATE SET
|
||||||
|
user_id = EXCLUDED.user_id,
|
||||||
|
last_refresh = EXCLUDED.last_refresh,
|
||||||
|
refresh = CASE
|
||||||
|
WHEN general_access_log.last_refresh IS NULL
|
||||||
|
OR general_access_log.last_refresh < ${input.dayStartedAt}
|
||||||
|
THEN EXCLUDED.refresh
|
||||||
|
ELSE general_access_log.refresh + EXCLUDED.refresh
|
||||||
|
END,
|
||||||
|
refresh_total = general_access_log.refresh_total + EXCLUDED.refresh_total,
|
||||||
|
refresh_score = CASE
|
||||||
|
WHEN general_access_log.last_refresh IS NULL
|
||||||
|
OR general_access_log.last_refresh < ${input.scoreStartedAt}
|
||||||
|
THEN EXCLUDED.refresh_score
|
||||||
|
ELSE general_access_log.refresh_score + EXCLUDED.refresh_score
|
||||||
|
END,
|
||||||
|
refresh_score_total =
|
||||||
|
general_access_log.refresh_score_total + EXCLUDED.refresh_score_total
|
||||||
|
`
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const recordGeneralAccess = async (
|
||||||
|
ctx: Pick<GameApiContext, 'auth' | 'db'>,
|
||||||
|
page: AccessPage,
|
||||||
|
now = new Date()
|
||||||
|
): Promise<boolean> => {
|
||||||
|
const user = ctx.auth?.user;
|
||||||
|
if (!user || user.roles.some((role) => adminRoles.has(role))) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [general, worldState] = await Promise.all([
|
||||||
|
ctx.db.general.findFirst({
|
||||||
|
where: { userId: user.id },
|
||||||
|
orderBy: { id: 'asc' },
|
||||||
|
select: { id: true, userId: true },
|
||||||
|
}),
|
||||||
|
ctx.db.worldState.findFirst({
|
||||||
|
orderBy: { id: 'asc' },
|
||||||
|
select: { tickSeconds: true, meta: true },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
if (!general || !worldState) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const meta = asRecord(worldState.meta);
|
||||||
|
const isUnited = readFiniteNumber(meta.isUnited) ?? readFiniteNumber(meta.isunited) ?? 0;
|
||||||
|
const openTime = readDate(meta.opentime);
|
||||||
|
if (isUnited === 2 || (openTime && openTime.getTime() > now.getTime())) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const weight = accessPageWeights[page];
|
||||||
|
const { dayStartedAt, scoreStartedAt } = resolveAccessWindows(now, worldState.tickSeconds, meta);
|
||||||
|
|
||||||
|
await upsertGeneralAccess(ctx.db, {
|
||||||
|
generalId: general.id,
|
||||||
|
userId: user.id,
|
||||||
|
weight,
|
||||||
|
now,
|
||||||
|
dayStartedAt,
|
||||||
|
scoreStartedAt,
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
};
|
||||||
@@ -63,6 +63,10 @@ export const router = t.router;
|
|||||||
export const procedure = t.procedure.use(inputEventMiddleware);
|
export const procedure = t.procedure.use(inputEventMiddleware);
|
||||||
export const authedProcedure: typeof procedure = procedure.use(requireAuthMiddleware);
|
export const authedProcedure: typeof procedure = procedure.use(requireAuthMiddleware);
|
||||||
|
|
||||||
|
// 페이지 조회 계측처럼 game state/input-event 원장과 무관한 세션 보조
|
||||||
|
// mutation에 사용한다. gameplay state 변경에는 사용하지 않는다.
|
||||||
|
export const sessionActivityProcedure = t.procedure;
|
||||||
|
|
||||||
// 시뮬레이터처럼 게임 상태를 변경하지 않는 계산은 input-event transaction과
|
// 시뮬레이터처럼 게임 상태를 변경하지 않는 계산은 input-event transaction과
|
||||||
// 이벤트 원장을 만들지 않는다. 인증은 유지하되 lifecycle DB 경계 밖에서 실행한다.
|
// 이벤트 원장을 만들지 않는다. 인증은 유지하되 lifecycle DB 경계 밖에서 실행한다.
|
||||||
export const readOnlyAuthedProcedure: typeof procedure = t.procedure.use(requireAuthMiddleware);
|
export const readOnlyAuthedProcedure: typeof procedure = t.procedure.use(requireAuthMiddleware);
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||||
|
import { createGamePostgresConnector, type GamePrismaClient } from '@sammo-ts/infra';
|
||||||
|
|
||||||
|
import { upsertGeneralAccess } from '../src/services/generalAccess.js';
|
||||||
|
|
||||||
|
const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL;
|
||||||
|
const integration = describe.skipIf(!databaseUrl);
|
||||||
|
const generalId = 9_980_071;
|
||||||
|
|
||||||
|
integration('general access tracking persistence', () => {
|
||||||
|
let db: GamePrismaClient;
|
||||||
|
let closeDb: (() => Promise<void>) | undefined;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const connector = createGamePostgresConnector({ url: databaseUrl! });
|
||||||
|
await connector.connect();
|
||||||
|
db = connector.prisma;
|
||||||
|
closeDb = () => connector.disconnect();
|
||||||
|
await db.generalAccessLog.deleteMany({ where: { generalId } });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await db.generalAccessLog.deleteMany({ where: { generalId } });
|
||||||
|
await closeDb?.();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('atomically increments concurrent requests and resets only windowed counters', async () => {
|
||||||
|
const firstWindow = {
|
||||||
|
generalId,
|
||||||
|
userId: 'access-user-a',
|
||||||
|
now: new Date('2026-07-26T03:05:00.000Z'),
|
||||||
|
dayStartedAt: new Date('2026-07-26T00:00:00.000Z'),
|
||||||
|
scoreStartedAt: new Date('2026-07-26T03:00:00.000Z'),
|
||||||
|
};
|
||||||
|
await upsertGeneralAccess(db, { ...firstWindow, weight: 2 });
|
||||||
|
await Promise.all(
|
||||||
|
Array.from({ length: 20 }, (_, index) =>
|
||||||
|
upsertGeneralAccess(db, {
|
||||||
|
...firstWindow,
|
||||||
|
now: new Date(firstWindow.now.getTime() + index + 1),
|
||||||
|
weight: 1,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(await db.generalAccessLog.findUniqueOrThrow({ where: { generalId } })).toMatchObject({
|
||||||
|
userId: 'access-user-a',
|
||||||
|
refresh: 22,
|
||||||
|
refreshTotal: 22,
|
||||||
|
refreshScore: 22,
|
||||||
|
refreshScoreTotal: 22,
|
||||||
|
});
|
||||||
|
|
||||||
|
await upsertGeneralAccess(db, {
|
||||||
|
generalId,
|
||||||
|
userId: 'access-user-b',
|
||||||
|
now: new Date('2026-07-27T00:05:00.000Z'),
|
||||||
|
dayStartedAt: new Date('2026-07-27T00:00:00.000Z'),
|
||||||
|
scoreStartedAt: new Date('2026-07-27T00:00:00.000Z'),
|
||||||
|
weight: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await db.generalAccessLog.findUniqueOrThrow({ where: { generalId } })).toMatchObject({
|
||||||
|
userId: 'access-user-b',
|
||||||
|
refresh: 1,
|
||||||
|
refreshTotal: 23,
|
||||||
|
refreshScore: 1,
|
||||||
|
refreshScoreTotal: 23,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
|
||||||
|
import type { DatabaseClient } from '../src/context.js';
|
||||||
|
|
||||||
|
import { recordGeneralAccess, resolveAccessWindows } from '../src/services/generalAccess.js';
|
||||||
|
|
||||||
|
const auth = (roles = ['user']): GameSessionTokenPayload => ({
|
||||||
|
version: 1,
|
||||||
|
profile: 'che:default',
|
||||||
|
issuedAt: '2026-07-26T00:00:00.000Z',
|
||||||
|
expiresAt: '2026-07-27T00:00:00.000Z',
|
||||||
|
sessionId: 'access-session',
|
||||||
|
user: {
|
||||||
|
id: 'user-7',
|
||||||
|
username: 'user7',
|
||||||
|
displayName: '사용자7',
|
||||||
|
roles,
|
||||||
|
},
|
||||||
|
sanctions: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const buildDb = (meta: Record<string, unknown> = {}) => {
|
||||||
|
const executeRaw = vi.fn(async (_query: unknown) => 1);
|
||||||
|
const findGeneral = vi.fn(async () => ({ id: 7, userId: 'user-7' }));
|
||||||
|
const findWorld = vi.fn(async () => ({
|
||||||
|
tickSeconds: 600,
|
||||||
|
meta: {
|
||||||
|
opentime: '2026-07-25T00:00:00.000Z',
|
||||||
|
lastTurnTime: '2026-07-26T03:00:00.000Z',
|
||||||
|
...meta,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
const db = {
|
||||||
|
$executeRaw: executeRaw,
|
||||||
|
general: { findFirst: findGeneral },
|
||||||
|
worldState: { findFirst: findWorld },
|
||||||
|
} as unknown as DatabaseClient;
|
||||||
|
return { db, executeRaw, findGeneral, findWorld };
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('general access tracking', () => {
|
||||||
|
it('resolves the UTC day and latest processed turn windows', () => {
|
||||||
|
expect(
|
||||||
|
resolveAccessWindows(new Date('2026-07-26T03:14:15.000Z'), 600, {
|
||||||
|
lastTurnTime: '2026-07-26T03:10:00.000Z',
|
||||||
|
})
|
||||||
|
).toEqual({
|
||||||
|
dayStartedAt: new Date('2026-07-26T00:00:00.000Z'),
|
||||||
|
scoreStartedAt: new Date('2026-07-26T03:10:00.000Z'),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the session user actor and the legacy page weight in one atomic upsert', async () => {
|
||||||
|
const { db, executeRaw, findGeneral } = buildDb();
|
||||||
|
const now = new Date('2026-07-26T03:05:00.000Z');
|
||||||
|
|
||||||
|
await expect(recordGeneralAccess({ auth: auth(), db }, 'npc-list', now)).resolves.toBe(true);
|
||||||
|
expect(findGeneral).toHaveBeenCalledWith({
|
||||||
|
where: { userId: 'user-7' },
|
||||||
|
orderBy: { id: 'asc' },
|
||||||
|
select: { id: true, userId: true },
|
||||||
|
});
|
||||||
|
expect(executeRaw).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
const statement = executeRaw.mock.calls[0]![0] as { sql: string; values: unknown[] };
|
||||||
|
expect(statement.sql).toContain('ON CONFLICT (general_id) DO UPDATE');
|
||||||
|
expect(statement.sql).toContain('general_access_log.refresh + EXCLUDED.refresh');
|
||||||
|
expect(statement.values).toEqual([
|
||||||
|
7,
|
||||||
|
'user-7',
|
||||||
|
now,
|
||||||
|
2,
|
||||||
|
2,
|
||||||
|
2,
|
||||||
|
2,
|
||||||
|
new Date('2026-07-26T00:00:00.000Z'),
|
||||||
|
new Date('2026-07-26T03:00:00.000Z'),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not write for anonymous/admin users, a future opening, or a finished world', async () => {
|
||||||
|
const anonymous = buildDb();
|
||||||
|
await expect(recordGeneralAccess({ auth: null, db: anonymous.db }, 'traffic')).resolves.toBe(false);
|
||||||
|
expect(anonymous.findGeneral).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
const admin = buildDb();
|
||||||
|
await expect(recordGeneralAccess({ auth: auth(['admin']), db: admin.db }, 'traffic')).resolves.toBe(false);
|
||||||
|
expect(admin.findGeneral).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
const future = buildDb({ opentime: '2026-07-27T00:00:00.000Z' });
|
||||||
|
await expect(
|
||||||
|
recordGeneralAccess({ auth: auth(), db: future.db }, 'traffic', new Date('2026-07-26T03:05:00.000Z'))
|
||||||
|
).resolves.toBe(false);
|
||||||
|
expect(future.executeRaw).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
const united = buildDb({ isUnited: 2 });
|
||||||
|
await expect(recordGeneralAccess({ auth: auth(), db: united.db }, 'traffic')).resolves.toBe(false);
|
||||||
|
expect(united.executeRaw).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -23,6 +23,12 @@ type FixtureState = {
|
|||||||
permission: 'head' | 'member';
|
permission: 'head' | 'member';
|
||||||
myset: number;
|
myset: number;
|
||||||
settingMutations: Array<Record<string, unknown>>;
|
settingMutations: Array<Record<string, unknown>>;
|
||||||
|
accessPages: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type TrpcRequestPayload = {
|
||||||
|
json?: Record<string, unknown>;
|
||||||
|
input?: { json?: Record<string, unknown> };
|
||||||
};
|
};
|
||||||
|
|
||||||
const myGeneral = (state: FixtureState) => ({
|
const myGeneral = (state: FixtureState) => ({
|
||||||
@@ -113,7 +119,7 @@ const battleCenter = (state: FixtureState) => ({
|
|||||||
|
|
||||||
const install = async (page: Page, state: FixtureState) => {
|
const install = async (page: Page, state: FixtureState) => {
|
||||||
await page.addInitScript(() => {
|
await page.addInitScript(() => {
|
||||||
localStorage.setItem('sammo-game-token', 'menu-token');
|
localStorage.setItem('sammo-game-token', 'ga_menu-token');
|
||||||
localStorage.setItem('sammo-game-profile', 'che:default');
|
localStorage.setItem('sammo-game-profile', 'che:default');
|
||||||
});
|
});
|
||||||
await page.route('**/image/game/**', async (route) => {
|
await page.route('**/image/game/**', async (route) => {
|
||||||
@@ -130,7 +136,16 @@ const install = async (page: Page, state: FixtureState) => {
|
|||||||
});
|
});
|
||||||
await page.route('**/che/api/trpc/**', async (route) => {
|
await page.route('**/che/api/trpc/**', async (route) => {
|
||||||
const operations = operationNames(route);
|
const operations = operationNames(route);
|
||||||
const results = operations.map((operation) => {
|
const rawRequestBody: unknown = route.request().postData() ? route.request().postDataJSON() : {};
|
||||||
|
const requestBody =
|
||||||
|
rawRequestBody && typeof rawRequestBody === 'object' ? (rawRequestBody as Record<string, unknown>) : {};
|
||||||
|
const results = operations.map((operation, operationIndex) => {
|
||||||
|
const rawPayload =
|
||||||
|
requestBody[String(operationIndex)] ?? (operations.length === 1 ? requestBody : undefined);
|
||||||
|
const payload =
|
||||||
|
rawPayload && typeof rawPayload === 'object' ? (rawPayload as TrpcRequestPayload) : undefined;
|
||||||
|
const jsonInput =
|
||||||
|
payload?.json ?? payload?.input?.json ?? (payload as Record<string, unknown> | undefined) ?? {};
|
||||||
if (operation === 'lobby.info') return response({ myGeneral: { id: 7, name: '검증장수' } });
|
if (operation === 'lobby.info') return response({ myGeneral: { id: 7, name: '검증장수' } });
|
||||||
if (operation === 'join.getConfig') return response({});
|
if (operation === 'join.getConfig') return response({});
|
||||||
if (operation === 'general.me') return response(myGeneral(state));
|
if (operation === 'general.me') return response(myGeneral(state));
|
||||||
@@ -162,11 +177,15 @@ const install = async (page: Page, state: FixtureState) => {
|
|||||||
if (operation === 'general.getMyLog')
|
if (operation === 'general.getMyLog')
|
||||||
return response({ type: 'generalAction', logs: [{ id: 1, text: '<Y>기록</>' }] });
|
return response({ type: 'generalAction', logs: [{ id: 1, text: '<Y>기록</>' }] });
|
||||||
if (operation === 'general.setMySetting') {
|
if (operation === 'general.setMySetting') {
|
||||||
const raw = route.request().postDataJSON() as { input?: { json?: Record<string, unknown> } };
|
state.settingMutations.push(jsonInput);
|
||||||
state.settingMutations.push(raw.input?.json ?? {});
|
|
||||||
state.myset = Math.max(0, state.myset - 1);
|
state.myset = Math.max(0, state.myset - 1);
|
||||||
return response({ ok: true });
|
return response({ ok: true });
|
||||||
}
|
}
|
||||||
|
if (operation === 'public.recordAccess') {
|
||||||
|
const pageName = typeof jsonInput.page === 'string' ? jsonInput.page : null;
|
||||||
|
if (pageName) state.accessPages.push(pageName);
|
||||||
|
return response({ recorded: true });
|
||||||
|
}
|
||||||
if (operation === 'nation.getBattleCenter') {
|
if (operation === 'nation.getBattleCenter') {
|
||||||
if (state.permission === 'member') {
|
if (state.permission === 'member') {
|
||||||
return {
|
return {
|
||||||
@@ -196,11 +215,12 @@ const install = async (page: Page, state: FixtureState) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
test('접속량정보 keeps the legacy public 1016px chart geometry', async ({ page }) => {
|
test('접속량정보 keeps the legacy public 1016px chart geometry', async ({ page }) => {
|
||||||
const state: FixtureState = { permission: 'member', myset: 0, settingMutations: [] };
|
const state: FixtureState = { permission: 'member', myset: 0, settingMutations: [], accessPages: [] };
|
||||||
await install(page, state);
|
await install(page, state);
|
||||||
await page.setViewportSize({ width: 1200, height: 900 });
|
await page.setViewportSize({ width: 1200, height: 900 });
|
||||||
await page.goto('traffic');
|
await page.goto('traffic');
|
||||||
await expect(page.locator('.chart-title').first()).toHaveText('접 속 량');
|
await expect(page.locator('.chart-title').first()).toHaveText('접 속 량');
|
||||||
|
await expect.poll(() => state.accessPages).toContain('traffic');
|
||||||
|
|
||||||
const geometry = await page.locator('#traffic-container').evaluate((element) => {
|
const geometry = await page.locator('#traffic-container').evaluate((element) => {
|
||||||
const rect = element.getBoundingClientRect();
|
const rect = element.getBoundingClientRect();
|
||||||
@@ -244,12 +264,13 @@ test('접속량정보 keeps the legacy public 1016px chart geometry', async ({ p
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('내 정보&설정 keeps the legacy 1000px/500px geometry and saves in place', async ({ page }) => {
|
test('내 정보&설정 keeps the legacy 1000px/500px geometry and saves in place', async ({ page }) => {
|
||||||
const state: FixtureState = { permission: 'head', myset: 3, settingMutations: [] };
|
const state: FixtureState = { permission: 'head', myset: 3, settingMutations: [], accessPages: [] };
|
||||||
await install(page, state);
|
await install(page, state);
|
||||||
await page.setViewportSize({ width: 1200, height: 900 });
|
await page.setViewportSize({ width: 1200, height: 900 });
|
||||||
await page.goto('my-page');
|
await page.goto('my-page');
|
||||||
await expect(page.locator('.title-row')).toContainText('내 정 보');
|
await expect(page.locator('.title-row')).toContainText('내 정 보');
|
||||||
await expect(page.locator('#set_my_setting')).toBeVisible();
|
await expect(page.locator('#set_my_setting')).toBeVisible();
|
||||||
|
await expect.poll(() => state.accessPages).toContain('my-page');
|
||||||
|
|
||||||
const desktop = await page.locator('#container').evaluate((element) => {
|
const desktop = await page.locator('#container').evaluate((element) => {
|
||||||
const rect = element.getBoundingClientRect();
|
const rect = element.getBoundingClientRect();
|
||||||
@@ -322,7 +343,7 @@ test('내 정보&설정 keeps the legacy 1000px/500px geometry and saves in plac
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('감찰부 keeps the selector interaction and shows the permission error path', async ({ page }) => {
|
test('감찰부 keeps the selector interaction and shows the permission error path', async ({ page }) => {
|
||||||
const head: FixtureState = { permission: 'head', myset: 3, settingMutations: [] };
|
const head: FixtureState = { permission: 'head', myset: 3, settingMutations: [], accessPages: [] };
|
||||||
await install(page, head);
|
await install(page, head);
|
||||||
await page.setViewportSize({ width: 1000, height: 900 });
|
await page.setViewportSize({ width: 1000, height: 900 });
|
||||||
await page.goto('battle-center');
|
await page.goto('battle-center');
|
||||||
@@ -368,7 +389,7 @@ test('감찰부 keeps the selector interaction and shows the permission error pa
|
|||||||
await persistParityArtifact(page, 'core-battle-center-mobile', mobileGeometry);
|
await persistParityArtifact(page, 'core-battle-center-mobile', mobileGeometry);
|
||||||
|
|
||||||
await page.unrouteAll({ behavior: 'wait' });
|
await page.unrouteAll({ behavior: 'wait' });
|
||||||
const member: FixtureState = { permission: 'member', myset: 3, settingMutations: [] };
|
const member: FixtureState = { permission: 'member', myset: 3, settingMutations: [], accessPages: [] };
|
||||||
await install(page, member);
|
await install(page, member);
|
||||||
await page.reload();
|
await page.reload();
|
||||||
await expect(page.locator('.error')).toContainText('권한이 부족합니다.');
|
await expect(page.locator('.error')).toContainText('권한이 부족합니다.');
|
||||||
|
|||||||
@@ -34,6 +34,34 @@ import NationBettingView from '../views/NationBettingView.vue';
|
|||||||
import NpcListView from '../views/NpcListView.vue';
|
import NpcListView from '../views/NpcListView.vue';
|
||||||
import TrafficView from '../views/TrafficView.vue';
|
import TrafficView from '../views/TrafficView.vue';
|
||||||
import { useSessionStore } from '../stores/session';
|
import { useSessionStore } from '../stores/session';
|
||||||
|
import { trpc } from '../utils/trpc';
|
||||||
|
|
||||||
|
const accessPageByRouteName = {
|
||||||
|
home: 'front-info',
|
||||||
|
'nation-info': 'nation-info',
|
||||||
|
'nation-cities': 'nation-cities',
|
||||||
|
'global-info': 'global-info',
|
||||||
|
'current-city': 'current-city',
|
||||||
|
diplomacy: 'diplomacy',
|
||||||
|
'nation-generals': 'nation-generals',
|
||||||
|
'nation-personnel': 'nation-personnel',
|
||||||
|
'nation-finance': 'nation-finance',
|
||||||
|
'battle-center': 'battle-center',
|
||||||
|
board: 'board',
|
||||||
|
'board-secret': 'board',
|
||||||
|
'best-general': 'best-general',
|
||||||
|
'hall-of-fame': 'hall-of-fame',
|
||||||
|
'dynasty-list': 'dynasty',
|
||||||
|
'dynasty-detail': 'dynasty',
|
||||||
|
yearbook: 'yearbook',
|
||||||
|
'nation-betting': 'nation-betting',
|
||||||
|
traffic: 'traffic',
|
||||||
|
'npc-list': 'npc-list',
|
||||||
|
'my-page': 'my-page',
|
||||||
|
'npc-control': 'npc-control',
|
||||||
|
tournament: 'tournament',
|
||||||
|
betting: 'betting',
|
||||||
|
} as const;
|
||||||
|
|
||||||
const routes = [
|
const routes = [
|
||||||
{
|
{
|
||||||
@@ -370,4 +398,14 @@ router.beforeEach(async (to) => {
|
|||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
router.afterEach((to) => {
|
||||||
|
const session = useSessionStore();
|
||||||
|
const routeName = typeof to.name === 'string' ? to.name : '';
|
||||||
|
const page = accessPageByRouteName[routeName as keyof typeof accessPageByRouteName];
|
||||||
|
if (!page || !session.hasGeneral) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void trpc.public.recordAccess.mutate({ page }).catch(() => undefined);
|
||||||
|
});
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@@ -219,6 +219,11 @@ minimum/maximum clamps for gift, donation, and rice trade; donation against
|
|||||||
available and minimum resources; and gift reserve and self-target rejection.
|
available and minimum resources; and gift reserve and self-target rejection.
|
||||||
They compare normalized last-turn arguments, RNG, fallback, and semantic state
|
They compare normalized last-turn arguments, RNG, fallback, and semantic state
|
||||||
deltas.
|
deltas.
|
||||||
|
Eleven nation resource cases cover 100-unit rounding and minimum/maximum
|
||||||
|
clamps for award and seizure; award against available gold, the base-rice
|
||||||
|
reserve, and self-target rejection; and seizure against target holdings and
|
||||||
|
self-target rejection. They compare exact target resource deltas, RNG,
|
||||||
|
fallback, and semantic state deltas.
|
||||||
Requests that omit the required argument object remain unverified because the
|
Requests that omit the required argument object remain unverified because the
|
||||||
reference runner did not terminate within the bounded comparison run.
|
reference runner did not terminate within the bounded comparison run.
|
||||||
This is not yet a claim that every command-specific
|
This is not yet a claim that every command-specific
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import { defaultActionContextBuilder } from '@sammo-ts/logic/actions/turn/action
|
|||||||
import { tryApplyUniqueLottery } from '@sammo-ts/logic/rewards/uniqueLottery.js';
|
import { tryApplyUniqueLottery } from '@sammo-ts/logic/rewards/uniqueLottery.js';
|
||||||
import type { GeneralTurnCommandSpec } from './index.js';
|
import type { GeneralTurnCommandSpec } from './index.js';
|
||||||
import { parseArgsWithSchema } from '../parseArgs.js';
|
import { parseArgsWithSchema } from '../parseArgs.js';
|
||||||
import { normalizeResourceActionAmount } from './resourceAmount.js';
|
import { normalizeResourceActionAmount } from '../resourceAmount.js';
|
||||||
|
|
||||||
export interface TradeEnvironment {
|
export interface TradeEnvironment {
|
||||||
exchangeFee?: number;
|
exchangeFee?: number;
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js'
|
|||||||
import { tryApplyUniqueLottery } from '@sammo-ts/logic/rewards/uniqueLottery.js';
|
import { tryApplyUniqueLottery } from '@sammo-ts/logic/rewards/uniqueLottery.js';
|
||||||
import type { GeneralTurnCommandSpec } from './index.js';
|
import type { GeneralTurnCommandSpec } from './index.js';
|
||||||
import { parseArgsWithSchema } from '../parseArgs.js';
|
import { parseArgsWithSchema } from '../parseArgs.js';
|
||||||
import { normalizeResourceActionAmount } from './resourceAmount.js';
|
import { normalizeResourceActionAmount } from '../resourceAmount.js';
|
||||||
|
|
||||||
const ACTION_NAME = '증여';
|
const ACTION_NAME = '증여';
|
||||||
const ACTION_KEY = 'che_증여';
|
const ACTION_KEY = 'che_증여';
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import { defaultActionContextBuilder } from '@sammo-ts/logic/actions/turn/action
|
|||||||
import { tryApplyUniqueLottery } from '@sammo-ts/logic/rewards/uniqueLottery.js';
|
import { tryApplyUniqueLottery } from '@sammo-ts/logic/rewards/uniqueLottery.js';
|
||||||
import type { GeneralTurnCommandSpec } from './index.js';
|
import type { GeneralTurnCommandSpec } from './index.js';
|
||||||
import { parseArgsWithSchema } from '../parseArgs.js';
|
import { parseArgsWithSchema } from '../parseArgs.js';
|
||||||
import { normalizeResourceActionAmount } from './resourceAmount.js';
|
import { normalizeResourceActionAmount } from '../resourceAmount.js';
|
||||||
|
|
||||||
const ACTION_NAME = '헌납';
|
const ACTION_NAME = '헌납';
|
||||||
const ACTION_KEY = 'che_헌납';
|
const ACTION_KEY = 'che_헌납';
|
||||||
|
|||||||
@@ -24,13 +24,11 @@ import type { ActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionCo
|
|||||||
import { clamp } from 'es-toolkit';
|
import { clamp } from 'es-toolkit';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { parseArgsWithSchema } from '../parseArgs.js';
|
import { parseArgsWithSchema } from '../parseArgs.js';
|
||||||
|
import { normalizeResourceActionAmount } from '../resourceAmount.js';
|
||||||
|
|
||||||
const ARGS_SCHEMA = z.object({
|
const ARGS_SCHEMA = z.object({
|
||||||
isGold: z.boolean(),
|
isGold: z.boolean(),
|
||||||
amount: z.preprocess(
|
amount: z.number(),
|
||||||
(value) => (typeof value === 'number' ? Math.floor(value / 100) * 100 : value),
|
|
||||||
z.number().int().positive()
|
|
||||||
),
|
|
||||||
destGeneralID: z.number(),
|
destGeneralID: z.number(),
|
||||||
});
|
});
|
||||||
export type SeizureArgs = z.infer<typeof ARGS_SCHEMA>;
|
export type SeizureArgs = z.infer<typeof ARGS_SCHEMA>;
|
||||||
@@ -56,9 +54,13 @@ export class ActionDefinition<
|
|||||||
if (!data) {
|
if (!data) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
const amount = normalizeResourceActionAmount(data.amount, this.env.maxResourceActionAmount);
|
||||||
|
if (amount === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
...data,
|
...data,
|
||||||
amount: clamp(data.amount, 100, this.env.maxResourceActionAmount ?? 10000),
|
amount,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -144,10 +144,10 @@ export class ActionResolver<
|
|||||||
createLogEffect(
|
createLogEffect(
|
||||||
`<Y>${context.destGeneral.name}</>에게 ${label} <C>${amountText}</>${amountJosa} 수여했습니다.`,
|
`<Y>${context.destGeneral.name}</>에게 ${label} <C>${amountText}</>${amountJosa} 수여했습니다.`,
|
||||||
{
|
{
|
||||||
scope: LogScope.GENERAL,
|
scope: LogScope.GENERAL,
|
||||||
category: LogCategory.ACTION,
|
category: LogCategory.ACTION,
|
||||||
format: LogFormat.MONTH,
|
format: LogFormat.MONTH,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -199,7 +199,7 @@ export class ActionDefinition<
|
|||||||
requirements.push({ kind: 'destGeneral', id: ctx.destGeneralId });
|
requirements.push({ kind: 'destGeneral', id: ctx.destGeneralId });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ctx.destGeneralId === ctx.actorId) {
|
if (args.destGeneralId === ctx.actorId) {
|
||||||
return [denyWithReason('본인입니다')];
|
return [denyWithReason('본인입니다')];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ const configuredWorkspaceRoot = process.env.TURN_DIFFERENTIAL_WORKSPACE_ROOT;
|
|||||||
const workspaceRoot = configuredWorkspaceRoot ?? findTurnDifferentialWorkspaceRoot(process.cwd());
|
const workspaceRoot = configuredWorkspaceRoot ?? findTurnDifferentialWorkspaceRoot(process.cwd());
|
||||||
const integration = describe.skipIf(!workspaceRoot || process.env.TURN_DIFFERENTIAL_REFERENCE !== '1');
|
const integration = describe.skipIf(!workspaceRoot || process.env.TURN_DIFFERENTIAL_REFERENCE !== '1');
|
||||||
|
|
||||||
|
const readGold = (row: { gold?: unknown } | undefined): number => (typeof row?.gold === 'number' ? row.gold : 0);
|
||||||
|
|
||||||
const ignoredLifecyclePaths = [
|
const ignoredLifecyclePaths = [
|
||||||
/^generalTurns/,
|
/^generalTurns/,
|
||||||
/^nationTurns/,
|
/^nationTurns/,
|
||||||
@@ -469,3 +471,159 @@ integration('nation command success matrix', () => {
|
|||||||
120_000
|
120_000
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const nationResourceAmountCases: Array<{
|
||||||
|
name: string;
|
||||||
|
action: 'che_포상' | 'che_몰수';
|
||||||
|
args: Record<string, unknown>;
|
||||||
|
expectedAmount: number;
|
||||||
|
}> = [
|
||||||
|
{
|
||||||
|
name: 'award rounds a half unit up',
|
||||||
|
action: 'che_포상',
|
||||||
|
args: { isGold: true, amount: 150, destGeneralID: 3 },
|
||||||
|
expectedAmount: 200,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'award clamps below the minimum',
|
||||||
|
action: 'che_포상',
|
||||||
|
args: { isGold: true, amount: 1, destGeneralID: 3 },
|
||||||
|
expectedAmount: 100,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'award clamps above the maximum',
|
||||||
|
action: 'che_포상',
|
||||||
|
args: { isGold: true, amount: 10_050, destGeneralID: 3 },
|
||||||
|
expectedAmount: 10_000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'seizure rounds a half unit up',
|
||||||
|
action: 'che_몰수',
|
||||||
|
args: { isGold: true, amount: 150, destGeneralID: 3 },
|
||||||
|
expectedAmount: 200,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'seizure clamps below the minimum',
|
||||||
|
action: 'che_몰수',
|
||||||
|
args: { isGold: true, amount: 1, destGeneralID: 3 },
|
||||||
|
expectedAmount: 100,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'seizure clamps above the maximum',
|
||||||
|
action: 'che_몰수',
|
||||||
|
args: { isGold: true, amount: 10_050, destGeneralID: 3 },
|
||||||
|
expectedAmount: 10_000,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
integration('nation command resource amount normalization matrix', () => {
|
||||||
|
it.each(nationResourceAmountCases)(
|
||||||
|
'$name matches legacy rounding and clamp semantics',
|
||||||
|
async ({ action, args, expectedAmount }) => {
|
||||||
|
const request = buildRequest(action, args);
|
||||||
|
const reference = runReferenceTurnCommandTraceRequest(
|
||||||
|
workspaceRoot!,
|
||||||
|
request as unknown as Record<string, unknown>
|
||||||
|
);
|
||||||
|
const core = await runCoreTurnCommandTrace(request, reference.before);
|
||||||
|
const referenceTargetBefore = reference.before.generals.find((entry) => entry.id === 3);
|
||||||
|
const referenceTargetAfter = reference.after.generals.find((entry) => entry.id === 3);
|
||||||
|
const coreTargetBefore = core.before.generals.find((entry) => entry.id === 3);
|
||||||
|
const coreTargetAfter = core.after.generals.find((entry) => entry.id === 3);
|
||||||
|
const referenceAmount =
|
||||||
|
action === 'che_포상'
|
||||||
|
? readGold(referenceTargetAfter) - readGold(referenceTargetBefore)
|
||||||
|
: readGold(referenceTargetBefore) - readGold(referenceTargetAfter);
|
||||||
|
const coreAmount =
|
||||||
|
action === 'che_포상'
|
||||||
|
? readGold(coreTargetAfter) - readGold(coreTargetBefore)
|
||||||
|
: readGold(coreTargetBefore) - readGold(coreTargetAfter);
|
||||||
|
|
||||||
|
expect(reference.execution.outcome).toMatchObject({ completed: true });
|
||||||
|
expect(core.execution.outcome).toMatchObject({
|
||||||
|
requestedAction: action,
|
||||||
|
actionKey: action,
|
||||||
|
usedFallback: false,
|
||||||
|
});
|
||||||
|
expect(referenceAmount).toBe(expectedAmount);
|
||||||
|
expect(coreAmount).toBe(expectedAmount);
|
||||||
|
expect(core.rng).toEqual(reference.rng);
|
||||||
|
expect(
|
||||||
|
compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, {
|
||||||
|
ignoredPathPatterns: ignoredLifecyclePaths,
|
||||||
|
})
|
||||||
|
).toEqual([]);
|
||||||
|
},
|
||||||
|
120_000
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const nationResourceBoundaryCases: Array<{
|
||||||
|
name: string;
|
||||||
|
action: 'che_포상' | 'che_몰수';
|
||||||
|
args: Record<string, unknown>;
|
||||||
|
fixturePatches?: FixturePatches;
|
||||||
|
completed: boolean;
|
||||||
|
}> = [
|
||||||
|
{
|
||||||
|
name: 'award is limited to the available nation gold',
|
||||||
|
action: 'che_포상',
|
||||||
|
args: { isGold: true, amount: 10_000, destGeneralID: 3 },
|
||||||
|
fixturePatches: { nations: { 1: { gold: 5_000 } } },
|
||||||
|
completed: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'award keeps the legacy base rice reserve',
|
||||||
|
action: 'che_포상',
|
||||||
|
args: { isGold: false, amount: 10_000, destGeneralID: 3 },
|
||||||
|
fixturePatches: { nations: { 1: { rice: 2_100 } } },
|
||||||
|
completed: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'award rejects the actor as its target',
|
||||||
|
action: 'che_포상',
|
||||||
|
args: { isGold: true, amount: 100, destGeneralID: 1 },
|
||||||
|
completed: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'seizure is limited to the target general gold',
|
||||||
|
action: 'che_몰수',
|
||||||
|
args: { isGold: true, amount: 1_000, destGeneralID: 3 },
|
||||||
|
fixturePatches: { generals: { 3: { gold: 50 } } },
|
||||||
|
completed: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'seizure rejects the actor as its target',
|
||||||
|
action: 'che_몰수',
|
||||||
|
args: { isGold: true, amount: 100, destGeneralID: 1 },
|
||||||
|
completed: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
integration('nation command resource balance and target boundaries', () => {
|
||||||
|
it.each(nationResourceBoundaryCases)(
|
||||||
|
'$name matches legacy completion, RNG, and state delta',
|
||||||
|
async ({ action, args, fixturePatches, completed }) => {
|
||||||
|
const request = buildRequest(action, args, fixturePatches);
|
||||||
|
const reference = runReferenceTurnCommandTraceRequest(
|
||||||
|
workspaceRoot!,
|
||||||
|
request as unknown as Record<string, unknown>
|
||||||
|
);
|
||||||
|
const core = await runCoreTurnCommandTrace(request, reference.before);
|
||||||
|
|
||||||
|
expect(reference.execution.outcome).toMatchObject({ completed });
|
||||||
|
expect(core.execution.outcome).toMatchObject({
|
||||||
|
requestedAction: action,
|
||||||
|
actionKey: completed ? action : '휴식',
|
||||||
|
usedFallback: !completed,
|
||||||
|
});
|
||||||
|
expect(core.rng).toEqual(reference.rng);
|
||||||
|
expect(
|
||||||
|
compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, {
|
||||||
|
ignoredPathPatterns: ignoredLifecyclePaths,
|
||||||
|
})
|
||||||
|
).toEqual([]);
|
||||||
|
},
|
||||||
|
120_000
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user