From 5ed1cbe951920735aa6410d2d7d04b3a6b7e8824 Mon Sep 17 00:00:00 2001 From: hided62 Date: Sat, 25 Jul 2026 18:39:58 +0000 Subject: [PATCH] feat: register delayed scenario NPCs --- .../src/turn/monthlyRegisterNpcAction.ts | 383 ++++++++++++++++++ app/game-engine/src/turn/turnDaemon.ts | 20 +- .../test/monthlyRegisterNpcAction.test.ts | 374 +++++++++++++++++ ...RegisterNpcPersistence.integration.test.ts | 225 ++++++++++ packages/logic/src/world/bootstrap.ts | 103 ++++- packages/logic/test/worldBootstrap.test.ts | 119 ++++++ 6 files changed, 1218 insertions(+), 6 deletions(-) create mode 100644 app/game-engine/src/turn/monthlyRegisterNpcAction.ts create mode 100644 app/game-engine/test/monthlyRegisterNpcAction.test.ts create mode 100644 app/game-engine/test/monthlyRegisterNpcPersistence.integration.test.ts diff --git a/app/game-engine/src/turn/monthlyRegisterNpcAction.ts b/app/game-engine/src/turn/monthlyRegisterNpcAction.ts new file mode 100644 index 0000000..f19f098 --- /dev/null +++ b/app/game-engine/src/turn/monthlyRegisterNpcAction.ts @@ -0,0 +1,383 @@ +import { JosaUtil, LiteHashDRBG, RandUtil, asRecord } from '@sammo-ts/common'; +import { + DOMESTIC_TRAIT_KEYS, + LogCategory, + LogFormat, + LogScope, + PERSONALITY_TRAIT_KEYS, + WAR_TRAIT_KEYS, + type TurnCommandEnv, +} from '@sammo-ts/logic'; +import { simpleSerialize } from '@sammo-ts/logic/war/utils.js'; + +import type { InMemoryTurnWorld } from './inMemoryWorld.js'; +import type { MonthlyEventActionHandler } from './monthlyEventHandler.js'; +import type { InMemoryReservedTurnStore } from './reservedTurnStore.js'; +import type { TurnGeneral } from './types.js'; + +type RegisterNpcActionName = 'RegNPC' | 'RegNeutralNPC'; + +interface RegisterNpcArguments { + affinity: number; + name: string; + picture: number | string | null; + nationId: number; + city: number | string | null; + leadership: number; + strength: number; + intelligence: number; + officerLevel: number | null; + birthYear: number; + deathYear: number; + personality: string | null; + special: string | null; + text: string; +} + +const ADULT_AGE = 14; + +const readInteger = (value: unknown, label: string, fallback?: number): number => { + if (value === undefined && fallback !== undefined) { + return fallback; + } + const parsed = + typeof value === 'number' + ? value + : typeof value === 'string' && value.trim() !== '' + ? Number(value) + : Number.NaN; + if (!Number.isInteger(parsed)) { + throw new Error(`${label} must be an integer.`); + } + return parsed; +}; + +const readName = (value: unknown): string => { + if (typeof value !== 'string') { + throw new Error('NPC name must be a string.'); + } + return value; +}; + +const readNullableString = (value: unknown, label: string): string | null => { + if (value === null || value === undefined) { + return null; + } + if (typeof value !== 'string') { + throw new Error(`${label} must be a string or null.`); + } + return value; +}; + +const readPicture = (value: unknown): number | string | null => { + if (value === null || value === undefined || typeof value === 'number' || typeof value === 'string') { + return value ?? null; + } + throw new Error('NPC picture must be a number, string, or null.'); +}; + +const readCity = (value: unknown): number | string | null => { + if (value === null || value === undefined || typeof value === 'number' || typeof value === 'string') { + return value ?? null; + } + throw new Error('NPC city must be a number, string, or null.'); +}; + +const parseArguments = (actionName: RegisterNpcActionName, args: readonly unknown[]): RegisterNpcArguments => { + if (actionName === 'RegNPC') { + return { + affinity: readInteger(args[0], 'RegNPC affinity'), + name: readName(args[1]), + picture: readPicture(args[2]), + nationId: readInteger(args[3], 'RegNPC nationId'), + city: readCity(args[4]), + leadership: readInteger(args[5], 'RegNPC leadership'), + strength: readInteger(args[6], 'RegNPC strength'), + intelligence: readInteger(args[7], 'RegNPC intelligence'), + officerLevel: readInteger(args[8], 'RegNPC officerLevel'), + birthYear: readInteger(args[9], 'RegNPC birthYear', 160), + deathYear: readInteger(args[10], 'RegNPC deathYear', 300), + personality: readNullableString(args[11], 'RegNPC personality'), + special: readNullableString(args[12], 'RegNPC special'), + text: readNullableString(args[13], 'RegNPC text') || '', + }; + } + return { + affinity: readInteger(args[0], 'RegNeutralNPC affinity'), + name: readName(args[1]), + picture: readPicture(args[2]), + nationId: readInteger(args[3], 'RegNeutralNPC nationId'), + city: readCity(args[4]), + leadership: readInteger(args[5], 'RegNeutralNPC leadership'), + strength: readInteger(args[6], 'RegNeutralNPC strength'), + intelligence: readInteger(args[7], 'RegNeutralNPC intelligence'), + officerLevel: null, + birthYear: readInteger(args[8], 'RegNeutralNPC birthYear', 160), + deathYear: readInteger(args[9], 'RegNeutralNPC deathYear', 300), + personality: readNullableString(args[10], 'RegNeutralNPC personality'), + special: readNullableString(args[11], 'RegNeutralNPC special'), + text: readNullableString(args[12], 'RegNeutralNPC text') || '', + }; +}; + +const resolveHiddenSeed = (world: InMemoryTurnWorld): string | number => { + const state = world.getState(); + const value = state.meta.hiddenSeed ?? state.meta.seed ?? state.id; + return typeof value === 'string' || typeof value === 'number' ? value : String(value); +}; + +const resolvePersonality = (raw: string | null): string | null => { + if (raw === null) { + return null; + } + const key = raw.startsWith('che_') ? raw : `che_${raw}`; + if (!PERSONALITY_TRAIT_KEYS.includes(key as (typeof PERSONALITY_TRAIT_KEYS)[number])) { + throw new Error(`Unknown NPC personality: ${raw}`); + } + return key; +}; + +const resolveSpecial = ( + raw: string | null, + env: TurnCommandEnv +): { specialDomestic: string | null; specialWar: string | null } => { + if (raw === null || raw === '' || raw === 'None') { + return { + specialDomestic: env.defaultSpecialDomestic, + specialWar: env.defaultSpecialWar, + }; + } + const key = raw.startsWith('che_') ? raw : `che_${raw}`; + if (DOMESTIC_TRAIT_KEYS.includes(key as (typeof DOMESTIC_TRAIT_KEYS)[number])) { + return { specialDomestic: key, specialWar: env.defaultSpecialWar }; + } + if (WAR_TRAIT_KEYS.includes(key as (typeof WAR_TRAIT_KEYS)[number])) { + return { specialDomestic: env.defaultSpecialDomestic, specialWar: key }; + } + throw new Error(`Unknown NPC speciality: ${raw}`); +}; + +const resolveCityId = (world: InMemoryTurnWorld, raw: number | string | null): number | null => { + if (raw === null) { + return null; + } + const city = + typeof raw === 'number' + ? world.getCityById(raw) + : world.listCities().find((candidate) => candidate.name === raw) ?? null; + if (!city) { + throw new Error(`Unknown NPC city: ${String(raw)}`); + } + return city.id; +}; + +const resolvePicture = ( + raw: number | string | null, + showImgLevel: number, + iconPath: string +): string => { + if (showImgLevel < 3 || raw === null || (typeof raw === 'number' && raw < 0)) { + return 'default.jpg'; + } + if (typeof raw === 'number') { + return `${raw}.jpg`; + } + if (iconPath !== '' && iconPath !== '.' && !raw.includes('/')) { + return `${iconPath}/${raw}`; + } + return raw; +}; + +const buildSpecialityAge = ( + retirementYear: number, + age: number, + relativeYear: number, + divisor: number +): number => Math.max(Math.round((retirementYear - age) / divisor - relativeYear / 2), 3) + age; + +const resolveRuntimeNumber = ( + source: Record | undefined, + key: string, + fallback: number +): number => { + const value = source?.[key]; + return typeof value === 'number' && Number.isFinite(value) ? value : fallback; +}; + +export const createRegisterNpcHandler = (options: { + actionName: RegisterNpcActionName; + getWorld: () => InMemoryTurnWorld | null; + reservedTurns: InMemoryReservedTurnStore; + env: TurnCommandEnv; + worldConfig?: Record; + scenarioFiction?: number | null; +}): MonthlyEventActionHandler => { + const npcType = options.actionName === 'RegNPC' ? 2 : 6; + const namePrefix = npcType === 2 ? 'ⓝ' : 'ⓤ'; + + return (args, environment) => { + const world = options.getWorld(); + if (!world) { + return; + } + const parsed = parseArguments(options.actionName, args); + const explicitCityId = resolveCityId(world, parsed.city); + const explicitPersonality = resolvePersonality(parsed.personality); + const special = resolveSpecial(parsed.special, options.env); + const rng = new RandUtil( + new LiteHashDRBG( + simpleSerialize( + resolveHiddenSeed(world), + options.actionName, + parsed.name, + parsed.nationId, + parsed.leadership, + parsed.strength, + parsed.intelligence + ) + ) + ); + + const affinity = + parsed.affinity < 1 + ? rng.nextRangeInt(1, 150) + : parsed.affinity >= 900 + ? 999 + : parsed.affinity <= 150 + ? parsed.affinity + : null; + if (affinity === null) { + throw new Error(`Invalid NPC affinity: ${parsed.affinity}`); + } + + const configValues = asRecord(world.getScenarioConfig().const); + const retirementYear = resolveRuntimeNumber(configValues, 'retirementYear', 80); + const age = environment.year - parsed.birthYear; + const relativeYear = Math.max(environment.year - environment.startyear, 0); + const specAge = buildSpecialityAge(retirementYear, age, relativeYear, 12); + const specAge2 = buildSpecialityAge(retirementYear, age, relativeYear, 6); + const personality = + explicitPersonality ?? rng.choice(options.env.availablePersonalities ?? ['che_안전']); + + if (parsed.deathYear <= environment.year || age < ADULT_AGE) { + return; + } + + const isNewGeneral = age === ADULT_AGE; + const fiction = resolveRuntimeNumber( + options.worldConfig, + 'fiction', + options.scenarioFiction ?? 0 + ); + let nationId = parsed.nationId; + if ((fiction !== 0 && isNewGeneral) || !world.getNationById(nationId)) { + nationId = 0; + } + const initialOfficerLevel = + parsed.officerLevel === null ? (parsed.nationId !== 0 ? 1 : 0) : parsed.officerLevel; + const officerLevel = !initialOfficerLevel || isNewGeneral ? (nationId !== 0 ? 1 : 0) : initialOfficerLevel; + const name = `${namePrefix}${parsed.name}`; + + if (isNewGeneral) { + const josaYi = JosaUtil.pick(name, '이'); + world.pushLog({ + scope: LogScope.SYSTEM, + category: LogCategory.ACTION, + text: `${name}${josaYi} 성인이 되어 등장했습니다.`, + format: LogFormat.MONTH, + year: environment.year, + month: environment.month, + }); + } + + let cityId = explicitCityId; + if (cityId === null) { + const nationCities = + nationId === 0 ? [] : world.listCities().filter((city) => city.nationId === nationId); + const candidates = nationCities.length > 0 ? nationCities : world.listCities(); + if (candidates.length === 0) { + throw new Error(`${options.actionName} requires at least one city.`); + } + cityId = rng.choice(candidates).id; + } + + const turnMinutes = world.getState().tickSeconds / 60; + if (!(turnMinutes > 0) || !Number.isInteger(turnMinutes)) { + throw new Error(`${options.actionName} requires a positive integer turn term.`); + } + const turnSecond = rng.nextRangeInt(0, 60 * turnMinutes - 1); + const turnFraction = rng.nextRangeInt(0, 999_999); + const turnTime = new Date( + environment.turnTime.getTime() + turnSecond * 1_000 + Math.floor(turnFraction / 1_000) + ); + const killturn = + (parsed.deathYear - environment.year) * 12 + + rng.nextRangeInt(0, 11) + + environment.month - + 1; + const id = world.getNextGeneralId(); + const showImgLevel = resolveRuntimeNumber(options.worldConfig, 'showImgLevel', 3); + const general: TurnGeneral = { + id, + userId: null, + name, + nationId, + cityId, + troopId: 0, + stats: { + leadership: parsed.leadership, + strength: parsed.strength, + intelligence: parsed.intelligence, + }, + experience: age * 100, + dedication: age * 100, + officerLevel, + role: { + personality, + ...special, + items: { horse: null, weapon: null, book: null, item: null }, + }, + injury: 0, + gold: 1_000, + rice: 1_000, + crew: 0, + crewTypeId: options.env.defaultCrewTypeId, + train: 0, + atmos: 0, + age, + npcState: npcType, + bornYear: parsed.birthYear, + deadYear: parsed.deathYear, + affinity, + picture: resolvePicture(parsed.picture, showImgLevel, world.getScenarioConfig().iconPath), + triggerState: { + flags: {}, + counters: {}, + modifiers: {}, + meta: {}, + }, + lastTurn: { command: '휴식' }, + turnTime, + recentWarTime: null, + meta: { + killturn, + npcType, + npc_org: npcType, + belong: 0, + dedlevel: 1, + specage: specAge, + specage2: specAge2, + dex1: 0, + dex2: 0, + dex3: 0, + dex4: 0, + dex5: 0, + text: parsed.text, + }, + }; + if (!world.addGeneral(general)) { + throw new Error(`${options.actionName} generated a duplicate general id: ${id}`); + } + options.reservedTurns.ensureGeneralTurns(id); + }; +}; diff --git a/app/game-engine/src/turn/turnDaemon.ts b/app/game-engine/src/turn/turnDaemon.ts index 3e8dcd0..075247d 100644 --- a/app/game-engine/src/turn/turnDaemon.ts +++ b/app/game-engine/src/turn/turnDaemon.ts @@ -52,6 +52,7 @@ import { createProcessSemiAnnualHandler } from './monthlySemiAnnualAction.js'; import { createProcessWarIncomeHandler } from './monthlyWarIncomeAction.js'; import { createCreateAdminNpcHandler } from './monthlyCreateAdminNpcAction.js'; import { createCreateManyNpcHandler } from './monthlyCreateManyNpcAction.js'; +import { createRegisterNpcHandler } from './monthlyRegisterNpcAction.js'; import { buildCommandEnv } from './reservedTurnCommands.js'; import { DatabaseTurnDaemonLease, TurnDaemonLeaseUnavailableError } from '../lifecycle/databaseTurnDaemonLease.js'; @@ -155,7 +156,11 @@ const createTurnDaemonRuntimeWithLease = async ( Array.isArray(event.action) && event.action.some((action) => Array.isArray(action) && action[0] === name) ); - const eventRequiresReservedTurns = hasEventAction('UpdateNationLevel') || hasEventAction('CreateManyNPC'); + const eventRequiresReservedTurns = + hasEventAction('UpdateNationLevel') || + hasEventAction('CreateManyNPC') || + hasEventAction('RegNPC') || + hasEventAction('RegNeutralNPC'); const reservedTurnStoreHandle = options.generalTurnHandler && !eventRequiresReservedTurns ? null @@ -232,6 +237,19 @@ const createTurnDaemonRuntimeWithLease = async ( env: monthlyCommandEnv, }) ); + for (const actionName of ['RegNPC', 'RegNeutralNPC'] as const) { + eventActions.set( + actionName, + createRegisterNpcHandler({ + actionName, + getWorld: () => worldRef, + reservedTurns: reservedTurnStoreHandle.store, + env: monthlyCommandEnv, + worldConfig: snapshot.worldConfig, + scenarioFiction: snapshot.scenarioMeta?.fiction, + }) + ); + } eventActions.set( 'UpdateNationLevel', createUpdateNationLevelHandler({ diff --git a/app/game-engine/test/monthlyRegisterNpcAction.test.ts b/app/game-engine/test/monthlyRegisterNpcAction.test.ts new file mode 100644 index 0000000..8813f86 --- /dev/null +++ b/app/game-engine/test/monthlyRegisterNpcAction.test.ts @@ -0,0 +1,374 @@ +import { describe, expect, it, vi } from 'vitest'; +import { PERSONALITY_TRAIT_KEYS, type City, type Nation } from '@sammo-ts/logic'; + +import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js'; +import { createRegisterNpcHandler } from '../src/turn/monthlyRegisterNpcAction.js'; +import { InMemoryReservedTurnStore } from '../src/turn/reservedTurnStore.js'; +import { buildCommandEnv } from '../src/turn/reservedTurnCommands.js'; +import type { TurnEvent, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js'; + +const buildCity = (id: number, nationId: number): City => ({ + id, + name: `도시${id}`, + nationId, + level: 4, + state: 0, + population: 10_000, + populationMax: 20_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: {}, +}); + +const buildNation = (id: number): Nation => ({ + id, + name: `국가${id}`, + color: '#777777', + capitalCityId: id, + chiefGeneralId: null, + gold: 1_000, + rice: 1_000, + power: 0, + level: 1, + typeCode: 'che_중립', + meta: {}, +}); + +const event: TurnEvent = { + id: 1, + targetCode: 'month', + priority: 1_000, + condition: true, + action: [], + meta: {}, +}; + +const buildHarness = (options: { fiction?: number; seed?: string; fixtureCity?: boolean } = {}) => { + const state: TurnWorldState = { + id: 1, + currentYear: 200, + currentMonth: 1, + tickSeconds: 600, + lastTurnTime: new Date('0200-01-01T00:00:00.000Z'), + meta: { hiddenSeed: options.seed ?? 'register-npc-fixture' }, + }; + const snapshot: TurnWorldSnapshot = { + scenarioConfig: { + stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 75, npcMin: 10, chiefMin: 70 }, + iconPath: '.', + map: {}, + const: { + retirementYear: 80, + availablePersonality: ['che_안전', 'che_유지'], + }, + environment: { mapName: 'test', unitSet: 'default' }, + }, + scenarioMeta: { + title: 'test', + startYear: 190, + life: null, + fiction: null, + history: [], + ignoreDefaultEvents: false, + }, + worldConfig: { fiction: options.fiction ?? 0, showImgLevel: 3 }, + map: { + id: 'test', + name: 'test', + cities: [], + defaults: { trust: 50, trade: 100, supplyState: 1, frontState: 0 }, + }, + generals: [], + cities: [ + buildCity(1, 1), + buildCity(2, 1), + buildCity(3, 0), + ...(options.fixtureCity ? [buildCity(33, 1)] : []), + ], + nations: [buildNation(1)], + troops: [], + diplomacy: [], + events: [], + initialEvents: [], + }; + const world = new InMemoryTurnWorld(state, snapshot, { + schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] }, + }); + const prisma = { + generalTurn: { findMany: vi.fn(), deleteMany: vi.fn(), createMany: vi.fn() }, + nationTurn: { findMany: vi.fn(), deleteMany: vi.fn(), createMany: vi.fn() }, + }; + const reservedTurns = new InMemoryReservedTurnStore(prisma as never, { + maxGeneralTurns: 30, + maxNationTurns: 12, + }); + const env = buildCommandEnv(snapshot.scenarioConfig); + const environment = { + year: 200, + month: 1, + startyear: 190, + currentEventID: 1, + turnTime: state.lastTurnTime, + }; + return { world, reservedTurns, env, environment, snapshot }; +}; + +describe('RegNPC and RegNeutralNPC monthly actions', () => { + it('registers a 14-year-old NPC with the legacy prefix, officer override, log, and reserved turns', async () => { + const { world, reservedTurns, env, environment, snapshot } = buildHarness(); + const handler = createRegisterNpcHandler({ + actionName: 'RegNPC', + getWorld: () => world, + reservedTurns, + env, + worldConfig: snapshot.worldConfig, + }); + + await handler( + [77, '등장장수', null, 1, '도시2', 60, 50, 40, 7, 186, 240, '유지', '인덕', '대사'], + environment, + event + ); + + const created = world.peekDirtyState().createdGenerals[0]!; + expect(created).toMatchObject({ + name: 'ⓝ등장장수', + nationId: 1, + cityId: 2, + stats: { leadership: 60, strength: 50, intelligence: 40 }, + officerLevel: 1, + experience: 1_400, + dedication: 1_400, + age: 14, + npcState: 2, + bornYear: 186, + deadYear: 240, + affinity: 77, + picture: 'default.jpg', + role: { + personality: 'che_유지', + specialDomestic: 'che_인덕', + specialWar: null, + }, + meta: { + npcType: 2, + npc_org: 2, + text: '대사', + }, + }); + expect(reservedTurns.getGeneralTurns(created.id)).toHaveLength(30); + expect(world.peekDirtyState().logs).toEqual([ + expect.objectContaining({ + text: 'ⓝ등장장수가 성인이 되어 등장했습니다.', + year: 200, + month: 1, + }), + ]); + }); + + it('uses the neutral prefix/type and the legacy fixed RNG order for implicit fields', async () => { + const { world, reservedTurns, env, environment, snapshot } = buildHarness(); + const handler = createRegisterNpcHandler({ + actionName: 'RegNeutralNPC', + getWorld: () => world, + reservedTurns, + env, + worldConfig: snapshot.worldConfig, + }); + + await handler([0, '재야장수', null, 0, null, 45, 55, 65, 180, 245, null, '무쌍', ''], environment, event); + + const created = world.peekDirtyState().createdGenerals[0]!; + expect({ + name: created.name, + nationId: created.nationId, + cityId: created.cityId, + officerLevel: created.officerLevel, + affinity: created.affinity, + personality: created.role.personality, + specialDomestic: created.role.specialDomestic, + specialWar: created.role.specialWar, + turnTime: created.turnTime.toISOString(), + killturn: created.meta.killturn, + npcType: created.npcState, + npcOrg: created.meta.npc_org, + }).toMatchInlineSnapshot(` + { + "affinity": 47, + "cityId": 1, + "killturn": 547, + "name": "ⓤ재야장수", + "nationId": 0, + "npcOrg": 6, + "npcType": 6, + "officerLevel": 0, + "personality": "che_안전", + "specialDomestic": null, + "specialWar": "che_무쌍", + "turnTime": "0200-01-01T00:04:36.545Z", + } + `); + }); + + it('forces a newly adult general to neutral in fiction mode', async () => { + const { world, reservedTurns, env, environment, snapshot } = buildHarness({ fiction: 1 }); + const handler = createRegisterNpcHandler({ + actionName: 'RegNPC', + getWorld: () => world, + reservedTurns, + env, + worldConfig: snapshot.worldConfig, + }); + + await handler([10, '가상장수', null, 1, null, 50, 50, 50, 5, 186, 240, '안전', '', ''], environment, event); + + expect(world.peekDirtyState().createdGenerals[0]).toMatchObject({ + name: 'ⓝ가상장수', + nationId: 0, + officerLevel: 0, + }); + }); + + it('projects legacy numeric icon indexes to shared jpg paths', async () => { + const { world, reservedTurns, env, environment, snapshot } = buildHarness(); + const handler = createRegisterNpcHandler({ + actionName: 'RegNPC', + getWorld: () => world, + reservedTurns, + env, + worldConfig: snapshot.worldConfig, + }); + + await handler([10, '도상', 1001, 0, 1, 50, 50, 50, 0, 180, 240, '안전', '', ''], environment, event); + + expect(world.peekDirtyState().createdGenerals[0]?.picture).toBe('1001.jpg'); + }); + + it('consumes fill-time random values but does not insert a dead or underage general', async () => { + const { world, reservedTurns, env, environment, snapshot } = buildHarness(); + const handler = createRegisterNpcHandler({ + actionName: 'RegNPC', + getWorld: () => world, + reservedTurns, + env, + worldConfig: snapshot.worldConfig, + }); + + await handler([0, '사망', null, 1, null, 50, 50, 50, 1, 170, 200, null, '', ''], environment, event); + await handler([0, '미성년', null, 1, null, 50, 50, 50, 1, 190, 250, null, '', ''], environment, event); + + expect(world.peekDirtyState().createdGenerals).toEqual([]); + expect(world.peekDirtyState().logs).toEqual([]); + expect(reservedTurns.peekDirtyState().generalInitializationIds).toEqual([]); + }); + + it.skipIf(!process.env.REF_HIDDEN_SEED)('matches the fixed-seed legacy RegNPC fixture', async () => { + const { world, reservedTurns, env, environment, snapshot } = buildHarness({ + seed: process.env.REF_HIDDEN_SEED, + fixtureCity: true, + }); + env.availablePersonalities = [...PERSONALITY_TRAIT_KEYS]; + const handler = createRegisterNpcHandler({ + actionName: 'RegNPC', + getWorld: () => world, + reservedTurns, + env, + worldConfig: snapshot.worldConfig, + }); + + await handler( + [0, '등록장수', null, 0, 33, 60, 50, 40, 7, 186, 240, null, '인덕', '등록 대사'], + environment, + event + ); + + const created = world.peekDirtyState().createdGenerals[0]!; + expect({ + name: created.name, + nationId: created.nationId, + cityId: created.cityId, + officerLevel: created.officerLevel, + affinity: created.affinity, + personality: created.role.personality, + specialDomestic: created.role.specialDomestic, + specialWar: created.role.specialWar, + specAge: created.meta.specage, + specAge2: created.meta.specage2, + turnTime: created.turnTime.toISOString(), + killturn: created.meta.killturn, + }).toEqual({ + name: 'ⓝ등록장수', + nationId: 0, + cityId: 33, + officerLevel: 0, + affinity: 126, + personality: 'che_출세', + specialDomestic: 'che_인덕', + specialWar: null, + specAge: 17, + specAge2: 20, + turnTime: '0200-01-01T00:09:00.782Z', + killturn: 489, + }); + }); + + it.skipIf(!process.env.REF_HIDDEN_SEED)('matches the fixed-seed legacy RegNeutralNPC fixture', async () => { + const { world, reservedTurns, env, environment, snapshot } = buildHarness({ + seed: process.env.REF_HIDDEN_SEED, + fixtureCity: true, + }); + env.availablePersonalities = [...PERSONALITY_TRAIT_KEYS]; + const handler = createRegisterNpcHandler({ + actionName: 'RegNeutralNPC', + getWorld: () => world, + reservedTurns, + env, + worldConfig: snapshot.worldConfig, + }); + + await handler( + [0, '등록재야', null, 0, 33, 45, 55, 65, 180, 245, null, '무쌍', ''], + environment, + event + ); + + const created = world.peekDirtyState().createdGenerals[0]!; + expect({ + name: created.name, + nationId: created.nationId, + cityId: created.cityId, + officerLevel: created.officerLevel, + affinity: created.affinity, + personality: created.role.personality, + specialDomestic: created.role.specialDomestic, + specialWar: created.role.specialWar, + specAge: created.meta.specage, + specAge2: created.meta.specage2, + turnTime: created.turnTime.toISOString(), + killturn: created.meta.killturn, + }).toEqual({ + name: 'ⓤ등록재야', + nationId: 0, + cityId: 33, + officerLevel: 0, + affinity: 103, + personality: 'che_출세', + specialDomestic: null, + specialWar: 'che_무쌍', + specAge: 23, + specAge2: 25, + turnTime: '0200-01-01T00:09:50.378Z', + killturn: 540, + }); + }); +}); diff --git a/app/game-engine/test/monthlyRegisterNpcPersistence.integration.test.ts b/app/game-engine/test/monthlyRegisterNpcPersistence.integration.test.ts new file mode 100644 index 0000000..b239a36 --- /dev/null +++ b/app/game-engine/test/monthlyRegisterNpcPersistence.integration.test.ts @@ -0,0 +1,225 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { createGamePostgresConnector, type GamePrismaClient } from '@sammo-ts/infra'; +import type { City } from '@sammo-ts/logic'; + +import { createDatabaseTurnHooks } from '../src/turn/databaseHooks.js'; +import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js'; +import { createRegisterNpcHandler } from '../src/turn/monthlyRegisterNpcAction.js'; +import { InMemoryReservedTurnStore } from '../src/turn/reservedTurnStore.js'; +import { buildCommandEnv } from '../src/turn/reservedTurnCommands.js'; +import type { TurnEvent, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js'; + +const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL; +const integration = describe.skipIf(!databaseUrl); +const cityId = 990_082; +const createdGeneralId = 990_082; + +const city: City = { + id: cityId, + name: '등록NPC저장도시', + nationId: 0, + level: 4, + state: 0, + population: 10_000, + populationMax: 20_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: {}, +}; + +const event: TurnEvent = { + id: 1, + targetCode: 'month', + priority: 1_000, + condition: true, + action: [['RegNPC']], + meta: {}, +}; + +integration('RegNPC database persistence', () => { + let db: GamePrismaClient; + let closeDb: (() => Promise) | undefined; + + const clean = async () => { + await db.logEntry.deleteMany({ + where: { + OR: [ + { generalId: createdGeneralId }, + { year: 200, month: 1, text: { contains: 'ⓝ저장장수' } }, + ], + }, + }); + await db.generalTurn.deleteMany({ where: { generalId: createdGeneralId } }); + await db.rankData.deleteMany({ where: { generalId: createdGeneralId } }); + await db.general.deleteMany({ where: { id: createdGeneralId } }); + await db.city.deleteMany({ where: { id: cityId } }); + }; + + beforeAll(async () => { + const connector = createGamePostgresConnector({ url: databaseUrl! }); + await connector.connect(); + db = connector.prisma; + closeDb = () => connector.disconnect(); + await clean(); + }); + + afterAll(async () => { + await clean(); + await closeDb?.(); + }); + + it('commits the registered general, resting turns, ranks, and adult log atomically', async () => { + await db.city.create({ + data: { + id: city.id, + name: city.name, + nationId: city.nationId, + level: city.level, + supplyState: city.supplyState, + frontState: city.frontState, + population: city.population, + populationMax: city.populationMax, + agriculture: city.agriculture, + agricultureMax: city.agricultureMax, + commerce: city.commerce, + commerceMax: city.commerceMax, + security: city.security, + securityMax: city.securityMax, + trust: 50, + trade: 100, + defence: city.defence, + defenceMax: city.defenceMax, + wall: city.wall, + wallMax: city.wallMax, + region: 1, + conflict: {}, + meta: {}, + }, + }); + const stateRow = await db.worldState.create({ + data: { + scenarioCode: 'monthly-register-npc-persistence', + currentYear: 200, + currentMonth: 1, + tickSeconds: 600, + config: {}, + meta: { hiddenSeed: 'register-npc-persistence', lastGeneralId: createdGeneralId - 1 }, + }, + }); + const state: TurnWorldState = { + id: stateRow.id, + currentYear: 200, + currentMonth: 1, + tickSeconds: 600, + lastTurnTime: new Date('0200-01-01T00:00:00.000Z'), + meta: { hiddenSeed: 'register-npc-persistence', lastGeneralId: createdGeneralId - 1 }, + }; + const snapshot: TurnWorldSnapshot = { + scenarioConfig: { + stat: { total: 165, min: 15, max: 80, npcTotal: 150, npcMax: 75, npcMin: 10, chiefMin: 65 }, + iconPath: '.', + map: {}, + const: { retirementYear: 80, availablePersonality: ['che_안전'] }, + environment: { mapName: 'test', unitSet: 'default' }, + }, + scenarioMeta: { + title: 'test', + startYear: 190, + life: null, + fiction: null, + history: [], + ignoreDefaultEvents: false, + }, + worldConfig: { fiction: 0, showImgLevel: 3 }, + map: { + id: 'test', + name: 'test', + cities: [], + defaults: { trust: 50, trade: 100, supplyState: 1, frontState: 0 }, + }, + generals: [], + cities: [city], + nations: [], + troops: [], + diplomacy: [], + events: [event], + initialEvents: [], + }; + const world = new InMemoryTurnWorld(state, snapshot, { + schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] }, + }); + const reservedTurns = new InMemoryReservedTurnStore(db, { maxGeneralTurns: 30, maxNationTurns: 12 }); + const handler = createRegisterNpcHandler({ + actionName: 'RegNPC', + getWorld: () => world, + reservedTurns, + env: buildCommandEnv(snapshot.scenarioConfig), + worldConfig: snapshot.worldConfig, + }); + const dbHooks = await createDatabaseTurnHooks(databaseUrl!, world, { reservedTurns }); + + try { + await handler( + [77, '저장장수', 1001, 0, cityId, 60, 50, 40, 7, 186, 240, '유지', '인덕', '대사'], + { + year: 200, + month: 1, + startyear: 190, + currentEventID: 1, + turnTime: state.lastTurnTime, + }, + event + ); + await dbHooks.hooks.flushChanges?.({ + lastTurnTime: state.lastTurnTime.toISOString(), + processedGenerals: 0, + processedTurns: 1, + durationMs: 0, + partial: false, + }); + + expect(await db.general.findUniqueOrThrow({ where: { id: createdGeneralId } })).toMatchObject({ + name: 'ⓝ저장장수', + nationId: 0, + cityId, + officerLevel: 0, + npcState: 2, + affinity: 77, + bornYear: 186, + deadYear: 240, + picture: '1001.jpg', + experience: 1_400, + dedication: 1_400, + personalCode: 'che_유지', + specialCode: 'che_인덕', + }); + const turns = await db.generalTurn.findMany({ where: { generalId: createdGeneralId } }); + expect(turns).toHaveLength(30); + expect(new Set(turns.map((turn) => turn.actionCode))).toEqual(new Set(['휴식'])); + const ranks = await db.rankData.findMany({ where: { generalId: createdGeneralId } }); + expect(ranks).toHaveLength(41); + expect(ranks.every((rank) => rank.nationId === 0 && rank.value === 0)).toBe(true); + expect( + await db.logEntry.findFirst({ + where: { year: 200, month: 1, text: { contains: 'ⓝ저장장수' } }, + }) + ).toMatchObject({ + category: 'ACTION', + text: expect.stringContaining('성인이 되어'), + }); + } finally { + await dbHooks.close(); + await db.worldState.delete({ where: { id: stateRow.id } }); + } + }); +}); diff --git a/packages/logic/src/world/bootstrap.ts b/packages/logic/src/world/bootstrap.ts index 0744053..0b37bcd 100644 --- a/packages/logic/src/world/bootstrap.ts +++ b/packages/logic/src/world/bootstrap.ts @@ -161,6 +161,64 @@ const resolveAge = (startYear: number | null, birthYear: number): number => { return Math.max(startYear - birthYear, 0); }; +const ADULT_GENERAL_AGE = 14; + +type GeneralBootstrapDisposition = 'active' | 'delayed' | 'expired'; + +const resolveGeneralBootstrapDisposition = ( + general: ScenarioGeneral, + startYear: number | null +): GeneralBootstrapDisposition => { + if (startYear === null) { + return 'active'; + } + if (general.deathYear <= startYear) { + return 'expired'; + } + if (startYear - general.birthYear < ADULT_GENERAL_AGE) { + return 'delayed'; + } + return 'active'; +}; + +const buildDelayedGeneralAction = ( + general: ScenarioGeneral, + nationId: number, + npcType: 2 | 6 +): unknown[] => { + const common = [ + general.affinity ?? 0, + general.name, + general.picture, + nationId, + general.city, + general.leadership, + general.strength, + general.intelligence, + ]; + if (npcType === 2) { + return [ + 'RegNPC', + ...common, + general.officerLevel, + general.birthYear, + general.deathYear, + general.personality, + general.special, + general.text ?? '', + ]; + } + return [ + 'RegNeutralNPC', + ...common, + general.birthYear, + general.deathYear, + general.personality, + general.special, + general.text ?? '', + ]; +}; + const resolveOfficerLevel = (officerLevel: number, nationId: number): number => { if (officerLevel > 0) { return officerLevel; @@ -590,9 +648,33 @@ export const buildScenarioBootstrap = (input: ScenarioBootstrapInput): ScenarioB let nextGeneralId = 1; const allGeneralSeeds: GeneralSeed[] = []; const allGenerals: General[] = []; + const delayedActionsByBirthYear = new Map(); + + const partitionGenerals = (rows: ScenarioGeneral[], npcType: 2 | 6): ScenarioGeneral[] => { + const active: ScenarioGeneral[] = []; + for (const general of rows) { + const disposition = resolveGeneralBootstrapDisposition(general, scenario.startYear); + if (disposition === 'active') { + active.push(general); + continue; + } + if (disposition === 'expired') { + continue; + } + const nationId = resolveNationId(general.nation, nationNameToId, warnings, general.name); + const actions = delayedActionsByBirthYear.get(general.birthYear) ?? []; + actions.push(buildDelayedGeneralAction(general, nationId, npcType)); + delayedActionsByBirthYear.set(general.birthYear, actions); + } + return active; + }; + + const activeGenerals = partitionGenerals(scenario.generals, 2); + const activeGeneralsEx = partitionGenerals(scenario.generalsEx, 2); + const activeGeneralsNeutral = partitionGenerals(scenario.generalsNeutral, 6); const generalResult = buildGeneralSeeds( - scenario.generals, + activeGenerals, 2, nextGeneralId, 'general', @@ -608,7 +690,7 @@ export const buildScenarioBootstrap = (input: ScenarioBootstrapInput): ScenarioB nextGeneralId = generalResult.nextId; const generalExResult = buildGeneralSeeds( - scenario.generalsEx, + activeGeneralsEx, 2, nextGeneralId, 'general_ex', @@ -624,7 +706,7 @@ export const buildScenarioBootstrap = (input: ScenarioBootstrapInput): ScenarioB nextGeneralId = generalExResult.nextId; const generalNeutralResult = buildGeneralSeeds( - scenario.generalsNeutral, + activeGeneralsNeutral, 6, nextGeneralId, 'general_neutral', @@ -638,6 +720,17 @@ export const buildScenarioBootstrap = (input: ScenarioBootstrapInput): ScenarioB allGeneralSeeds.push(...generalNeutralResult.seeds); allGenerals.push(...generalNeutralResult.generals); + const delayedGeneralEvents = Array.from(delayedActionsByBirthYear.entries()).map( + ([birthYear, actions]) => [ + 'Month', + 1_000, + ['Date', '>=', birthYear + ADULT_GENERAL_AGE, 1], + ...actions, + ['DeleteEvent'], + ] + ); + const events = [...scenario.events, ...delayedGeneralEvents]; + const seed: WorldSeedPayload = { scenarioConfig: scenario.config, scenarioMeta, @@ -648,7 +741,7 @@ export const buildScenarioBootstrap = (input: ScenarioBootstrapInput): ScenarioB generals: allGeneralSeeds, troops: [], diplomacy: scenario.diplomacy, - events: scenario.events, + events, initialEvents: scenario.initialEvents, }; @@ -662,7 +755,7 @@ export const buildScenarioBootstrap = (input: ScenarioBootstrapInput): ScenarioB generals: allGenerals, troops: [], diplomacy: scenario.diplomacy, - events: scenario.events, + events, initialEvents: scenario.initialEvents, }; diff --git a/packages/logic/test/worldBootstrap.test.ts b/packages/logic/test/worldBootstrap.test.ts index c7ee940..00d87f8 100644 --- a/packages/logic/test/worldBootstrap.test.ts +++ b/packages/logic/test/worldBootstrap.test.ts @@ -122,4 +122,123 @@ describe('scenario bootstrap', () => { expect(result.seed.generals[0]?.npcType).toBe(2); expect(result.snapshot.scenarioMeta?.title).toBe('Test Scenario'); }); + + it('defers future generals into birth-year registration events and omits expired rows', () => { + const general = ( + name: string, + birthYear: number, + deathYear: number, + nation: number | string | null = 1 + ): ScenarioDefinition['generals'][number] => ({ + affinity: 0, + name, + picture: null, + nation, + city: null, + leadership: 50, + strength: 60, + intelligence: 40, + officerLevel: 3, + birthYear, + deathYear, + personality: null, + special: '', + text: '', + }); + const scenario: ScenarioDefinition = { + title: 'Delayed generals', + startYear: 200, + life: null, + fiction: null, + history: [], + config: { + stat: { total: 100, min: 10, max: 70, npcTotal: 80, npcMax: 60, npcMin: 5, chiefMin: 50 }, + iconPath: '.', + map: {}, + const: {}, + environment: { mapName: 'test-map', unitSet: 'test-unit' }, + }, + nations: [ + { + id: 1, + name: 'TestNation', + color: '#123456', + gold: 5_000, + rice: 3_000, + infoText: '', + tech: 100, + type: 'Test', + level: 3, + cities: ['Alpha'], + }, + ], + diplomacy: [], + generals: [ + general('현재', 180, 240), + general('미래1', 190, 250, 'TestNation'), + general('만료', 170, 200), + ], + generalsEx: [general('미래확장', 190, 260)], + generalsNeutral: [general('미래재야', 191, 260, 0)], + cities: [], + events: [['Month', 500, ['Date', '>=', 200, 1], ['Existing']]], + initialEvents: [], + ignoreDefaultEvents: false, + }; + const map: MapDefinition = { + id: 'test-map', + name: 'test-map', + cities: [ + { + id: 1, + name: 'Alpha', + level: 3, + region: 1, + position: { x: 0, y: 0 }, + connections: [], + max: { + population: 100_000, + agriculture: 20_000, + commerce: 20_000, + security: 15_000, + defence: 5_000, + wall: 3_000, + }, + initial: { + population: 50_000, + agriculture: 10_000, + commerce: 10_000, + security: 8_000, + defence: 2_500, + wall: 1_500, + }, + }, + ], + }; + + const result = buildScenarioBootstrap({ scenario, map }); + + expect(result.seed.generals.map((row) => row.name)).toEqual(['현재']); + expect(result.snapshot.generals.map((row) => row.name)).toEqual(['현재']); + expect(result.seed.events).toEqual([ + ['Month', 500, ['Date', '>=', 200, 1], ['Existing']], + [ + 'Month', + 1_000, + ['Date', '>=', 204, 1], + ['RegNPC', 0, '미래1', null, 1, null, 50, 60, 40, 3, 190, 250, null, '', ''], + ['RegNPC', 0, '미래확장', null, 1, null, 50, 60, 40, 3, 190, 260, null, '', ''], + ['DeleteEvent'], + ], + [ + 'Month', + 1_000, + ['Date', '>=', 205, 1], + ['RegNeutralNPC', 0, '미래재야', null, 0, null, 50, 60, 40, 191, 260, null, '', ''], + ['DeleteEvent'], + ], + ]); + expect(result.snapshot.events).toEqual(result.seed.events); + expect(result.seed.events.flat(3)).not.toContain('만료'); + }); });