diff --git a/app/game-api/package.json b/app/game-api/package.json index ac0975f4..07f3fc79 100644 --- a/app/game-api/package.json +++ b/app/game-api/package.json @@ -40,10 +40,10 @@ "@trpc/server": "^11.8.1", "date-fns": "^4.1.0", "es-toolkit": "^1.43.0", - "fastify": "^5.6.2", + "fastify": "^5.12.5", "redis": "^5.10.0", - "sanitize-html": "2.17.6", - "sharp": "^0.35.0", + "sanitize-html": "2.17.7", + "sharp": "^0.35.5", "zod": "^4.3.5" } } diff --git a/app/game-api/src/router/npc/history.ts b/app/game-api/src/router/npc/history.ts new file mode 100644 index 00000000..2d70d9ca --- /dev/null +++ b/app/game-api/src/router/npc/history.ts @@ -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 => { + 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); + }) + ); diff --git a/app/game-api/src/router/npc/index.ts b/app/game-api/src/router/npc/index.ts index 94dbe978..430c3001 100644 --- a/app/game-api/src/router/npc/index.ts +++ b/app/game-api/src/router/npc/index.ts @@ -15,6 +15,7 @@ import { loadUnitSetDefinitionByName } from '@sammo-ts/game-engine/scenario/unit import type { GameApiContext } from '../../context.js'; import { getMyGeneral } from '../shared/general.js'; import { resolveSecretPermission } from '../shared/secretPermission.js'; +import { policyHistory, policyVersion } from './history.js'; type SetterInfo = { setter: string | null; @@ -356,6 +357,8 @@ const requestNpcPolicyMutation = async ( }; export const npcRouter = router({ + policyHistory, + policyVersion, getPolicy: authedProcedure.query(async ({ ctx }) => { const general = await getMyGeneral(ctx); if (general.nationId <= 0) { diff --git a/app/game-api/test/npcPolicyLifecycle.integration.test.ts b/app/game-api/test/npcPolicyLifecycle.integration.test.ts new file mode 100644 index 00000000..edb24f27 --- /dev/null +++ b/app/game-api/test/npcPolicyLifecycle.integration.test.ts @@ -0,0 +1,372 @@ +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 | 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: 5, + 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 = { + 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 }; + }): 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).toContainEqual(expect.objectContaining({ generalId: 2, kind: 'nation' })); + expect( + actions.filter((action) => action.generalId === 2).some((action) => action.actionKey === 'che_징병') + ).toBe(false); + await runtime.hooks!.flushChanges!(result); + const nationDecision = await db.playAuditDecision.findFirstOrThrow({ + where: { generalId: 2, phase: 'nation' }, + include: { chunks: { orderBy: { ordinal: 'asc' } } }, + }); + const procedures = nationDecision.chunks.flatMap((chunk) => + Array.isArray(chunk.steps) + ? chunk.steps + .map(asRecord) + .filter((step) => step.kind === 'PROCEDURE_START') + .map((step) => step.procedure) + : [] + ); + expect(procedures).toEqual(['NPC포상']); + 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 +); diff --git a/app/game-api/test/npcPolicyRouter.test.ts b/app/game-api/test/npcPolicyRouter.test.ts index 918ced09..c50bc6a3 100644 --- a/app/game-api/test/npcPolicyRouter.test.ts +++ b/app/game-api/test/npcPolicyRouter.test.ts @@ -1,4 +1,6 @@ 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 { RedisConnector } from '@sammo-ts/infra'; @@ -96,10 +98,16 @@ const createContext = ( options: { me?: GeneralRow; nation?: typeof baseNation; - world?: typeof baseWorld; + world?: { + config: typeof baseWorld.config; + meta: Record; + currentYear?: number; + currentMonth?: number; + }; requestCommand?: ReturnType; troopRows?: Array<{ troopLeaderId: number }>; cityRows?: Array<{ id: number }>; + historyRows?: Array>; } = {} ): { context: GameApiContext; findFirst: ReturnType; requestCommand: ReturnType } => { const requestCommand = @@ -117,6 +125,11 @@ const createContext = ( worldState: { findFirst: vi.fn(async () => options.world ?? baseWorld) }, troop: { findMany: vi.fn(async () => options.troopRows ?? [{ troopLeaderId: 101 }]) }, 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 (read: (tx: object) => Promise): Promise => read(db), }; const redisClient = { get: async () => null, set: async () => null }; return { @@ -140,6 +153,146 @@ const createContext = ( }; 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[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 } }) => ({ + ...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 () => { const fixture = createContext(); const result = await appRouter.createCaller(fixture.context).npc.getPolicy(); diff --git a/app/game-frontend/e2e/npcPolicy.spec.ts b/app/game-frontend/e2e/npcPolicy.spec.ts index 59f8e5cd..398db675 100644 --- a/app/game-frontend/e2e/npcPolicy.spec.ts +++ b/app/game-frontend/e2e/npcPolicy.spec.ts @@ -1,6 +1,6 @@ import { verifyVerticalAlignment } from '../../../tools/frontend-legacy-parity/verticalAlignment.js'; 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 { fileURLToPath } from 'node:url'; import { gameProfile, gameTrpcRoute } from './gameTestPaths.js'; @@ -14,6 +14,8 @@ type FixtureState = { mutations: string[]; nationPriorityInput?: string[]; currentNationPriority?: string[]; + currentGeneralPriority?: string[]; + historyVersion?: number; }; const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../..'); @@ -131,7 +133,7 @@ const policyFixture = (state: FixtureState) => ({ currentNationPriority: state.currentNationPriority ?? nationPriority, availableNationPriorityItems: nationPriority, defaultGeneralActionPriority: generalPriority, - currentGeneralActionPriority: generalPriority, + currentGeneralActionPriority: state.currentGeneralPriority ?? generalPriority, availableGeneralActionPriorityItems: generalPriority, lastSetters: { policy: { setter: null, date: null }, @@ -175,6 +177,41 @@ const installFixture = async (page: Page, state: FixtureState) => { ? errorResponse(operation, '권한이 부족합니다.', 'FORBIDDEN') : 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 ( operation === 'npc.setNationPolicy' || operation === 'npc.setNationPriority' || @@ -188,6 +225,11 @@ const installFixture = async (page: Page, state: FixtureState) => { state.failNextMutation = false; 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 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 }); }; +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 ({ page, }) => { diff --git a/app/game-frontend/package.json b/app/game-frontend/package.json index 82e0bf21..020ddced 100644 --- a/app/game-frontend/package.json +++ b/app/game-frontend/package.json @@ -35,14 +35,14 @@ "dependencies": { "@sammo-ts/common": "workspace:*", "@sammo-ts/logic": "workspace:*", - "@tiptap/extension-image": "^3.5.0", - "@tiptap/extension-link": "^3.5.0", - "@tiptap/extension-placeholder": "^3.5.0", - "@tiptap/extension-text-align": "^3.5.0", - "@tiptap/extension-text-style": "^3.5.0", - "@tiptap/extension-underline": "^3.5.0", - "@tiptap/starter-kit": "^3.5.0", - "@tiptap/vue-3": "^3.5.0", + "@tiptap/extension-image": "^3.30.5", + "@tiptap/extension-link": "^3.30.5", + "@tiptap/extension-placeholder": "^3.30.5", + "@tiptap/extension-text-align": "^3.30.5", + "@tiptap/extension-text-style": "^3.30.5", + "@tiptap/extension-underline": "^3.30.5", + "@tiptap/starter-kit": "^3.30.5", + "@tiptap/vue-3": "^3.30.5", "@trpc/client": "^11.8.1", "@trpc/server": "^11.8.1", "@vueuse/core": "^14.1.0", @@ -52,7 +52,7 @@ "mitt": "^3.0.1", "pinia": "^3.0.4", "tippy.js": "6.3.7", - "vue": "^3.5.26", + "vue": "^3.5.42", "vue-draggable-plus": "0.6.1", "vue-router": "^4.6.4", "zod": "^4.3.5" diff --git a/app/game-frontend/src/components/NpcPolicyHistory.vue b/app/game-frontend/src/components/NpcPolicyHistory.vue new file mode 100644 index 00000000..87338644 --- /dev/null +++ b/app/game-frontend/src/components/NpcPolicyHistory.vue @@ -0,0 +1,191 @@ + + + + + diff --git a/app/game-frontend/src/views/NpcControlView.vue b/app/game-frontend/src/views/NpcControlView.vue index 00d5030b..4e12f6b1 100644 --- a/app/game-frontend/src/views/NpcControlView.vue +++ b/app/game-frontend/src/views/NpcControlView.vue @@ -3,12 +3,14 @@ import { usePageExit } from '../composables/usePageExit'; import { computed, onMounted, ref, watch } from 'vue'; +import NpcPolicyHistory from '../components/NpcPolicyHistory.vue'; import SortableStringList from '../components/ui/SortableStringList'; import { useGameFeedback } from '../composables/useGameFeedback'; import { npcPriorityHelp } from '../utils/npcPriorityHelp'; import { trpc } from '../utils/trpc'; const { pageExitLabel, exitPage } = usePageExit(); +const historyRevision = ref(0); type NpcPolicyResponse = Awaited>; type NationPolicy = NpcPolicyResponse['currentNationPolicy']; @@ -308,6 +310,7 @@ const submitPolicy = async () => { try { await trpc.npc.setNationPolicy.mutate(policyDraft.value); lastSavedPolicy.value = clonePolicy(policyDraft.value); + historyRevision.value += 1; showSuccessToast('NPC 정책이 반영되었습니다.'); } catch (caught) { showErrorToast(`설정하지 못했습니다: ${resolveErrorMessage(caught)}`); @@ -357,6 +360,7 @@ const submitPriority = async (section: PrioritySectionKey) => { await trpc.npc.setGeneralPriority.mutate(state.active); lastSavedGeneralPriority.value = [...state.active]; } + historyRevision.value += 1; showSuccessToast('NPC 정책이 반영되었습니다.'); } catch (caught) { showErrorToast(`설정하지 못했습니다: ${resolveErrorMessage(caught)}`); @@ -547,6 +551,7 @@ const submitPriority = async (section: PrioritySectionKey) => { +