NPC 정책 적용을 검증하고 자국 변경 이력 조회를 제공한다

This commit is contained in:
2026-10-22 23:50:35 +09:00
parent 87f810a878
commit 039c5d2a59
9 changed files with 977 additions and 3 deletions
+91
View File
@@ -0,0 +1,91 @@
import { TRPCError } from '@trpc/server';
import { z } from 'zod';
import type { GamePrisma } from '@sammo-ts/infra';
import type { GameApiContext } from '../../context.js';
import { authedProcedure } from '../../trpc.js';
import { getAuthenticatedUserId } from '../shared/general.js';
import { resolveSecretPermission } from '../shared/secretPermission.js';
import { projectPolicyConfiguration, projectPolicySummary } from '../playAudit/policies.js';
import { readAudit, readAuditWorld } from '../playAudit/shared.js';
const area = z.enum(['NPC_VALUES', 'NPC_NATION_PRIORITY', 'NPC_GENERAL_PRIORITY']);
const summarySelect = {
id: true,
schemaVersion: true,
nationId: true,
area: true,
revision: true,
previousId: true,
source: true,
year: true,
month: true,
actor: true,
createdAt: true,
} satisfies GamePrisma.PlayAuditPolicySelect;
// 소속·기밀 권한과 이력을 같은 snapshot에서 읽어 이적/권한 변경 경합을 막는다.
const readNationId = async (ctx: GameApiContext, tx: GamePrisma.TransactionClient): Promise<number> => {
const general = await tx.general.findFirst({ where: { userId: getAuthenticatedUserId(ctx) } });
if (!general) throw new TRPCError({ code: 'NOT_FOUND', message: '장수 정보를 찾을 수 없습니다.' });
if (general.nationId <= 0)
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: '국가에 소속되어있지 않습니다.' });
const nation = await tx.nation.findUnique({ where: { id: general.nationId }, select: { meta: true } });
if (!nation) throw new TRPCError({ code: 'NOT_FOUND', message: '국가 정보를 찾을 수 없습니다.' });
if (resolveSecretPermission(general, nation.meta) < 1)
throw new TRPCError({ code: 'FORBIDDEN', message: '권한이 부족합니다.' });
return general.nationId;
};
export const policyHistory = authedProcedure
.input(
z
.object({
area,
cursor: z.number().int().positive().optional(),
limit: z.number().int().min(1).max(50).default(20),
})
.strict()
)
.query(({ ctx, input }) =>
readAudit(ctx, async (tx) => {
const nationId = await readNationId(ctx, tx);
const world = await readAuditWorld(tx);
const rows = world.serverId
? await tx.playAuditPolicy.findMany({
where: {
serverId: world.serverId,
nationId,
area: input.area,
revision: input.cursor === undefined ? undefined : { lt: input.cursor },
},
orderBy: { revision: 'desc' },
take: input.limit + 1,
select: summarySelect,
})
: [];
return {
coverage: world.serverId ? ('RECORDED_VERSIONS_ONLY' as const) : ('IDENTITY_MISSING' as const),
historyGap: world.historyGap,
items: rows.slice(0, input.limit).map(projectPolicySummary),
nextCursor: rows.length > input.limit ? rows[input.limit - 1]!.revision : null,
};
})
);
export const policyVersion = authedProcedure
.input(z.object({ id: z.string().regex(/^[a-f0-9]{64}$/) }).strict())
.query(({ ctx, input }) =>
readAudit(ctx, async (tx) => {
const nationId = await readNationId(ctx, tx);
const world = await readAuditWorld(tx);
const row = world.serverId
? await tx.playAuditPolicy.findFirst({
where: { id: input.id, serverId: world.serverId, nationId, area: { in: area.options } },
select: { ...summarySelect, before: true, after: true },
})
: null;
if (!row)
throw new TRPCError({ code: 'NOT_FOUND', message: '자국의 현재 기수 정책 기록을 찾을 수 없습니다.' });
return projectPolicyConfiguration(row);
})
);
+3
View File
@@ -15,6 +15,7 @@ import { loadUnitSetDefinitionByName } from '@sammo-ts/game-engine/scenario/unit
import type { GameApiContext } from '../../context.js'; import type { GameApiContext } from '../../context.js';
import { getMyGeneral } from '../shared/general.js'; import { getMyGeneral } from '../shared/general.js';
import { resolveSecretPermission } from '../shared/secretPermission.js'; import { resolveSecretPermission } from '../shared/secretPermission.js';
import { policyHistory, policyVersion } from './history.js';
type SetterInfo = { type SetterInfo = {
setter: string | null; setter: string | null;
@@ -356,6 +357,8 @@ const requestNpcPolicyMutation = async (
}; };
export const npcRouter = router({ export const npcRouter = router({
policyHistory,
policyVersion,
getPolicy: authedProcedure.query(async ({ ctx }) => { getPolicy: authedProcedure.query(async ({ ctx }) => {
const general = await getMyGeneral(ctx); const general = await getMyGeneral(ctx);
if (general.nationId <= 0) { if (general.nationId <= 0) {
@@ -0,0 +1,358 @@
import { afterAll, beforeAll, expect, it } from 'vitest';
import fastify from 'fastify';
import { fastifyTRPCPlugin } from '@trpc/server/adapters/fastify';
import { asRecord } from '@sammo-ts/common';
import { createGamePostgresConnector, createRedisConnector, GamePrisma } from '@sammo-ts/infra';
import { createTurnDaemonRuntime, seedScenarioToDatabase, type TurnDaemonRuntime } from '@sammo-ts/game-engine';
import { appRouter } from '../src/router.js';
import { DatabaseTurnDaemonTransport } from '../src/daemon/databaseTransport.js';
import { RedisAccessTokenStore } from '../src/auth/accessTokenStore.js';
import { InMemoryFlushStore } from '../src/auth/flushStore.js';
import type { GameApiContext } from '../src/context.js';
const databaseUrl = process.env.NPC_POLICY_DATABASE_URL;
const integration = it.skipIf(!databaseUrl || !process.env.REDIS_URL);
const connector = databaseUrl ? createGamePostgresConnector({ url: databaseUrl }) : null;
const redis = process.env.REDIS_URL ? createRedisConnector({ url: process.env.REDIS_URL }) : null;
let runtime: TurnDaemonRuntime | undefined;
let loop: Promise<void> | undefined;
const profile = `npc-policy:${databaseUrl ? new URL(databaseUrl).searchParams.get('schema') : 'fixture'}`;
const baseTime = new Date('0185-01-01T00:00:00Z');
beforeAll(async () => {
if (!databaseUrl) return;
if (!new URL(databaseUrl).searchParams.get('schema')?.endsWith('_npc_policy_fixture'))
throw new Error('Dedicated NPC policy fixture schema required');
await connector!.connect();
await redis!.connect();
});
afterAll(async () => {
if (loop) {
await runtime?.lifecycle.stop();
await loop;
}
await runtime?.close();
await connector?.disconnect();
await redis?.disconnect();
});
integration(
'HTTP user edits survive daemon restart and real NPC turns, with readable actor and before/after history',
async () => {
const db = connector!.prisma;
await db.playAuditPolicy.deleteMany({
where: {
OR: [
{ serverId: 'npc-policy-lifecycle' },
{ id: { in: ['c'.repeat(64), 'd'.repeat(64), 'e'.repeat(64)] } },
],
},
});
await db.playAuditMonth.deleteMany({ where: { serverId: 'npc-policy-lifecycle' } });
await db.playAuditDecisionChunk.deleteMany({ where: { decision: { serverId: 'npc-policy-lifecycle' } } });
await db.playAuditDecision.deleteMany({ where: { serverId: 'npc-policy-lifecycle' } });
await db.playAuditDiplomacyEvent.deleteMany({ where: { serverId: 'npc-policy-lifecycle' } });
await db.inputEvent.deleteMany({ where: { requestId: { startsWith: 'npc-policy-http-' } } });
await seedScenarioToDatabase({
scenarioId: 903,
databaseUrl: databaseUrl!,
now: new Date(),
installOptions: {
openAt: new Date(Date.now() + 86_400_000),
turnTermMinutes: 5,
npcMode: 2,
showImgLevel: 3,
serverId: 'npc-policy-lifecycle',
season: 1,
},
});
await db.generalTurn.deleteMany();
await db.nationTurn.deleteMany();
await db.general.deleteMany();
await db.nation.deleteMany();
await db.diplomacy.deleteMany();
// 185년 고정 fixture에 180년 시나리오의 지연 등장 이벤트를 이월하지 않는다.
// 일반턴·월간 재정/도시 갱신은 실제 runtime handler를 그대로 실행한다.
await db.event.deleteMany();
const city = await db.city.findFirstOrThrow({ orderBy: { id: 'asc' } });
const world = await db.worldState.findFirstOrThrow();
await db.worldState.update({
where: { id: world.id },
data: {
currentYear: 185,
currentMonth: 1,
clockBaseTime: baseTime,
clockTick: 0n,
lastTurnTick: 0n,
clockPhase: 'RUNNING',
clockMode: 'realtime',
clockWallAnchor: new Date(),
config: { ...asRecord(world.config), tournamentTrig: false },
},
});
await db.nation.create({
data: {
id: 1,
name: '정책국',
color: '#ffffff',
level: 3,
chiefGeneralId: 1,
capitalCityId: city.id,
gold: 100_000,
rice: 100_000,
tech: 3000,
meta: {},
},
});
await db.city.updateMany({ data: { nationId: 0 } });
await db.city.update({
where: { id: city.id },
data: {
nationId: 1,
population: 100_000,
populationMax: 200_000,
supplyState: 1,
frontState: 1,
meta: { trust: 80, trade: 100 },
},
});
await db.general.createMany({
data: [
{
id: 1,
userId: 'npc-policy-owner',
name: '정책군주',
nationId: 1,
cityId: city.id,
officerLevel: 12,
npcState: 0,
turnTime: new Date('0185-02-01Z'),
turnTick: 100_000_000n,
meta: { belong: 5, killturn: 24 },
},
{
id: 2,
name: '징병검증장',
nationId: 1,
cityId: city.id,
officerLevel: 1,
npcState: 2,
leadership: 80,
strength: 80,
intel: 30,
gold: 100_000,
rice: 100_000,
crew: 0,
turnTime: baseTime,
turnTick: 0n,
meta: { killturn: 24, armType: 1, dex1: 1000 },
},
],
});
const transport = new DatabaseTurnDaemonTransport(db, 10_000);
const auth: NonNullable<GameApiContext['auth']> = {
version: 1,
profile,
sessionId: 'fixture-session',
issuedAt: new Date().toISOString(),
expiresAt: '2999-01-01T00:00:00Z',
user: { id: 'npc-policy-owner', username: 'fixture', displayName: 'fixture', roles: [] },
sanctions: {},
};
const app = fastify();
let request = 0;
await app.register(fastifyTRPCPlugin, {
prefix: '/che/api/trpc',
trpcOptions: {
router: appRouter,
createContext: ({
req,
}: {
req: { headers: Record<string, string | string[] | undefined> };
}): GameApiContext => ({
db,
redis: redis!.client,
profile: { id: 'hwe', scenario: '903', name: profile },
auth: req.headers['x-session-token'] === 'fixture-session' ? auth : null,
requestId: `npc-policy-http-${++request}`,
turnDaemon: transport,
accessTokenStore: new RedisAccessTokenStore(redis!.client, profile),
flushStore: new InMemoryFlushStore(),
battleSim: {} as GameApiContext['battleSim'],
uploadDir: '/tmp',
uploadPath: '/uploads',
uploadPublicUrl: null,
gameTokenSecret: 'fixture-only',
}),
},
});
const actions: Array<{ kind: string; actionKey: string; generalId: number; completed?: boolean }> = [];
const start = async () => {
const instance = await createTurnDaemonRuntime({
profile,
databaseUrl: databaseUrl!,
enableLeaseHeartbeat: false,
onActionResolved: (action) => actions.push(action),
});
// 자연 진행은 정지하고, 사용자 command와 명시한 due 턴만 처리한다.
instance.lifecycle.pause('NPC policy fixture');
return instance;
};
const call = async (endpoint: string, input: unknown, mutation = false) => {
const response = await app.inject({
method: mutation ? 'POST' : 'GET',
url: `/che/api/trpc/npc.${endpoint}${mutation ? '' : `?input=${encodeURIComponent(JSON.stringify(input))}`}`,
headers: {
'x-session-token': 'fixture-session',
...(mutation ? { 'content-type': 'application/json' } : {}),
},
...(mutation ? { payload: JSON.stringify(input) } : {}),
});
expect(response.statusCode, response.body).toBe(200);
return response.json<{ result: { data: unknown } }>().result.data;
};
const configuration = (value: unknown) => {
const data = asRecord(value);
return {
values: data.currentNationPolicy,
nation: data.currentNationPriority,
general: data.currentGeneralActionPriority,
setters: data.lastSetters,
};
};
try {
runtime = await start();
loop = runtime.lifecycle.start();
// 발령과 징병을 목록에서 빼면 실제 턴에서도 수행하지 않아야 한다.
await call('setNationPriority', ['NPC포상'], true);
await call('setGeneralPriority', ['출병', '일반내정'], true);
await call(
'setNationPolicy',
{ minWarCrew: 2000, minNPCRecruitCityPopulation: 30000, safeRecruitCityPopulationRatio: 0 },
true
);
const withoutRecruitment = await call('getPolicy', {});
expect(withoutRecruitment).toMatchObject({
currentNationPriority: ['NPC포상'],
currentGeneralActionPriority: ['출병', '일반내정'],
});
await runtime.lifecycle.stop();
await loop;
loop = undefined;
await runtime.close();
runtime = await start();
expect(configuration(await call('getPolicy', {}))).toEqual(configuration(withoutRecruitment));
let result = await runtime.processor.run(baseTime, { budgetMs: 10_000, maxGenerals: 1, catchUpCap: 1 });
expect(result.processedGenerals).toBe(1);
expect(runtime.world.getGeneralById(2)!.crew).toBe(0);
expect(
actions.filter((action) => action.generalId === 2).some((action) => action.actionKey === 'che_징병')
).toBe(false);
await runtime.hooks!.flushChanges!(result);
expect(configuration(await call('getPolicy', {}))).toEqual(configuration(withoutRecruitment));
// 같은 장수·자원으로 징병 우선순위를 맨 앞에 저장한 뒤 다시 시작한다.
loop = runtime.lifecycle.start();
await call('setGeneralPriority', ['징병', '출병', '일반내정'], true);
const saved = await call('getPolicy', {});
await runtime.lifecycle.stop();
await loop;
loop = undefined;
await runtime.close();
runtime = await start();
expect(configuration(await call('getPolicy', {}))).toEqual(configuration(saved));
const beforeMeta = asRecord(runtime.world.getNationById(1)!.meta);
// 실제 due 턴과 command를 실행한다. 전방의 중립 도시가 전쟁 상태를 만든다.
actions.length = 0;
result = await runtime.processor.run(runtime.world.getGeneralById(2)!.turnTime, {
budgetMs: 10_000,
maxGenerals: 1,
catchUpCap: 1,
});
expect(result.processedGenerals).toBe(1);
expect(runtime.world.getGeneralById(2)!.crew).toBeGreaterThan(0);
expect(actions).toContainEqual(
expect.objectContaining({ generalId: 2, actionKey: 'che_징병', completed: true })
);
await runtime.hooks!.flushChanges!(result);
expect((await db.general.findUniqueOrThrow({ where: { id: 2 } })).crew).toBeGreaterThan(0);
expect(asRecord(runtime.world.getNationById(1)!.meta).npc_nation_policy).toEqual(
beforeMeta.npc_nation_policy
);
expect(configuration(await call('getPolicy', {}))).toEqual(configuration(saved));
// 병력이 소진되고 도시 인구가 정책 하한 미만이면 징병을 거른다.
runtime.world.updateGeneral(2, { crew: 0 });
runtime.world.updateCity(city.id, { population: 30_100 });
actions.length = 0;
result = await runtime.processor.run(runtime.world.getGeneralById(2)!.turnTime, {
budgetMs: 10_000,
maxGenerals: 1,
catchUpCap: 1,
});
expect(result.processedGenerals).toBe(1);
expect(runtime.world.getGeneralById(2)!.crew).toBe(0);
expect(actions.some((action) => action.actionKey === 'che_징병')).toBe(false);
await runtime.hooks!.flushChanges!(result);
expect(configuration(await call('getPolicy', {}))).toEqual(configuration(saved));
// 이후의 개인턴/월간 갱신도 정책을 덮어쓰면 안 된다.
const target = new Date(runtime.world.getGeneralById(2)!.turnTime.getTime() + 30 * 60_000);
result = await runtime.processor.run(target, { budgetMs: 10_000, maxGenerals: 100, catchUpCap: 12 });
expect(result.processedGenerals).toBeGreaterThan(1);
await runtime.hooks!.flushChanges!(result);
expect(await call('getPolicy', {})).toMatchObject({
currentNationPriority: ['NPC포상'],
currentGeneralActionPriority: ['징병', '출병', '일반내정'],
currentNationPolicy: { minWarCrew: 2000, minNPCRecruitCityPopulation: 30000 },
});
expect(runtime.world.getState().meta.playAuditGap).toBeUndefined();
const history = await call('policyHistory', { area: 'NPC_NATION_PRIORITY' });
expect(history).toMatchObject({
items: [
expect.objectContaining({ source: 'CHANGE', actor: expect.objectContaining({ name: '정책군주' }) }),
expect.anything(),
],
});
const row = await db.playAuditPolicy.findFirstOrThrow({
where: { nationId: 1, area: 'NPC_NATION_PRIORITY', source: 'CHANGE' },
});
expect(await call('policyVersion', { id: row.id })).toMatchObject({
actor: { name: '정책군주' },
fields: [{ key: 'priority', beforeJson: 'null', afterJson: '["NPC포상"]', changed: true }],
});
expect(row.inputSequence).not.toBeNull();
expect((await db.inputEvent.findUniqueOrThrow({ where: { sequence: row.inputSequence! } })).status).toBe(
'SUCCEEDED'
);
// 외국/이전 기수/국방 정책 ID를 알아도 자국 NPC 정책으로 조회할 수 없다.
for (const [id, patch] of [
['c'.repeat(64), { nationId: 2 }],
['d'.repeat(64), { serverId: 'previous-season' }],
['e'.repeat(64), { area: 'DEFENCE' }],
] as const) {
await db.playAuditPolicy.create({
data: {
...row,
id,
...patch,
actor: row.actor ?? GamePrisma.DbNull,
before: row.before ?? GamePrisma.DbNull,
after: row.after ?? GamePrisma.JsonNull,
},
});
const response = await app.inject({
url: `/che/api/trpc/npc.policyVersion?input=${encodeURIComponent(JSON.stringify({ id }))}`,
headers: { 'x-session-token': 'fixture-session' },
});
expect(response.statusCode).toBe(404);
}
await db.general.update({ where: { id: 1 }, data: { penalty: { noChief: true } } });
const denied = await app.inject({
url: `/che/api/trpc/npc.policyHistory?input=${encodeURIComponent(JSON.stringify({ area: 'NPC_VALUES' }))}`,
headers: { 'x-session-token': 'fixture-session' },
});
expect(denied.statusCode).toBe(403);
} finally {
await app.close();
}
},
90_000
);
+154 -1
View File
@@ -1,4 +1,6 @@
import { describe, expect, it, vi } from 'vitest'; import { describe, expect, it, vi } from 'vitest';
import fastify from 'fastify';
import { fastifyTRPCPlugin } from '@trpc/server/adapters/fastify';
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';
@@ -96,10 +98,16 @@ const createContext = (
options: { options: {
me?: GeneralRow; me?: GeneralRow;
nation?: typeof baseNation; nation?: typeof baseNation;
world?: typeof baseWorld; world?: {
config: typeof baseWorld.config;
meta: Record<string, unknown>;
currentYear?: number;
currentMonth?: number;
};
requestCommand?: ReturnType<typeof vi.fn>; requestCommand?: ReturnType<typeof vi.fn>;
troopRows?: Array<{ troopLeaderId: number }>; troopRows?: Array<{ troopLeaderId: number }>;
cityRows?: Array<{ id: number }>; cityRows?: Array<{ id: number }>;
historyRows?: Array<Record<string, unknown>>;
} = {} } = {}
): { context: GameApiContext; findFirst: ReturnType<typeof vi.fn>; requestCommand: ReturnType<typeof vi.fn> } => { ): { context: GameApiContext; findFirst: ReturnType<typeof vi.fn>; requestCommand: ReturnType<typeof vi.fn> } => {
const requestCommand = const requestCommand =
@@ -117,6 +125,11 @@ const createContext = (
worldState: { findFirst: vi.fn(async () => options.world ?? baseWorld) }, worldState: { findFirst: vi.fn(async () => options.world ?? baseWorld) },
troop: { findMany: vi.fn(async () => options.troopRows ?? [{ troopLeaderId: 101 }]) }, troop: { findMany: vi.fn(async () => options.troopRows ?? [{ troopLeaderId: 101 }]) },
city: { findMany: vi.fn(async () => options.cityRows ?? [{ id: 1 }, { id: 2 }]) }, city: { findMany: vi.fn(async () => options.cityRows ?? [{ id: 1 }, { id: 2 }]) },
playAuditPolicy: {
findMany: vi.fn(async () => options.historyRows ?? []),
findFirst: vi.fn(async () => options.historyRows?.[0] ?? null),
},
$transaction: async <T>(read: (tx: object) => Promise<T>): Promise<T> => read(db),
}; };
const redisClient = { get: async () => null, set: async () => null }; const redisClient = { get: async () => null, set: async () => null };
return { return {
@@ -140,6 +153,146 @@ const createContext = (
}; };
describe('NPC policy router', () => { describe('NPC policy router', () => {
const historyRow = {
id: 'a'.repeat(64),
schemaVersion: 1,
nationId: 1,
area: 'NPC_NATION_PRIORITY',
revision: 2,
previousId: 'b'.repeat(64),
source: 'CHANGE',
year: 185,
month: 1,
createdAt: new Date('2026-01-01Z'),
actor: {
userId: 'private-user',
generalId: 22,
name: '정책담당',
nationId: 1,
officerLevel: 12,
npcState: 0,
permission: 4,
},
before: { priority: ['NPC전방발령', 'NPC포상'] },
after: { priority: ['NPC포상'] },
};
const historyContext = (options: Parameters<typeof createContext>[0] = {}) =>
createContext({
...options,
world: {
...baseWorld,
currentYear: 185,
currentMonth: 1,
meta: { ...baseWorld.meta, serverId: 'current-season' },
},
historyRows: options.historyRows ?? [historyRow],
});
it('lets an ordinary nation reader inspect the actor and changes without admin request/account fields', async () => {
const fixture = historyContext({ me: { ...baseGeneral, officerLevel: 2 } });
const caller = appRouter.createCaller(fixture.context);
const page = await caller.npc.policyHistory({ area: 'NPC_NATION_PRIORITY', limit: 1 });
expect(page.items[0]).toMatchObject({ actor: { name: '정책담당' }, revision: 2 });
const detail = await caller.npc.policyVersion({ id: historyRow.id });
expect(detail.fields).toEqual([
{ key: 'priority', beforeJson: '["NPC전방발령","NPC포상"]', afterJson: '["NPC포상"]', changed: true },
]);
expect(JSON.stringify(detail)).not.toMatch(/private-user|permission|requestId|inputSequence/);
});
it.each([
{ nationId: 0 },
{ officerLevel: 0 },
{ officerLevel: 1, meta: { belong: 0 } },
{ penalty: { noChief: true } },
])('denies history when current nation read permission is missing: %j', async (patch) => {
const fixture = historyContext({ me: { ...baseGeneral, ...patch } });
await expect(
appRouter.createCaller(fixture.context).npc.policyHistory({ area: 'NPC_VALUES' })
).rejects.toBeDefined();
});
it('scopes cursor and version lookups to the authenticated nation and current season', async () => {
const fixture = historyContext();
const caller = appRouter.createCaller(fixture.context);
await caller.npc.policyHistory({ area: 'NPC_VALUES', cursor: 7, limit: 1 });
const tx = await fixture.context.db.$transaction!(async (db) => db);
expect(tx.playAuditPolicy.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { serverId: 'current-season', nationId: 1, area: 'NPC_VALUES', revision: { lt: 7 } },
take: 2,
})
);
await caller.npc.policyVersion({ id: historyRow.id });
expect(tx.playAuditPolicy.findFirst).toHaveBeenCalledWith(
expect.objectContaining({
where: {
id: historyRow.id,
serverId: 'current-season',
nationId: 1,
area: { in: ['NPC_VALUES', 'NPC_NATION_PRIORITY', 'NPC_GENERAL_PRIORITY'] },
},
})
);
await expect(caller.npc.policyHistory({ area: 'NPC_VALUES', limit: 51 })).rejects.toMatchObject({
code: 'BAD_REQUEST',
});
await expect(caller.npc.policyVersion({ id: 'bad' })).rejects.toMatchObject({ code: 'BAD_REQUEST' });
});
it('requires authentication and rejects caller-supplied nation or defence history over HTTP', async () => {
const fixture = historyContext();
const app = fastify();
await app.register(fastifyTRPCPlugin, {
prefix: '/che/api/trpc',
trpcOptions: {
router: appRouter,
createContext: ({ req }: { req: { headers: Record<string, string | string[] | undefined> } }) => ({
...fixture.context,
auth: req.headers['x-session-token'] === 'fixture-session' ? auth : null,
}),
},
});
try {
const query = (input: object, authenticated = true) =>
app.inject({
url: `/che/api/trpc/npc.policyHistory?input=${encodeURIComponent(JSON.stringify(input))}`,
headers: authenticated ? { 'x-session-token': 'fixture-session' } : {},
});
expect((await query({ area: 'NPC_VALUES' }, false)).statusCode).toBe(401);
expect((await query({ area: 'NPC_VALUES' })).statusCode).toBe(200);
expect((await query({ area: 'DEFENCE' })).statusCode).toBe(400);
expect((await query({ area: 'NPC_VALUES', nationId: 2 })).statusCode).toBe(400);
} finally {
await app.close();
}
});
it('denies sanctioned sessions before reading history', async () => {
const fixture = historyContext();
fixture.context.auth = { ...auth, sanctions: { suspendedUntil: '2999-01-01T00:00:00Z' } };
await expect(
appRouter.createCaller(fixture.context).npc.policyHistory({ area: 'NPC_VALUES' })
).rejects.toMatchObject({ code: 'FORBIDDEN' });
expect(fixture.findFirst).not.toHaveBeenCalled();
});
it('paginates revisions without exposing a missing or unavailable version', async () => {
const fixture = historyContext({ historyRows: [historyRow, { ...historyRow, revision: 1 }] });
const page = await appRouter
.createCaller(fixture.context)
.npc.policyHistory({ area: 'NPC_NATION_PRIORITY', limit: 1 });
expect(page.items).toHaveLength(1);
expect(page.nextCursor).toBe(2);
const missing = historyContext({ historyRows: [] });
await expect(
appRouter.createCaller(missing.context).npc.policyVersion({ id: historyRow.id })
).rejects.toMatchObject({ code: 'NOT_FOUND' });
const noIdentity = createContext();
await expect(
appRouter.createCaller(noIdentity.context).npc.policyHistory({ area: 'NPC_VALUES' })
).resolves.toMatchObject({ coverage: 'IDENTITY_MISSING', items: [], nextCursor: null });
});
it('loads server and nation overrides while calculating legacy zero-value hints from nation tech', async () => { it('loads server and nation overrides while calculating legacy zero-value hints from nation tech', async () => {
const fixture = createContext(); const fixture = createContext();
const result = await appRouter.createCaller(fixture.context).npc.getPolicy(); const result = await appRouter.createCaller(fixture.context).npc.getPolicy();
+141 -2
View File
@@ -1,6 +1,6 @@
import { verifyVerticalAlignment } from '../../../tools/frontend-legacy-parity/verticalAlignment.js'; import { verifyVerticalAlignment } from '../../../tools/frontend-legacy-parity/verticalAlignment.js';
import { devices, expect, test, type Page, type Route } from '@playwright/test'; import { devices, expect, test, type Page, type Route } from '@playwright/test';
import { mkdir, readFile } from 'node:fs/promises'; import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { dirname, resolve } from 'node:path'; import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { gameProfile, gameTrpcRoute } from './gameTestPaths.js'; import { gameProfile, gameTrpcRoute } from './gameTestPaths.js';
@@ -14,6 +14,8 @@ type FixtureState = {
mutations: string[]; mutations: string[];
nationPriorityInput?: string[]; nationPriorityInput?: string[];
currentNationPriority?: string[]; currentNationPriority?: string[];
currentGeneralPriority?: string[];
historyVersion?: number;
}; };
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../..'); const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../..');
@@ -131,7 +133,7 @@ const policyFixture = (state: FixtureState) => ({
currentNationPriority: state.currentNationPriority ?? nationPriority, currentNationPriority: state.currentNationPriority ?? nationPriority,
availableNationPriorityItems: nationPriority, availableNationPriorityItems: nationPriority,
defaultGeneralActionPriority: generalPriority, defaultGeneralActionPriority: generalPriority,
currentGeneralActionPriority: generalPriority, currentGeneralActionPriority: state.currentGeneralPriority ?? generalPriority,
availableGeneralActionPriorityItems: generalPriority, availableGeneralActionPriorityItems: generalPriority,
lastSetters: { lastSetters: {
policy: { setter: null, date: null }, policy: { setter: null, date: null },
@@ -175,6 +177,41 @@ const installFixture = async (page: Page, state: FixtureState) => {
? errorResponse(operation, '권한이 부족합니다.', 'FORBIDDEN') ? errorResponse(operation, '권한이 부족합니다.', 'FORBIDDEN')
: response(policyFixture(state)); : response(policyFixture(state));
} }
if (operation === 'npc.policyHistory') {
return response({
coverage: 'RECORDED_VERSIONS_ONLY',
nextCursor: null,
items: [
{
id: 'a'.repeat(64),
revision: state.historyVersion ?? 2,
year: 185,
month: 1,
source: 'CHANGE',
actor: { name: '정책담당', generalId: 22 },
},
],
});
}
if (operation === 'npc.policyVersion') {
return response({
id: 'a'.repeat(64),
revision: state.historyVersion ?? 2,
year: 185,
month: 1,
source: 'CHANGE',
createdAt: '2026-10-22T00:00:00Z',
actor: { name: '정책담당', generalId: 22 },
fields: [
{
key: 'priority',
beforeJson: '["NPC전방발령","NPC포상"]',
afterJson: '["NPC포상"]',
changed: true,
},
],
});
}
if ( if (
operation === 'npc.setNationPolicy' || operation === 'npc.setNationPolicy' ||
operation === 'npc.setNationPriority' || operation === 'npc.setNationPriority' ||
@@ -188,6 +225,11 @@ const installFixture = async (page: Page, state: FixtureState) => {
state.failNextMutation = false; state.failNextMutation = false;
return errorResponse(operation, '권한이 부족합니다.', 'FORBIDDEN'); return errorResponse(operation, '권한이 부족합니다.', 'FORBIDDEN');
} }
state.historyVersion = (state.historyVersion ?? 2) + 1;
if (operation === 'npc.setNationPriority' && Array.isArray(jsonInput))
state.currentNationPriority = jsonInput.map(String);
if (operation === 'npc.setGeneralPriority' && Array.isArray(jsonInput))
state.currentGeneralPriority = jsonInput.map(String);
return response({ ok: true }); return response({ ok: true });
} }
return errorResponse(operation, `Unhandled fixture operation: ${operation}`); return errorResponse(operation, `Unhandled fixture operation: ${operation}`);
@@ -206,6 +248,103 @@ const screenshot = async (page: Page, name: string) => {
await page.screenshot({ path: resolve(artifactRoot, name), fullPage: true }); await page.screenshot({ path: resolve(artifactRoot, name), fullPage: true });
}; };
for (const width of [500, 1000]) {
test(`일반 사용자가 정책 변경자와 전후 이력을 조회하고 새로고침한다 ${width}px`, async ({ page }, testInfo) => {
const state: FixtureState = { permissionLevel: 1, mutations: [], currentNationPriority: ['NPC포상'] };
await installFixture(page, state);
await page.setViewportSize({ width, height: 900 });
await gotoPolicy(page);
await page.getByRole('button', { name: '정책 변경 이력 조회' }).click();
const history = page.getByRole('region', { name: 'NPC 정책 변경 이력', exact: true });
await expect(history).toContainText('정책담당 (#22)');
await history.getByLabel('이력 종류').selectOption('NPC_NATION_PRIORITY');
await history.getByRole('button', { name: '버전 2', exact: true }).click();
const detail = page.getByRole('region', { name: '정책 변경 내용', exact: true });
await expect(detail).toContainText('["NPC전방발령","NPC포상"]');
await expect(detail).toContainText('["NPC포상"]');
await history.getByLabel('이력 종류').focus();
await expect(history.getByLabel('이력 종류')).toBeFocused();
const measurements = await history.evaluate((element) => {
const rect = element.getBoundingClientRect();
const cell = element.querySelector('td')!;
return {
rect: rect.toJSON(),
style: {
verticalAlign: getComputedStyle(cell).verticalAlign,
whiteSpace: getComputedStyle(cell.querySelector('pre')!).whiteSpace,
},
html: element.outerHTML,
overflow: document.documentElement.scrollWidth > innerWidth,
};
});
expect(measurements.style).toEqual({ verticalAlign: 'middle', whiteSpace: 'pre-wrap' });
expect(measurements.overflow).toBe(false);
await testInfo.attach('history-dom-geometry-style', {
body: JSON.stringify(measurements, null, 2),
contentType: 'application/json',
});
await writeFile(testInfo.outputPath('history-dom-geometry-style.json'), JSON.stringify(measurements, null, 2));
await page.screenshot({ path: testInfo.outputPath(`npc-history-${width}.png`), fullPage: true });
state.historyVersion = 3;
await history.getByRole('button', { name: '이력 새로고침' }).click();
await expect(history.getByRole('button', { name: '버전 3', exact: true })).toBeVisible();
await page.reload();
const nationPanel = page.locator('.priority-panel').first();
await expect(
nationPanel.locator('.priority-column').nth(1).getByText('NPC전방발령', { exact: true })
).toHaveCount(0);
await expect(
nationPanel.locator('.priority-column').first().getByText('NPC전방발령', { exact: true })
).toBeVisible();
});
}
test('발령 비활성화와 징병 우선순위를 저장한 뒤 새로고침해도 유지되고 열린 이력이 갱신된다', async ({ page }) => {
const state: FixtureState = {
permissionLevel: 4,
mutations: [],
currentNationPriority: ['NPC전방발령', 'NPC포상'],
currentGeneralPriority: ['출병', '일반내정', '징병'],
};
await installFixture(page, state);
await page.setViewportSize({ width: 1000, height: 900 });
await gotoPolicy(page);
await page.getByRole('button', { name: '정책 변경 이력 조회' }).click();
const history = page.getByRole('region', { name: 'NPC 정책 변경 이력', exact: true });
await expect(history.getByRole('button', { name: '버전 2', exact: true })).toBeVisible();
const nationPanel = page.locator('.priority-panel').first();
await nationPanel
.locator('.priority-column')
.nth(1)
.getByText('NPC전방발령', { exact: true })
.dragTo(nationPanel.locator('.priority-column').first().locator('.priority-list'));
await nationPanel.locator('.submit_btn').click();
await acceptAppConfirmation(page, '저장할까요?');
await expect(history.getByRole('button', { name: '버전 3', exact: true })).toBeVisible();
const generalPanel = page.locator('.priority-panel').nth(1);
const active = generalPanel.locator('.priority-column').nth(1);
// 목록을 화면 중앙에 놓아 창 가장자리의 Sortable auto-scroll을 피한다.
await active
.locator('.priority-list')
.evaluate((element) => element.scrollIntoView({ block: 'center', behavior: 'instant' }));
await touchDrag(page, active.getByText('징병', { exact: true }), active.getByText('출병', { exact: true }), {
targetYRatio: 0.1,
});
await expect(active.locator('.priority_info > span:nth-child(2)').first()).toHaveText('징병');
await generalPanel.locator('.submit_btn').click();
await acceptAppConfirmation(page, '저장할까요?');
await expect(history.getByRole('button', { name: '버전 4', exact: true })).toBeVisible();
expect(state.currentNationPriority).toEqual(['NPC포상']);
expect(state.currentGeneralPriority).toEqual(['징병', '출병', '일반내정']);
await page.reload();
await expect(
nationPanel.locator('.priority-column').first().getByText('NPC전방발령', { exact: true })
).toBeVisible();
await expect(
generalPanel.locator('.priority-column').nth(1).locator('.priority_info > span:nth-child(2)').first()
).toHaveText('징병');
});
test('desktop geometry, typography, textures, drag, focus, tooltip, and successful save match the reference', async ({ test('desktop geometry, typography, textures, drag, focus, tooltip, and successful save match the reference', async ({
page, page,
}) => { }) => {
@@ -0,0 +1,191 @@
<script setup lang="ts">
import { ref, watch } from 'vue';
import { trpc } from '../utils/trpc';
import { fieldLabels } from './playAudit/policyLabels';
const props = defineProps<{ revision: number }>();
type History = Awaited<ReturnType<typeof trpc.npc.policyHistory.query>>;
type Version = Awaited<ReturnType<typeof trpc.npc.policyVersion.query>>;
const open = ref(false);
const area = ref<'NPC_VALUES' | 'NPC_NATION_PRIORITY' | 'NPC_GENERAL_PRIORITY'>('NPC_VALUES');
const history = ref<History | null>(null);
const version = ref<Version | null>(null);
const loading = ref(false);
const error = ref('');
const labels = { BASELINE: '최초 관측', CHANGE: '변경', OBSERVED_GAP: '관측 누락 이후 기준' };
let generation = 0;
const load = async (append = false) => {
const request = ++generation;
loading.value = true;
error.value = '';
if (!append) {
history.value = null;
version.value = null;
}
try {
const response = await trpc.npc.policyHistory.query({
area: area.value,
cursor: append ? (history.value?.nextCursor ?? undefined) : undefined,
});
if (request === generation)
history.value = {
...response,
items: append ? [...(history.value?.items ?? []), ...response.items] : response.items,
};
} catch (cause) {
if (request === generation)
error.value = cause instanceof Error ? cause.message : '이력을 조회하지 못했습니다.';
} finally {
if (request === generation) loading.value = false;
}
};
const select = async (id: string) => {
const request = ++generation;
loading.value = true;
error.value = '';
version.value = null;
try {
const response = await trpc.npc.policyVersion.query({ id });
if (request === generation) version.value = response;
} catch (cause) {
if (request === generation)
error.value = cause instanceof Error ? cause.message : '변경 내용을 조회하지 못했습니다.';
} finally {
if (request === generation) loading.value = false;
}
};
watch([open, area, () => props.revision], () => {
if (open.value) void load();
else {
++generation;
loading.value = false;
history.value = null;
version.value = null;
}
});
</script>
<template>
<section class="npc-policy-history" aria-label="NPC 정책 변경 이력">
<button class="legacy-button" :aria-expanded="open" @click="open = !open">
정책 변경 이력 {{ open ? '닫기' : '조회' }}
</button>
<div v-if="open">
<p>자국의 현재 기수 기록입니다. 최초 관측 이전의 변경은 복원하지 않습니다.</p>
<p>우선순위는 실행을 시도하는 순서입니다. 징병은 전쟁 상태·병력·인구·자원 조건을 만족해야 합니다.</p>
<p>0이면 자동 계산하는 항목은 저장값이 같아도 기술·내정 비용에 따라 기준이 달라질 수 있습니다.</p>
<label
>이력 종류
<select v-model="area">
<option value="NPC_VALUES">국가 정책</option>
<option value="NPC_NATION_PRIORITY">국가턴 우선순위</option>
<option value="NPC_GENERAL_PRIORITY">일반턴 우선순위</option>
</select>
</label>
<button class="legacy-button" :disabled="loading" @click="load()">이력 새로고침</button>
<p v-if="loading" role="status">조회 중…</p>
<p v-if="error" role="alert">{{ error }}</p>
<template v-if="history">
<p v-if="history.historyGap" role="status">
{{ history.historyGap.firstYear }}년 {{ history.historyGap.firstMonth }}월 이후 일부 기록에 누락이
있습니다. 이력이 없다는 사실만으로 변경이 없었다고 판단할 수 없습니다.
</p>
<p v-if="history.coverage === 'IDENTITY_MISSING'">기수 식별자가 없어 이력을 조회할 수 없습니다.</p>
<p v-else-if="!history.items.length">기록된 정책 이력이 없습니다.</p>
<ol v-else>
<li v-for="item in history.items" :key="item.id">
<button class="legacy-button" :disabled="loading" @click="select(item.id)">
버전 {{ item.revision }}
</button>
{{ item.year }}년 {{ item.month }}월 · {{ labels[item.source] }} ·
{{ item.actor ? `${item.actor.name} (#${item.actor.generalId})` : '변경 주체 미상' }}
</li>
</ol>
<button
v-if="history.nextCursor !== null"
class="legacy-button"
:disabled="loading"
@click="load(true)"
>
이전 기록 더 보기
</button>
</template>
<section v-if="version" aria-label="정책 변경 내용">
<h3>버전 {{ version.revision }} · {{ version.actor?.name ?? '변경 주체 미상' }}</h3>
<p>{{ version.year }}년 {{ version.month }}월 · 기록 시각 {{ version.createdAt }}</p>
<p v-if="version.source !== 'CHANGE'">관측 기준입니다. 이전 값과 변경 주체를 추정하지 않습니다.</p>
<p>null은 개별 설정 없이 기본값을 상속함을 뜻합니다.</p>
<div class="table-scroll" tabindex="0" aria-label="정책 변경 전후 값">
<table>
<thead>
<tr>
<th>설정</th>
<th>변경 전</th>
<th>변경 후</th>
</tr>
</thead>
<tbody>
<tr v-for="field in version.fields" :key="field.key" :class="{ changed: field.changed }">
<th scope="row">
{{ fieldLabels[field.key] ?? field.key }}{{ field.changed ? ' (변경)' : '' }}
</th>
<td>
<pre>{{ field.beforeJson ?? '관측하지 않음' }}</pre>
</td>
<td>
<pre>{{ field.afterJson }}</pre>
</td>
</tr>
</tbody>
</table>
</div>
</section>
</div>
</section>
</template>
<style scoped>
.npc-policy-history {
margin-top: 16px;
padding: 8px;
border: 1px solid gray;
}
select,
button {
margin: 4px;
vertical-align: middle;
}
li {
margin-block: 6px;
}
.table-scroll {
overflow-x: auto;
}
table {
width: 100%;
border-collapse: collapse;
}
th,
td {
border: 1px solid gray;
padding: 6px;
text-align: left;
vertical-align: middle;
}
pre {
white-space: pre-wrap;
overflow-wrap: anywhere;
min-width: 100px;
margin: 0;
font: inherit;
}
.changed {
background: #343425;
}
[role='alert'] {
color: #ffb9b9;
}
h3 {
font-size: var(--sammo-font-size-normal);
}
</style>
@@ -3,12 +3,14 @@ import { usePageExit } from '../composables/usePageExit';
import { computed, onMounted, ref, watch } from 'vue'; import { computed, onMounted, ref, watch } from 'vue';
import NpcPolicyHistory from '../components/NpcPolicyHistory.vue';
import SortableStringList from '../components/ui/SortableStringList'; import SortableStringList from '../components/ui/SortableStringList';
import { useGameFeedback } from '../composables/useGameFeedback'; import { useGameFeedback } from '../composables/useGameFeedback';
import { npcPriorityHelp } from '../utils/npcPriorityHelp'; import { npcPriorityHelp } from '../utils/npcPriorityHelp';
import { trpc } from '../utils/trpc'; import { trpc } from '../utils/trpc';
const { pageExitLabel, exitPage } = usePageExit(); const { pageExitLabel, exitPage } = usePageExit();
const historyRevision = ref(0);
type NpcPolicyResponse = Awaited<ReturnType<typeof trpc.npc.getPolicy.query>>; type NpcPolicyResponse = Awaited<ReturnType<typeof trpc.npc.getPolicy.query>>;
type NationPolicy = NpcPolicyResponse['currentNationPolicy']; type NationPolicy = NpcPolicyResponse['currentNationPolicy'];
@@ -308,6 +310,7 @@ const submitPolicy = async () => {
try { try {
await trpc.npc.setNationPolicy.mutate(policyDraft.value); await trpc.npc.setNationPolicy.mutate(policyDraft.value);
lastSavedPolicy.value = clonePolicy(policyDraft.value); lastSavedPolicy.value = clonePolicy(policyDraft.value);
historyRevision.value += 1;
showSuccessToast('NPC 정책이 반영되었습니다.'); showSuccessToast('NPC 정책이 반영되었습니다.');
} catch (caught) { } catch (caught) {
showErrorToast(`설정하지 못했습니다: ${resolveErrorMessage(caught)}`); showErrorToast(`설정하지 못했습니다: ${resolveErrorMessage(caught)}`);
@@ -357,6 +360,7 @@ const submitPriority = async (section: PrioritySectionKey) => {
await trpc.npc.setGeneralPriority.mutate(state.active); await trpc.npc.setGeneralPriority.mutate(state.active);
lastSavedGeneralPriority.value = [...state.active]; lastSavedGeneralPriority.value = [...state.active];
} }
historyRevision.value += 1;
showSuccessToast('NPC 정책이 반영되었습니다.'); showSuccessToast('NPC 정책이 반영되었습니다.');
} catch (caught) { } catch (caught) {
showErrorToast(`설정하지 못했습니다: ${resolveErrorMessage(caught)}`); showErrorToast(`설정하지 못했습니다: ${resolveErrorMessage(caught)}`);
@@ -547,6 +551,7 @@ const submitPriority = async (section: PrioritySectionKey) => {
</section> </section>
</div> </div>
</section> </section>
<NpcPolicyHistory v-if="data" :key="data.nationId" :revision="historyRevision" />
<div class="sortable-compat-controls" aria-hidden="true"> <div class="sortable-compat-controls" aria-hidden="true">
<button type="button" tabindex="-1" /> <button type="button" tabindex="-1" />
<input v-for="index in 20" :key="index" type="hidden" /> <input v-for="index in 20" :key="index" type="hidden" />
@@ -30,3 +30,4 @@ TURN_DIFFERENTIAL_DATABASE_URL core
TURN_FULL_LIFECYCLE_PERSISTENCE_DATABASE_URL reference_full_lifecycle TURN_FULL_LIFECYCLE_PERSISTENCE_DATABASE_URL reference_full_lifecycle
WEB_PUSH_GATEWAY_DATABASE_URL web_push_gateway WEB_PUSH_GATEWAY_DATABASE_URL web_push_gateway
TOURNAMENT_RECOVERY_DATABASE_URL external_fixture TOURNAMENT_RECOVERY_DATABASE_URL external_fixture
NPC_POLICY_DATABASE_URL external_fixture
1 # Environment variable Execution mode
30 TURN_FULL_LIFECYCLE_PERSISTENCE_DATABASE_URL reference_full_lifecycle
31 WEB_PUSH_GATEWAY_DATABASE_URL web_push_gateway
32 TOURNAMENT_RECOVERY_DATABASE_URL external_fixture
33 NPC_POLICY_DATABASE_URL external_fixture
+33
View File
@@ -0,0 +1,33 @@
import { spawnSync } from 'node:child_process';
// 개발 전용 .env.ci를 node --env-file로 전달한다. 매번 새 schema를 만들며
// 기존 게임 schema·Redis key를 초기화하거나 volume을 삭제하지 않는다.
for (const key of [
'POSTGRES_HOST',
'POSTGRES_PORT',
'POSTGRES_DB',
'POSTGRES_USER',
'POSTGRES_PASSWORD',
'REDIS_URL',
]) {
if (!process.env[key]) throw new Error(`Missing environment variable: ${key}`);
}
const schema = `ci_${Date.now()}_${process.pid}_npc_policy_fixture`;
const url = new URL('postgresql://localhost');
url.hostname = process.env.POSTGRES_HOST;
url.port = process.env.POSTGRES_PORT;
url.username = process.env.POSTGRES_USER;
url.password = process.env.POSTGRES_PASSWORD;
url.pathname = `/${process.env.POSTGRES_DB}`;
url.searchParams.set('schema', schema);
const env = { ...process.env, DATABASE_URL: url.href, NPC_POLICY_DATABASE_URL: url.href, POSTGRES_SCHEMA: schema };
console.info(`NPC policy verification schema: ${schema}`);
for (const args of [
['--filter', '@sammo-ts/infra', 'prisma:migrate:deploy:game'],
['--filter', '@sammo-ts/infra', 'prisma:migrate:deploy:game'],
['--filter', '@sammo-ts/game-api', 'test', 'npcPolicyLifecycle.integration.test.ts'],
]) {
const result = spawnSync('pnpm', args, { env, stdio: 'inherit' });
if (result.error) throw new Error(`Could not start verification command: ${result.error.code}`);
if (result.status !== 0) process.exit(result.status ?? 1);
}