diff --git a/app/game-api/src/router/npc/index.ts b/app/game-api/src/router/npc/index.ts index e4d77815..87cfe3b2 100644 --- a/app/game-api/src/router/npc/index.ts +++ b/app/game-api/src/router/npc/index.ts @@ -1,5 +1,3 @@ -import path from 'node:path'; - import { TRPCError } from '@trpc/server'; import { z } from 'zod'; @@ -165,8 +163,6 @@ const FLOAT_POLICY_KEYS = ['safeRecruitCityPopulationRatio'] as const; type NumericPolicyKey = (typeof INTEGER_POLICY_KEYS)[number]; type FloatPolicyKey = (typeof FLOAT_POLICY_KEYS)[number]; -const UNIT_SET_ROOT = path.resolve(process.cwd(), 'resources', 'unitset'); - const readNumber = (value: unknown, fallback = 0): number => { if (typeof value === 'number' && Number.isFinite(value)) { return value; @@ -353,8 +349,8 @@ const buildZeroPolicy = async ( } ): Promise => { const { statMax, statNpcMax, nationTech, develCost, defaultCrewTypeId, unitSetName } = options; - const unitSet = await loadUnitSetDefinitionByName(unitSetName, { unitSetRoot: UNIT_SET_ROOT }); - const crewType = findCrewTypeById(unitSet, defaultCrewTypeId); + const unitSet = await loadUnitSetDefinitionByName(unitSetName); + const crewType = findCrewTypeById(unitSet, defaultCrewTypeId || unitSet.defaultCrewTypeId || 0); const techCost = getTechCost(nationTech); const next = clonePolicy(policy); @@ -364,7 +360,7 @@ const buildZeroPolicy = async ( if (next.reqNPCWarGold === 0 || next.reqNPCWarRice === 0) { const baseGold = crewType ? crewType.cost * techCost * statNpcMax : 0; - const baseRice = statNpcMax; + const baseRice = crewType ? crewType.rice * techCost * statNpcMax : 0; if (next.reqNPCWarGold === 0) { next.reqNPCWarGold = roundTo(baseGold * 4, -2); } @@ -375,7 +371,7 @@ const buildZeroPolicy = async ( if (next.reqHumanWarUrgentGold === 0 || next.reqHumanWarUrgentRice === 0) { const baseGold = crewType ? crewType.cost * techCost * statMax : 0; - const baseRice = statMax; + const baseRice = crewType ? crewType.rice * techCost * statMax : 0; if (next.reqHumanWarUrgentGold === 0) { next.reqHumanWarUrgentGold = roundTo(baseGold * 6, -2); } @@ -415,8 +411,6 @@ const resolveSetterInfo = (policy: Record, kind: 'value' | 'pri }; }; -const ensureUniquePriority = (priority: string[]): string[] => Array.from(new Set(priority)); - const validateGeneralPriority = (priority: string[]): string | null => { const orderRequired: Array<[string, string]> = [['출병', '일반내정']]; const mustHave = new Set(['출병', '일반내정']); @@ -461,6 +455,7 @@ export const npcRouter = router({ id: true, name: true, level: true, + tech: true, meta: true, }, }), @@ -508,9 +503,9 @@ export const npcRouter = router({ const stat = resolveScenarioStat(config); const env = resolveCommandEnv(config); const unitSetName = resolveUnitSetName(config, 'che'); - const nationTech = readNumber(asRecord(nationMeta).tech, 0); + const nationTech = readNumber(nation.tech, 0); - const zeroPolicy = await buildZeroPolicy(defaultNationPolicy, { + const zeroPolicy = await buildZeroPolicy(DEFAULT_NATION_POLICY, { statMax: stat.max, statNpcMax: stat.npcMax, nationTech, @@ -542,277 +537,272 @@ export const npcRouter = router({ permissionLevel, }; }), - setNationPolicy: authedProcedure - .input(z.record(z.string(), z.unknown())) - .mutation(async ({ ctx, input }) => { - const general = await getMyGeneral(ctx); - if (general.nationId <= 0) { - throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'Nation membership required.' }); - } + setNationPolicy: authedProcedure.input(z.record(z.string(), z.unknown())).mutation(async ({ ctx, input }) => { + const general = await getMyGeneral(ctx); + if (general.nationId <= 0) { + throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'Nation membership required.' }); + } - const nation = await ctx.db.nation.findUnique({ - where: { id: general.nationId }, - select: { id: true, meta: true }, - }); - if (!nation) { - throw new TRPCError({ code: 'NOT_FOUND', message: 'Nation not found' }); - } + const nation = await ctx.db.nation.findUnique({ + where: { id: general.nationId }, + select: { id: true, meta: true }, + }); + if (!nation) { + throw new TRPCError({ code: 'NOT_FOUND', message: 'Nation not found' }); + } - const permissionLevel = resolveSecretPermission( - { - nationId: general.nationId, - officerLevel: general.officerLevel, - meta: general.meta, - penalty: general.penalty, - }, - nation.meta - ); - if (permissionLevel < 3) { - throw new TRPCError({ code: 'FORBIDDEN', message: '권한이 부족합니다.' }); - } + const permissionLevel = resolveSecretPermission( + { + nationId: general.nationId, + officerLevel: general.officerLevel, + meta: general.meta, + penalty: general.penalty, + }, + nation.meta + ); + if (permissionLevel < 3) { + throw new TRPCError({ code: 'FORBIDDEN', message: '권한이 부족합니다.' }); + } - const keys = Object.keys(input); - for (const key of keys) { - if (!NATION_POLICY_KEYS.has(key as keyof NationPolicy)) { + const keys = Object.keys(input); + for (const key of keys) { + if (!NATION_POLICY_KEYS.has(key as keyof NationPolicy)) { + throw new TRPCError({ code: 'BAD_REQUEST', message: `${key}는 올바른 정책값이 아닙니다.` }); + } + } + + const troopRows = await ctx.db.troop.findMany({ + where: { nationId: general.nationId }, + select: { troopLeaderId: true }, + }); + const cityRows = await ctx.db.city.findMany({ select: { id: true } }); + + const troopSet = new Set(troopRows.map((row) => row.troopLeaderId)); + const citySet = new Set(cityRows.map((row) => row.id)); + const assigned = new Set(); + + const nationMeta = asRecord(nation.meta); + const policyRoot = asRecord(nationMeta.npc_nation_policy); + const nextValues = applyPolicyValues(DEFAULT_NATION_POLICY, asRecord(policyRoot.values)); + + for (const key of INTEGER_POLICY_KEYS) { + if (!(key in input)) { + continue; + } + const value = input[key]; + if (typeof value !== 'number' || !Number.isFinite(value) || !Number.isInteger(value)) { + throw new TRPCError({ code: 'BAD_REQUEST', message: `${key}는 올바른 값이 아닙니다.` }); + } + nextValues[key] = Math.max(0, value); + } + + for (const key of FLOAT_POLICY_KEYS) { + if (!(key in input)) { + continue; + } + const value = input[key]; + if (typeof value !== 'number' || !Number.isFinite(value)) { + throw new TRPCError({ code: 'BAD_REQUEST', message: `${key}는 올바른 값이 아닙니다.` }); + } + nextValues[key] = value; + } + + if ('CombatForce' in input) { + const rawCombat = input.CombatForce; + if (!isRecord(rawCombat)) { + throw new TRPCError({ code: 'BAD_REQUEST', message: 'CombatForce는 올바른 정책값이 아닙니다.' }); + } + const combatForce: Record = {}; + for (const [rawKey, rawValue] of Object.entries(rawCombat)) { + const leaderId = Number(rawKey); + if (!Number.isFinite(leaderId)) { + throw new TRPCError({ code: 'BAD_REQUEST', message: `${rawKey}는 올바른 부대가 아닙니다.` }); + } + if (!troopSet.has(leaderId)) { + throw new TRPCError({ code: 'BAD_REQUEST', message: `${leaderId}는 국가의 부대가 아닙니다.` }); + } + if (assigned.has(leaderId)) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: `부대(${leaderId})는 하나의 역할만 지정할 수 있습니다.`, + }); + } + if (!Array.isArray(rawValue) || rawValue.length < 2) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: `${leaderId}의 입력양식이 올바르지 않습니다.`, + }); + } + const fromCity = Number(rawValue[0]); + const toCity = Number(rawValue[1]); + if (!citySet.has(fromCity) || !citySet.has(toCity)) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: `${leaderId}의 도시 ${fromCity}, ${toCity}가 올바른 도시 번호가 아닙니다.`, + }); + } + combatForce[leaderId] = [fromCity, toCity]; + assigned.add(leaderId); + } + nextValues.CombatForce = combatForce; + } + + for (const key of ['SupportForce', 'DevelopForce'] as const) { + if (!(key in input)) { + continue; + } + const rawList = input[key]; + if (!Array.isArray(rawList)) { + throw new TRPCError({ code: 'BAD_REQUEST', message: `${key}는 올바른 정책값이 아닙니다.` }); + } + const list: number[] = []; + for (const rawValue of rawList) { + if (typeof rawValue !== 'number' || !Number.isFinite(rawValue)) { throw new TRPCError({ code: 'BAD_REQUEST', message: `${key}는 올바른 정책값이 아닙니다.` }); } - } - - const troopRows = await ctx.db.troop.findMany({ - where: { nationId: general.nationId }, - select: { troopLeaderId: true }, - }); - const cityRows = await ctx.db.city.findMany({ select: { id: true } }); - - const troopSet = new Set(troopRows.map((row) => row.troopLeaderId)); - const citySet = new Set(cityRows.map((row) => row.id)); - const assigned = new Set(); - - const nationMeta = asRecord(nation.meta); - const policyRoot = asRecord(nationMeta.npc_nation_policy); - const nextValues = applyPolicyValues(DEFAULT_NATION_POLICY, asRecord(policyRoot.values)); - - for (const key of INTEGER_POLICY_KEYS) { - if (!(key in input)) { - continue; + if (!troopSet.has(rawValue)) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: `${rawValue}는 국가의 부대가 아닙니다.`, + }); } - const value = input[key]; - if (typeof value !== 'number' || !Number.isFinite(value) || !Number.isInteger(value)) { - throw new TRPCError({ code: 'BAD_REQUEST', message: `${key}는 올바른 값이 아닙니다.` }); + if (assigned.has(rawValue)) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: `부대(${rawValue})는 하나의 역할만 지정할 수 있습니다.`, + }); } - nextValues[key] = Math.max(0, value); + assigned.add(rawValue); + list.push(rawValue); } - - for (const key of FLOAT_POLICY_KEYS) { - if (!(key in input)) { - continue; - } - const value = input[key]; - if (typeof value !== 'number' || !Number.isFinite(value)) { - throw new TRPCError({ code: 'BAD_REQUEST', message: `${key}는 올바른 값이 아닙니다.` }); - } - nextValues[key] = Math.max(0, value); + if (key === 'SupportForce') { + nextValues.SupportForce = list; + } else { + nextValues.DevelopForce = list; } + } - if ('CombatForce' in input) { - const rawCombat = input.CombatForce; - if (!isRecord(rawCombat)) { - throw new TRPCError({ code: 'BAD_REQUEST', message: 'CombatForce는 올바른 정책값이 아닙니다.' }); - } - const combatForce: Record = {}; - for (const [rawKey, rawValue] of Object.entries(rawCombat)) { - const leaderId = Number(rawKey); - if (!Number.isFinite(leaderId)) { - throw new TRPCError({ code: 'BAD_REQUEST', message: `${rawKey}는 올바른 부대가 아닙니다.` }); - } - if (!troopSet.has(leaderId)) { - throw new TRPCError({ code: 'BAD_REQUEST', message: `${leaderId}는 국가의 부대가 아닙니다.` }); - } - if (assigned.has(leaderId)) { - throw new TRPCError({ - code: 'BAD_REQUEST', - message: `부대(${leaderId})는 하나의 역할만 지정할 수 있습니다.`, - }); - } - if (!Array.isArray(rawValue) || rawValue.length < 2) { - throw new TRPCError({ code: 'BAD_REQUEST', message: `${leaderId}의 입력양식이 올바르지 않습니다.` }); - } - const fromCity = Number(rawValue[0]); - const toCity = Number(rawValue[1]); - if (!citySet.has(fromCity) || !citySet.has(toCity)) { - throw new TRPCError({ - code: 'BAD_REQUEST', - message: `${leaderId}의 도시 ${fromCity}, ${toCity}가 올바른 도시 번호가 아닙니다.`, - }); - } - combatForce[leaderId] = [fromCity, toCity]; - assigned.add(leaderId); - } - nextValues.CombatForce = combatForce; + const nextPolicyRoot = { + ...policyRoot, + values: nextValues, + valueSetter: general.name, + valueSetTime: new Date().toISOString(), + }; + + await updateNationMeta( + ctx, + nation.id, + { + npc_nation_policy: nextPolicyRoot, + }, + nationMeta + ); + + return { ok: true }; + }), + setNationPriority: authedProcedure.input(z.array(z.string())).mutation(async ({ ctx, input }) => { + const general = await getMyGeneral(ctx); + if (general.nationId <= 0) { + throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'Nation membership required.' }); + } + + const nation = await ctx.db.nation.findUnique({ + where: { id: general.nationId }, + select: { id: true, meta: true }, + }); + if (!nation) { + throw new TRPCError({ code: 'NOT_FOUND', message: 'Nation not found' }); + } + + const permissionLevel = resolveSecretPermission( + { + nationId: general.nationId, + officerLevel: general.officerLevel, + meta: general.meta, + penalty: general.penalty, + }, + nation.meta + ); + if (permissionLevel < 3) { + throw new TRPCError({ code: 'FORBIDDEN', message: '권한이 부족합니다.' }); + } + + for (const item of input) { + if (!DEFAULT_NATION_PRIORITY.includes(item as (typeof DEFAULT_NATION_PRIORITY)[number])) { + throw new TRPCError({ code: 'BAD_REQUEST', message: `${item}은 올바른 명령이 아닙니다.` }); } + } - for (const key of ['SupportForce', 'DevelopForce'] as const) { - if (!(key in input)) { - continue; - } - const rawList = input[key]; - if (!Array.isArray(rawList)) { - throw new TRPCError({ code: 'BAD_REQUEST', message: `${key}는 올바른 정책값이 아닙니다.` }); - } - const list: number[] = []; - for (const rawValue of rawList) { - if (typeof rawValue !== 'number' || !Number.isFinite(rawValue)) { - throw new TRPCError({ code: 'BAD_REQUEST', message: `${key}는 올바른 정책값이 아닙니다.` }); - } - if (!troopSet.has(rawValue)) { - throw new TRPCError({ - code: 'BAD_REQUEST', - message: `${rawValue}는 국가의 부대가 아닙니다.`, - }); - } - if (assigned.has(rawValue)) { - throw new TRPCError({ - code: 'BAD_REQUEST', - message: `부대(${rawValue})는 하나의 역할만 지정할 수 있습니다.`, - }); - } - assigned.add(rawValue); - list.push(rawValue); - } - if (key === 'SupportForce') { - nextValues.SupportForce = list; - } else { - nextValues.DevelopForce = list; - } - } + const nationMeta = asRecord(nation.meta); + const policyRoot = asRecord(nationMeta.npc_nation_policy); + const nextPolicyRoot = { + ...policyRoot, + priority: input, + prioritySetter: general.name, + prioritySetTime: new Date().toISOString(), + }; - const nextPolicyRoot = { - ...policyRoot, - values: nextValues, - valueSetter: general.name, - valueSetTime: new Date().toISOString(), - }; + await updateNationMeta( + ctx, + nation.id, + { + npc_nation_policy: nextPolicyRoot, + }, + nationMeta + ); - await updateNationMeta( - ctx, - nation.id, - { - npc_nation_policy: nextPolicyRoot, - }, - nationMeta - ); + return { ok: true }; + }), + setGeneralPriority: authedProcedure.input(z.array(z.string())).mutation(async ({ ctx, input }) => { + const general = await getMyGeneral(ctx); + if (general.nationId <= 0) { + throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'Nation membership required.' }); + } - return { ok: true }; - }), - setNationPriority: authedProcedure - .input(z.array(z.string())) - .mutation(async ({ ctx, input }) => { - const general = await getMyGeneral(ctx); - if (general.nationId <= 0) { - throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'Nation membership required.' }); - } + const nation = await ctx.db.nation.findUnique({ + where: { id: general.nationId }, + select: { id: true, meta: true }, + }); + if (!nation) { + throw new TRPCError({ code: 'NOT_FOUND', message: 'Nation not found' }); + } - const nation = await ctx.db.nation.findUnique({ - where: { id: general.nationId }, - select: { id: true, meta: true }, - }); - if (!nation) { - throw new TRPCError({ code: 'NOT_FOUND', message: 'Nation not found' }); - } + const permissionLevel = resolveSecretPermission( + { + nationId: general.nationId, + officerLevel: general.officerLevel, + meta: general.meta, + penalty: general.penalty, + }, + nation.meta + ); + if (permissionLevel < 3) { + throw new TRPCError({ code: 'FORBIDDEN', message: '권한이 부족합니다.' }); + } - const permissionLevel = resolveSecretPermission( - { - nationId: general.nationId, - officerLevel: general.officerLevel, - meta: general.meta, - penalty: general.penalty, - }, - nation.meta - ); - if (permissionLevel < 3) { - throw new TRPCError({ code: 'FORBIDDEN', message: '권한이 부족합니다.' }); - } + const validationError = validateGeneralPriority(input); + if (validationError) { + throw new TRPCError({ code: 'BAD_REQUEST', message: validationError }); + } - const unique = ensureUniquePriority(input); - for (const item of unique) { - if (!DEFAULT_NATION_PRIORITY.includes(item as (typeof DEFAULT_NATION_PRIORITY)[number])) { - throw new TRPCError({ code: 'BAD_REQUEST', message: `${item}은 올바른 명령이 아닙니다.` }); - } - } + const nationMeta = asRecord(nation.meta); + const policyRoot = asRecord(nationMeta.npc_general_policy); + const nextPolicyRoot = { + ...policyRoot, + priority: input, + prioritySetter: general.name, + prioritySetTime: new Date().toISOString(), + }; - const nationMeta = asRecord(nation.meta); - const policyRoot = asRecord(nationMeta.npc_nation_policy); - const nextPolicyRoot = { - ...policyRoot, - priority: unique, - prioritySetter: general.name, - prioritySetTime: new Date().toISOString(), - }; + await updateNationMeta( + ctx, + nation.id, + { + npc_general_policy: nextPolicyRoot, + }, + nationMeta + ); - await updateNationMeta( - ctx, - nation.id, - { - npc_nation_policy: nextPolicyRoot, - }, - nationMeta - ); - - return { ok: true }; - }), - setGeneralPriority: authedProcedure - .input(z.array(z.string())) - .mutation(async ({ ctx, input }) => { - const general = await getMyGeneral(ctx); - if (general.nationId <= 0) { - throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'Nation membership required.' }); - } - - const nation = await ctx.db.nation.findUnique({ - where: { id: general.nationId }, - select: { id: true, meta: true }, - }); - if (!nation) { - throw new TRPCError({ code: 'NOT_FOUND', message: 'Nation not found' }); - } - - const permissionLevel = resolveSecretPermission( - { - nationId: general.nationId, - officerLevel: general.officerLevel, - meta: general.meta, - penalty: general.penalty, - }, - nation.meta - ); - if (permissionLevel < 3) { - throw new TRPCError({ code: 'FORBIDDEN', message: '권한이 부족합니다.' }); - } - - const unique = ensureUniquePriority(input); - const validationError = validateGeneralPriority(unique); - if (validationError) { - throw new TRPCError({ code: 'BAD_REQUEST', message: validationError }); - } - - const nationMeta = asRecord(nation.meta); - const policyRoot = asRecord(nationMeta.npc_general_policy); - const nextPolicyRoot = { - ...policyRoot, - priority: unique, - prioritySetter: general.name, - prioritySetTime: new Date().toISOString(), - }; - - await updateNationMeta( - ctx, - nation.id, - { - npc_general_policy: nextPolicyRoot, - }, - nationMeta - ); - - return { ok: true }; - }), + return { ok: true }; + }), }); diff --git a/app/game-api/test/npcPolicyRouter.test.ts b/app/game-api/test/npcPolicyRouter.test.ts new file mode 100644 index 00000000..052f35df --- /dev/null +++ b/app/game-api/test/npcPolicyRouter.test.ts @@ -0,0 +1,275 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken'; +import type { RedisConnector } from '@sammo-ts/infra'; + +import { RedisAccessTokenStore } from '../src/auth/accessTokenStore.js'; +import { InMemoryFlushStore } from '../src/auth/flushStore.js'; +import type { DatabaseClient, GameApiContext, GeneralRow } from '../src/context.js'; +import type { TurnDaemonTransport } from '../src/daemon/transport.js'; +import { appRouter } from '../src/router.js'; + +const baseGeneral: GeneralRow = { + id: 22, + userId: 'user-22', + name: '정책담당', + nationId: 1, + cityId: 1, + troopId: 0, + npcState: 0, + affinity: null, + bornYear: 180, + deadYear: 300, + picture: 'default.jpg', + imageServer: 0, + leadership: 70, + strength: 70, + intel: 70, + injury: 0, + experience: 0, + dedication: 0, + officerLevel: 12, + gold: 1_000, + rice: 1_000, + crew: 0, + crewTypeId: 0, + train: 0, + atmos: 0, + weaponCode: 'None', + bookCode: 'None', + horseCode: 'None', + itemCode: 'None', + turnTime: new Date('2026-01-01T00:00:00.000Z'), + recentWarTime: null, + age: 20, + startAge: 20, + personalCode: 'None', + specialCode: 'None', + special2Code: 'None', + lastTurn: {}, + meta: { belong: 5, permission: 'normal' }, + penalty: {}, + createdAt: new Date('2026-01-01T00:00:00.000Z'), + updatedAt: new Date('2026-01-01T00:00:00.000Z'), +}; + +const auth: GameSessionTokenPayload = { + version: 1, + profile: 'che:default', + issuedAt: '2026-01-01T00:00:00.000Z', + expiresAt: '2026-01-02T00:00:00.000Z', + sessionId: 'session-22', + user: { id: 'user-22', username: 'tester', displayName: 'Tester', roles: [] }, + sanctions: {}, +}; + +const baseNation = { + id: 1, + name: '위', + level: 3, + tech: 3_000, + meta: { + _updatedAt: '2026-01-01T00:00:00.000Z', + npc_nation_policy: { + values: { reqNationRice: 456 }, + priority: ['천도', '천도'], + }, + npc_general_policy: { + priority: ['출병', '일반내정', '출병'], + }, + }, +}; + +const baseWorld = { + config: { + stat: { max: 80, npcMax: 75 }, + environment: { unitSet: 'basic' }, + const: { develCost: 100 }, + }, + meta: { + npc_nation_policy: { values: { reqNationGold: 123 } }, + npc_general_policy: {}, + }, +}; + +const createContext = ( + options: { + me?: GeneralRow; + nation?: typeof baseNation; + world?: typeof baseWorld; + requestCommand?: ReturnType; + troopRows?: Array<{ troopLeaderId: number }>; + cityRows?: Array<{ id: number }>; + } = {} +): { context: GameApiContext; findFirst: ReturnType; requestCommand: ReturnType } => { + const requestCommand = + options.requestCommand ?? + vi.fn(async () => ({ + type: 'setNationMeta', + ok: true, + nationId: 1, + updatedAt: '2026-01-01T00:01:00.000Z', + })); + const findFirst = vi.fn(async () => options.me ?? baseGeneral); + const db = { + general: { findFirst }, + nation: { findUnique: vi.fn(async () => options.nation ?? baseNation) }, + 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 }]) }, + }; + const redisClient = { get: async () => null, set: async () => null }; + return { + context: { + db: db as unknown as DatabaseClient, + redis: {} as RedisConnector['client'], + turnDaemon: { requestCommand } as unknown as TurnDaemonTransport, + battleSim: {} as GameApiContext['battleSim'], + profile: { id: 'che', scenario: 'default', name: 'che:default' }, + auth, + uploadDir: 'uploads', + uploadPath: '/uploads', + uploadPublicUrl: null, + accessTokenStore: new RedisAccessTokenStore(redisClient, 'che:default'), + flushStore: new InMemoryFlushStore(), + gameTokenSecret: 'test-secret', + }, + findFirst, + requestCommand, + }; +}; + +describe('NPC policy router', () => { + 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(); + + expect(fixture.findFirst).toHaveBeenCalledWith({ where: { userId: 'user-22' } }); + expect(result.currentNationPolicy).toMatchObject({ reqNationGold: 123, reqNationRice: 456 }); + expect(result.currentNationPriority).toEqual(['천도', '천도']); + expect(result.currentGeneralActionPriority).toEqual(['출병', '일반내정', '출병']); + expect(result.zeroPolicy).toMatchObject({ + reqNationGold: 10_000, + reqNationRice: 12_000, + reqNPCDevelGold: 3_000, + reqNPCWarGold: 3_900, + reqNPCWarRice: 3_900, + reqHumanWarUrgentGold: 6_300, + reqHumanWarUrgentRice: 6_300, + reqHumanWarRecommandGold: 12_600, + reqHumanWarRecommandRice: 12_600, + }); + }); + + it('lets a secret-level reader load the page but rejects every mutation before daemon dispatch', async () => { + const reader = { ...baseGeneral, officerLevel: 2 }; + const fixture = createContext({ me: reader }); + const caller = appRouter.createCaller(fixture.context); + + await expect(caller.npc.getPolicy()).resolves.toMatchObject({ permissionLevel: 1 }); + await expect(caller.npc.setNationPriority(['천도'])).rejects.toMatchObject({ code: 'FORBIDDEN' }); + await expect(caller.npc.setGeneralPriority(['출병', '일반내정'])).rejects.toMatchObject({ + code: 'FORBIDDEN', + }); + await expect(caller.npc.setNationPolicy({ reqNationGold: 100 })).rejects.toMatchObject({ + code: 'FORBIDDEN', + }); + expect(fixture.requestCommand).not.toHaveBeenCalled(); + }); + + it.each([ + ['군주', { ...baseGeneral, officerLevel: 12 }], + ['감찰권자', { ...baseGeneral, officerLevel: 1, meta: { belong: 0, permission: 'auditor' } }], + ['외교권자', { ...baseGeneral, officerLevel: 1, meta: { belong: 0, permission: 'ambassador' } }], + ])('%s can persist policy through the daemon-owned metadata command', async (_label, me) => { + const fixture = createContext({ me }); + await expect(appRouter.createCaller(fixture.context).npc.setNationPriority(['천도', '천도'])).resolves.toEqual({ + ok: true, + }); + expect(fixture.requestCommand).toHaveBeenCalledWith({ + type: 'setNationMeta', + nationId: 1, + updates: { + npc_nation_policy: expect.objectContaining({ + priority: ['천도', '천도'], + prioritySetter: '정책담당', + prioritySetTime: expect.any(String), + }), + }, + expectedUpdatedAt: '2026-01-01T00:00:00.000Z', + }); + }); + + it('clamps legacy integer values, preserves float values, and validates troop ownership before dispatch', async () => { + const fixture = createContext(); + const caller = appRouter.createCaller(fixture.context); + + await caller.npc.setNationPolicy({ + reqNationGold: -100, + safeRecruitCityPopulationRatio: -0.5, + CombatForce: { 101: [1, 2] }, + }); + expect(fixture.requestCommand).toHaveBeenCalledWith( + expect.objectContaining({ + updates: { + npc_nation_policy: expect.objectContaining({ + values: expect.objectContaining({ + reqNationGold: 0, + safeRecruitCityPopulationRatio: -0.5, + CombatForce: { 101: [1, 2] }, + }), + }), + }, + }) + ); + + fixture.requestCommand.mockClear(); + await expect(caller.npc.setNationPolicy({ SupportForce: [999] })).rejects.toMatchObject({ + code: 'BAD_REQUEST', + }); + expect(fixture.requestCommand).not.toHaveBeenCalled(); + }); + + it('preserves duplicate legacy priority entries and enforces required general actions and ordering', async () => { + const fixture = createContext(); + const caller = appRouter.createCaller(fixture.context); + + await caller.npc.setGeneralPriority(['출병', '출병', '일반내정']); + expect(fixture.requestCommand).toHaveBeenCalledWith( + expect.objectContaining({ + updates: { + npc_general_policy: expect.objectContaining({ + priority: ['출병', '출병', '일반내정'], + }), + }, + }) + ); + await expect(caller.npc.setGeneralPriority(['일반내정', '출병'])).rejects.toMatchObject({ + code: 'BAD_REQUEST', + }); + await expect(caller.npc.setGeneralPriority(['출병'])).rejects.toMatchObject({ code: 'BAD_REQUEST' }); + }); + + it('blocks nationless, penalized, and stale writers without changing lifecycle state directly', async () => { + const nationless = createContext({ me: { ...baseGeneral, nationId: 0, officerLevel: 0 } }); + await expect(appRouter.createCaller(nationless.context).npc.getPolicy()).rejects.toMatchObject({ + code: 'PRECONDITION_FAILED', + }); + + const penalized = createContext({ me: { ...baseGeneral, penalty: { noChief: true } } }); + await expect(appRouter.createCaller(penalized.context).npc.getPolicy()).rejects.toMatchObject({ + code: 'FORBIDDEN', + }); + + const staleCommand = vi.fn(async () => ({ + type: 'setNationMeta', + ok: false, + nationId: 1, + reason: 'CONFLICT', + })); + const stale = createContext({ requestCommand: staleCommand }); + await expect(appRouter.createCaller(stale.context).npc.setNationPriority(['천도'])).rejects.toMatchObject({ + code: 'CONFLICT', + }); + }); +}); diff --git a/app/game-engine/test/npcPolicyLifecycle.test.ts b/app/game-engine/test/npcPolicyLifecycle.test.ts new file mode 100644 index 00000000..18ca5afe --- /dev/null +++ b/app/game-engine/test/npcPolicyLifecycle.test.ts @@ -0,0 +1,230 @@ +import { describe, expect, it } from 'vitest'; + +import type { TurnCommandEnv, TurnSchedule, UnitSetDefinition } from '@sammo-ts/logic'; +import { asRecord } from '@sammo-ts/common'; + +import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js'; +import { AutorunNationPolicy } from '../src/turn/ai/policies.js'; +import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js'; +import { createTurnDaemonCommandHandler } from '../src/turn/worldCommandHandler.js'; + +const schedule: TurnSchedule = { entries: [{ startMinute: 0, tickMinutes: 10 }] }; +const general: TurnGeneral = { + id: 1, + userId: 'owner-1', + name: 'NPC군주', + nationId: 1, + cityId: 1, + troopId: 0, + stats: { leadership: 75, strength: 40, intelligence: 70 }, + turnTime: new Date('0185-01-01T00:00:00Z'), + recentWarTime: null, + role: { + items: { horse: null, weapon: null, book: null, item: null }, + personality: null, + specialDomestic: null, + specialWar: null, + }, + triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} }, + meta: { killturn: 24 }, + penalty: {}, + officerLevel: 12, + experience: 0, + dedication: 0, + injury: 0, + gold: 1_000, + rice: 1_000, + crew: 0, + crewTypeId: 1100, + train: 0, + atmos: 0, + age: 30, + npcState: 2, +}; + +const snapshot: TurnWorldSnapshot = { + generals: [general], + cities: [ + { + id: 1, + name: '허창', + nationId: 1, + level: 7, + state: 0, + population: 100_000, + populationMax: 200_000, + agriculture: 1_000, + agricultureMax: 2_000, + commerce: 1_000, + commerceMax: 2_000, + security: 1_000, + securityMax: 2_000, + supplyState: 1, + frontState: 0, + defence: 1_000, + defenceMax: 2_000, + wall: 1_000, + wallMax: 2_000, + meta: {}, + }, + ], + nations: [ + { + id: 1, + name: '위', + color: '#777777', + capitalCityId: 1, + chiefGeneralId: 1, + gold: 10_000, + rice: 20_000, + power: 0, + level: 3, + typeCode: 'che_법가', + meta: { tech: 3_000, preserved: 'yes', _updatedAt: '2026-01-01T00:00:00.000Z' }, + }, + ], + troops: [], + diplomacy: [], + events: [], + initialEvents: [], + scenarioConfig: { + stat: { total: 300, min: 10, max: 80, npcTotal: 150, npcMax: 75, npcMin: 10, chiefMin: 65 }, + iconPath: '', + map: {}, + const: {}, + environment: { mapName: 'test', unitSet: 'basic' }, + }, + scenarioMeta: { + title: 'test', + startYear: 180, + life: null, + fiction: null, + history: [], + ignoreDefaultEvents: false, + }, + map: { + id: 'test', + name: 'test', + cities: [], + defaults: { trust: 50, trade: 100, supplyState: 1, frontState: 0 }, + }, +}; + +const state: TurnWorldState = { + id: 1, + currentYear: 185, + currentMonth: 1, + tickSeconds: 600, + lastTurnTime: new Date('0185-01-01T00:00:00Z'), + meta: { killturn: 24 }, +}; + +const commandEnv: TurnCommandEnv = { + baseGold: 1_000, + baseRice: 1_000, + develCost: 18, + maxResourceActionAmount: 10_000, + minAvailableRecruitPop: 30_000, + trainDelta: 5, + atmosDelta: 5, + maxTrainByCommand: 100, + maxAtmosByCommand: 100, + sabotageDefaultProb: 0.5, + sabotageProbCoefByStat: 0.01, + sabotageDefenceCoefByGeneralCount: 0.01, + sabotageDamageMin: 1, + sabotageDamageMax: 10, + defaultCrewTypeId: 1100, + maxGeneral: 100, + defaultNpcGold: 1_000, + defaultNpcRice: 1_000, + defaultSpecialDomestic: null, + defaultSpecialWar: null, + openingPartYear: 3, + initialNationGenLimit: 10, + maxTechLevel: 10, + techLevelIncYear: 5, + initialAllowedTechLevel: 1, +}; + +const unitSet: UnitSetDefinition = { + id: 'basic', + name: 'basic', + defaultCrewTypeId: 1100, + armTypes: { 1: '보병' }, + crewTypes: [ + { + id: 1100, + armType: 1, + name: '보병', + attack: 100, + defence: 150, + speed: 7, + avoid: 10, + magicCoef: 0, + cost: 9, + rice: 9, + requirements: [], + attackCoef: {}, + defenceCoef: {}, + info: [], + initSkillTrigger: null, + phaseSkillTrigger: null, + iActionList: null, + }, + ], +}; + +describe('NPC policy lifecycle', () => { + it('applies one CAS-protected metadata command and the next AI instance consumes it without scheduler changes', async () => { + const world = new InMemoryTurnWorld(state, snapshot, { schedule }); + const handler = createTurnDaemonCommandHandler({ world }); + const updates = { + npc_nation_policy: { + values: { reqNationGold: 4_321 }, + priority: ['천도'], + valueSetter: '정책담당', + }, + }; + + await expect( + handler.handle({ + type: 'setNationMeta', + nationId: 1, + updates, + expectedUpdatedAt: '2026-01-01T00:00:00.000Z', + }) + ).resolves.toMatchObject({ type: 'setNationMeta', ok: true, nationId: 1 }); + + const nation = world.getNationById(1)!; + expect(nation.meta).toMatchObject({ preserved: 'yes', npc_nation_policy: updates.npc_nation_policy }); + const policy = new AutorunNationPolicy({ + general: world.getGeneralById(1)!, + aiOptions: null, + nationPolicy: asRecord(nation.meta).npc_nation_policy as Record, + serverPolicy: null, + nation, + env: commandEnv, + scenarioConfig: snapshot.scenarioConfig, + unitSet, + }); + expect(policy.reqNationGold).toBe(4_321); + expect(policy.priority).toEqual(['천도']); + expect(policy.reqNpcDevelGold).toBe(540); + expect(policy.reqNpcWarGold).toBe(3_900); + expect(policy.reqNpcWarRice).toBe(3_900); + + await expect( + handler.handle({ + type: 'setNationMeta', + nationId: 1, + updates: { npc_nation_policy: { values: { reqNationGold: 9_999 } } }, + expectedUpdatedAt: '2026-01-01T00:00:00.000Z', + }) + ).resolves.toMatchObject({ type: 'setNationMeta', ok: false, reason: 'CONFLICT' }); + expect(asRecord(asRecord(world.getNationById(1)?.meta).npc_nation_policy).values).toEqual({ + reqNationGold: 4_321, + }); + expect(world.getState()).toMatchObject({ currentYear: 185, currentMonth: 1, tickSeconds: 600 }); + }); +});