diff --git a/app/game-engine/src/turn/databaseHooks.ts b/app/game-engine/src/turn/databaseHooks.ts index 5ec6ebb..0d93286 100644 --- a/app/game-engine/src/turn/databaseHooks.ts +++ b/app/game-engine/src/turn/databaseHooks.ts @@ -153,6 +153,11 @@ const buildRankRows = ( })); }; +const buildInitialRankRows = ( + general: ReturnType['generals'][number] +): Array<{ generalId: number; nationId: number; type: string; value: number }> => + buildRankRows(general).map((row) => ({ ...row, nationId: 0, value: 0 })); + const buildGeneralUpdate = ( general: ReturnType['generals'][number] ): TurnEngineGeneralUpdateInput => ({ @@ -175,6 +180,10 @@ const buildGeneralUpdate = ( train: toLegacyDatabaseInt(general.train), atmos: toLegacyDatabaseInt(general.atmos), age: general.age, + affinity: general.affinity ?? null, + bornYear: general.bornYear, + deadYear: general.deadYear, + picture: general.picture ?? null, npcState: general.npcState, horseCode: toCode(general.role.items.horse), weaponCode: toCode(general.role.items.weapon), @@ -212,6 +221,10 @@ const buildGeneralCreate = ( train: toLegacyDatabaseInt(general.train), atmos: toLegacyDatabaseInt(general.atmos), age: general.age, + affinity: general.affinity ?? null, + bornYear: general.bornYear, + deadYear: general.deadYear, + picture: general.picture ?? null, horseCode: toCode(general.role.items.horse), weaponCode: toCode(general.role.items.weapon), bookCode: toCode(general.role.items.book), @@ -662,9 +675,12 @@ export const createDatabaseTurnHooks = async ( ), ]); - const rankTargets = [...createdGenerals, ...generals]; - if (rankTargets.length > 0) { - const rankRows = rankTargets.flatMap(buildRankRows); + const rankTargets = generals.filter((general) => !createdIds.has(general.id)); + if (createdGenerals.length > 0 || rankTargets.length > 0) { + const rankRows = [ + ...createdGenerals.flatMap(buildInitialRankRows), + ...rankTargets.flatMap(buildRankRows), + ]; await Promise.all( rankRows.map((row) => prisma.rankData.upsert({ diff --git a/app/game-engine/src/turn/inMemoryWorld.ts b/app/game-engine/src/turn/inMemoryWorld.ts index 7815b25..ae259e1 100644 --- a/app/game-engine/src/turn/inMemoryWorld.ts +++ b/app/game-engine/src/turn/inMemoryWorld.ts @@ -454,6 +454,19 @@ export class InMemoryTurnWorld { return next; } + addGeneral(general: TurnGeneral): boolean { + if (this.generals.has(general.id)) { + return false; + } + const worldKillturn = resolveWorldKillturn(this.state.meta); + const normalized = normalizeGeneralTurnTime({ ...general }, this.state.lastTurnTime); + const ensured = ensureGeneralKillturn(normalized, worldKillturn); + this.generals.set(general.id, ensured); + this.dirtyGeneralIds.add(general.id); + this.createdGeneralIds.add(general.id); + return true; + } + removeGeneral(id: number): boolean { if (!this.generals.has(id)) { return false; diff --git a/app/game-engine/src/turn/monthlyCreateManyNpcAction.ts b/app/game-engine/src/turn/monthlyCreateManyNpcAction.ts new file mode 100644 index 0000000..612d0a2 --- /dev/null +++ b/app/game-engine/src/turn/monthlyCreateManyNpcAction.ts @@ -0,0 +1,271 @@ +import { JosaUtil, LiteHashDRBG, RandUtil, asRecord } from '@sammo-ts/common'; +import { LogCategory, LogFormat, LogScope, type TurnCommandEnv } from '@sammo-ts/logic'; +import { simpleSerialize } from '@sammo-ts/logic/war/utils.js'; + +import type { InMemoryTurnWorld } from './inMemoryWorld.js'; +import type { MonthlyEventActionHandler, MonthlyEventEnvironment } from './monthlyEventHandler.js'; +import type { InMemoryReservedTurnStore } from './reservedTurnStore.js'; +import type { TurnGeneral } from './types.js'; + +const NPC_TYPE = 3; +const NPC_NAME_PREFIX = 'ⓜ'; +const LEGACY_GENERAL_NAME_PREFIXES = ['', 'ⓝ', 'ⓝ', 'ⓜ', 'ⓖ', '㉥', 'ⓤ', 'ⓞ']; +const STAT_TYPE_WEIGHTS = { 무: 0.333, 지: 0.333, 무지: 0.334 } as const; + +const readLegacyNumber = (value: unknown, fallback: number): number => { + if (typeof value === 'number' && Number.isFinite(value)) { + return value; + } + if (typeof value === 'string' && value.trim() !== '') { + const parsed = Number(value); + if (Number.isFinite(parsed)) { + return parsed; + } + } + return fallback; +}; + +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 countLegacyNameDuplicates = (generals: readonly TurnGeneral[], baseName: string): number => { + let count = 0; + for (const prefix of LEGACY_GENERAL_NAME_PREFIXES) { + const target = `${prefix}${baseName}`; + count += generals.filter((general) => general.name.startsWith(target)).length; + } + return count; +}; + +const pickNames = ( + rng: RandUtil, + count: number, + existingGenerals: readonly TurnGeneral[], + env: TurnCommandEnv +): string[] => { + const firstNames = env.randomGeneralFirstNames ?? ['가']; + const middleNames = env.randomGeneralMiddleNames ?? ['']; + const lastNames = env.randomGeneralLastNames ?? ['가']; + const names: string[] = []; + + for (let index = 0; index < count; index += 1) { + let loopCount = 0; + while (true) { + let name = `${rng.choice(firstNames)}${rng.choice(middleNames)}${rng.choice(lastNames)}`; + const duplicateCount = countLegacyNameDuplicates(existingGenerals, name); + if (duplicateCount === 0) { + names.push(name); + break; + } + if (loopCount >= 99 || duplicateCount < 2) { + name += duplicateCount + 1; + names.push(name); + break; + } + loopCount += 1; + } + } + return names; +}; + +const buildStats = (rng: RandUtil, env: TurnCommandEnv): TurnGeneral['stats'] => { + const totalStat = env.npcStatTotal ?? 150; + const minStat = env.npcStatMin ?? 10; + const maxStat = env.npcStatMax ?? 50; + const pickType = rng.choiceUsingWeight(STAT_TYPE_WEIGHTS); + let mainStat = maxStat - rng.nextRangeInt(0, minStat); + let otherStat = minStat + rng.nextRangeInt(0, Math.trunc(minStat / 2)); + let subStat = totalStat - mainStat - otherStat; + + if (subStat < minStat) { + subStat = otherStat; + otherStat = minStat; + mainStat = totalStat - subStat - otherStat; + // GeneralBuilder::fillRandomStat()의 기존 truthy 검사까지 보존한다. + if (mainStat !== 0) { + throw new Error('기본 스탯 설정값이 잘못되어 있음'); + } + } + + if (pickType === '무') { + return { leadership: subStat, strength: mainStat, intelligence: otherStat }; + } + if (pickType === '지') { + return { leadership: subStat, strength: otherStat, intelligence: mainStat }; + } + return { leadership: otherStat, strength: subStat, intelligence: mainStat }; +}; + +const buildSpecialityAge = (retirementYear: number, age: number, relativeYear: number, divisor: number): number => + Math.max(Math.round((retirementYear - age) / divisor - relativeYear / 2), 3) + age; + +const buildNpc = (options: { + world: InMemoryTurnWorld; + reservedTurns: InMemoryReservedTurnStore; + rng: RandUtil; + environment: MonthlyEventEnvironment; + env: TurnCommandEnv; + baseName: string; +}): TurnGeneral => { + const { world, reservedTurns, rng, environment, env } = options; + const age = rng.nextRangeInt(20, 25); + const bornYear = environment.year - age; + const deadYear = environment.year + rng.nextRangeInt(10, 50); + const stats = buildStats(rng, env); + const affinity = rng.nextRangeInt(1, 150); + const relativeYear = Math.max(environment.year - environment.startyear, 0); + const configValues = asRecord(world.getScenarioConfig().const); + const retirementYear = readLegacyNumber(configValues.retirementYear, 80); + const specAge = buildSpecialityAge(retirementYear, age, relativeYear, 12); + const specAge2 = buildSpecialityAge(retirementYear, age, relativeYear, 6); + const personality = rng.choice(env.availablePersonalities ?? ['che_안전']); + const cities = world.listCities(); + if (cities.length === 0) { + throw new Error('CreateManyNPC requires at least one city.'); + } + const city = rng.choice(cities); + const turnMinutes = world.getState().tickSeconds / 60; + if (!(turnMinutes > 0)) { + throw new Error('CreateManyNPC requires a positive turn term.'); + } + const turnSecond = rng.nextRangeInt(0, 60 * turnMinutes - 1); + const turnFraction = rng.nextRangeInt(0, 999_999); + // core DB는 millisecond precision이므로 레거시 microsecond 값을 내림해 + // 저장한다. 먼 과거 연도에서 IEEE-754 덧셈이 반올림하지 않도록 먼저 + // 정수화한다. + const turnTime = new Date(environment.turnTime.getTime() + turnSecond * 1_000 + Math.floor(turnFraction / 1_000)); + const killturn = (deadYear - environment.year) * 12 + rng.nextRangeInt(0, 11) + environment.month - 1; + const id = world.getNextGeneralId(); + const general: TurnGeneral = { + id, + userId: null, + name: `${NPC_NAME_PREFIX}${options.baseName}`, + nationId: 0, + cityId: city.id, + troopId: 0, + stats, + experience: age * 100, + dedication: age * 100, + officerLevel: 0, + role: { + personality, + specialDomestic: env.defaultSpecialDomestic, + specialWar: env.defaultSpecialWar, + items: { horse: null, weapon: null, book: null, item: null }, + }, + injury: 0, + gold: 1_000, + rice: 1_000, + crew: 0, + crewTypeId: env.defaultCrewTypeId, + train: 0, + atmos: 0, + age, + npcState: NPC_TYPE, + bornYear, + deadYear, + affinity, + picture: 'default.jpg', + triggerState: { + flags: {}, + counters: {}, + modifiers: {}, + meta: {}, + }, + lastTurn: { command: '휴식' }, + turnTime, + recentWarTime: null, + meta: { + killturn, + npcType: NPC_TYPE, + npc_org: NPC_TYPE, + belong: 0, + dedlevel: 1, + specage: specAge, + specage2: specAge2, + dex1: 0, + dex2: 0, + dex3: 0, + dex4: 0, + dex5: 0, + }, + }; + if (!world.addGeneral(general)) { + throw new Error(`CreateManyNPC generated a duplicate general id: ${id}`); + } + reservedTurns.ensureGeneralTurns(id); + return general; +}; + +export const createCreateManyNpcHandler = (options: { + getWorld: () => InMemoryTurnWorld | null; + reservedTurns: InMemoryReservedTurnStore; + env: TurnCommandEnv; +}): MonthlyEventActionHandler => { + return (args, environment) => { + const world = options.getWorld(); + if (!world) { + return; + } + const npcCount = readLegacyNumber(args[0], 10); + const fillCount = readLegacyNumber(args[1], 0); + if (npcCount <= 0 && fillCount <= 0) { + return; + } + + let moreGeneralCount = 0; + if (fillCount !== 0) { + const chiefs = world + .listGenerals() + .filter((general) => general.npcState < 3 && general.officerLevel === 12); + const chiefNationIds = new Set(chiefs.map((general) => general.nationId)); + const registeredGeneralCount = world + .listGenerals() + .filter((general) => chiefNationIds.has(general.nationId) && general.npcState < 4).length; + moreGeneralCount = chiefs.length * fillCount - registeredGeneralCount; + } + + const requestedCount = npcCount + moreGeneralCount; + const rng = new RandUtil( + new LiteHashDRBG( + simpleSerialize(resolveHiddenSeed(world), 'CreateManyNPC', environment.year, environment.month) + ) + ); + const baseNames = pickNames(rng, requestedCount, world.listGenerals(), options.env); + const created = baseNames.map((baseName) => + buildNpc({ + world, + reservedTurns: options.reservedTurns, + rng, + environment, + env: options.env, + baseName, + }) + ); + + const count = created.length; + const actionText = + count === 1 + ? `${created[0]!.name}${JosaUtil.pick(created[0]!.name, '라')}는 장수가 등장하였습니다.` + : `장수 ${count}명이 등장하였습니다.`; + world.pushLog({ + scope: LogScope.SYSTEM, + category: LogCategory.ACTION, + text: actionText, + format: LogFormat.MONTH, + year: environment.year, + month: environment.month, + }); + world.pushLog({ + scope: LogScope.SYSTEM, + category: LogCategory.HISTORY, + text: `장수 ${count}명이 등장했습니다.`, + format: LogFormat.NOTICE_YEAR_MONTH, + year: environment.year, + month: environment.month, + }); + }; +}; diff --git a/app/game-engine/src/turn/reservedTurnStore.ts b/app/game-engine/src/turn/reservedTurnStore.ts index 2a665d5..c383328 100644 --- a/app/game-engine/src/turn/reservedTurnStore.ts +++ b/app/game-engine/src/turn/reservedTurnStore.ts @@ -74,6 +74,7 @@ type ReservedTurnDatabaseClient = Pick(); private readonly dirtyGeneralIds = new Set(); private readonly dirtyNationKeys = new Set(); + private readonly pendingGeneralInitializationIds = new Set(); private readonly pendingNationInitializationKeys = new Set(); private readonly maxGeneralTurns: number; private readonly maxNationTurns: number; @@ -207,6 +209,11 @@ export class InMemoryReservedTurnStore { return list[turnIdx] ?? createDefaultEntry(); } + ensureGeneralTurns(generalId: number): void { + this.getGeneralTurns(generalId); + this.pendingGeneralInitializationIds.add(generalId); + } + ensureNationTurns(nationId: number, officerLevel: number): void { const key = buildNationKey(nationId, officerLevel); this.getNationTurns(nationId, officerLevel); @@ -229,6 +236,7 @@ export class InMemoryReservedTurnStore { peekDirtyState(): ReservedTurnChanges { return { generalIds: Array.from(this.dirtyGeneralIds), + generalInitializationIds: Array.from(this.pendingGeneralInitializationIds), nationKeys: Array.from(this.dirtyNationKeys), nationInitializationKeys: Array.from(this.pendingNationInitializationKeys), }; @@ -238,6 +246,9 @@ export class InMemoryReservedTurnStore { for (const generalId of changes.generalIds) { this.dirtyGeneralIds.delete(generalId); } + for (const generalId of changes.generalInitializationIds) { + this.pendingGeneralInitializationIds.delete(generalId); + } for (const key of changes.nationKeys) { this.dirtyNationKeys.delete(key); } @@ -260,6 +271,22 @@ export class InMemoryReservedTurnStore { }); } + for (const generalId of changes.generalInitializationIds) { + if (changes.generalIds.includes(generalId)) { + continue; + } + const turns = this.getGeneralTurns(generalId); + await prisma.generalTurn.createMany({ + data: turns.map((entry, turnIdx) => ({ + generalId, + turnIdx, + actionCode: normalizeAction(entry.action), + arg: asJson(normalizeArgs(entry.args)), + })), + skipDuplicates: true, + }); + } + for (const key of changes.nationKeys) { const [nationIdRaw, officerLevelRaw] = key.split(':'); const nationId = Number(nationIdRaw); diff --git a/app/game-engine/src/turn/turnDaemon.ts b/app/game-engine/src/turn/turnDaemon.ts index 660cc88..3e8dcd0 100644 --- a/app/game-engine/src/turn/turnDaemon.ts +++ b/app/game-engine/src/turn/turnDaemon.ts @@ -51,6 +51,8 @@ import { createUpdateNationLevelHandler } from './monthlyNationLevelAction.js'; import { createProcessSemiAnnualHandler } from './monthlySemiAnnualAction.js'; import { createProcessWarIncomeHandler } from './monthlyWarIncomeAction.js'; import { createCreateAdminNpcHandler } from './monthlyCreateAdminNpcAction.js'; +import { createCreateManyNpcHandler } from './monthlyCreateManyNpcAction.js'; +import { buildCommandEnv } from './reservedTurnCommands.js'; import { DatabaseTurnDaemonLease, TurnDaemonLeaseUnavailableError } from '../lifecycle/databaseTurnDaemonLease.js'; export interface TurnDaemonRuntimeOptions { @@ -153,8 +155,9 @@ const createTurnDaemonRuntimeWithLease = async ( Array.isArray(event.action) && event.action.some((action) => Array.isArray(action) && action[0] === name) ); + const eventRequiresReservedTurns = hasEventAction('UpdateNationLevel') || hasEventAction('CreateManyNPC'); const reservedTurnStoreHandle = - options.generalTurnHandler && !hasEventAction('UpdateNationLevel') + options.generalTurnHandler && !eventRequiresReservedTurns ? null : await createReservedTurnStore({ databaseUrl: options.databaseUrl, @@ -171,6 +174,7 @@ const createTurnDaemonRuntimeWithLease = async ( const nationTraits = await loadNationTraitModules([...NATION_TRAIT_KEYS], new NationTraitLoader()); const nationTraitMap = new Map(nationTraits.map((module) => [module.key, module])); const monthlyActionModules = await loadActionModuleBundle(snapshot.unitSet); + const monthlyCommandEnv = buildCommandEnv(snapshot.scenarioConfig, snapshot.unitSet); const unification = options.calendarHandler ? null : createUnificationHandler({ @@ -220,6 +224,14 @@ const createTurnDaemonRuntimeWithLease = async ( ); eventActions.set('CreateAdminNPC', createCreateAdminNpcHandler()); if (reservedTurnStoreHandle) { + eventActions.set( + 'CreateManyNPC', + createCreateManyNpcHandler({ + getWorld: () => worldRef, + reservedTurns: reservedTurnStoreHandle.store, + env: monthlyCommandEnv, + }) + ); eventActions.set( 'UpdateNationLevel', createUpdateNationLevelHandler({ diff --git a/app/game-engine/src/turn/types.ts b/app/game-engine/src/turn/types.ts index f488d2e..7ad189c 100644 --- a/app/game-engine/src/turn/types.ts +++ b/app/game-engine/src/turn/types.ts @@ -25,6 +25,7 @@ export interface TurnGeneral extends General { bornYear?: number; deadYear?: number; affinity?: number | null; + picture?: string | null; turnTime: Date; recentWarTime?: Date | null; lastTurn?: GeneralLastTurn; diff --git a/app/game-engine/src/turn/worldLoader.ts b/app/game-engine/src/turn/worldLoader.ts index 1cee20f..e7229e6 100644 --- a/app/game-engine/src/turn/worldLoader.ts +++ b/app/game-engine/src/turn/worldLoader.ts @@ -204,6 +204,7 @@ const mapGeneralRow = (row: TurnEngineGeneralRow): TurnGeneral => { bornYear: row.bornYear, deadYear: row.deadYear, affinity: row.affinity, + picture: row.picture, triggerState: { flags: {}, counters: {}, diff --git a/app/game-engine/test/inputEventAtomicity.test.ts b/app/game-engine/test/inputEventAtomicity.test.ts index 711675b..3790bd1 100644 --- a/app/game-engine/test/inputEventAtomicity.test.ts +++ b/app/game-engine/test/inputEventAtomicity.test.ts @@ -49,13 +49,24 @@ describe('input event atomicity', () => { maxNationTurns: 1, }); store.shiftGeneralTurns(7, -1); + store.ensureGeneralTurns(8); await expect(store.flushChanges()).rejects.toThrow('injected write failure'); - expect(store.peekDirtyState()).toEqual({ generalIds: [7], nationKeys: [] }); + expect(store.peekDirtyState()).toEqual({ + generalIds: [7], + generalInitializationIds: [8], + nationKeys: [], + nationInitializationKeys: [], + }); failCreate = false; await store.flushChanges(); - expect(store.peekDirtyState()).toEqual({ generalIds: [], nationKeys: [] }); + expect(store.peekDirtyState()).toEqual({ + generalIds: [], + generalInitializationIds: [], + nationKeys: [], + nationInitializationKeys: [], + }); }); it('dispatches registry mutations that the old lifecycle switch dropped, then commits before responding', async () => { diff --git a/app/game-engine/test/monthlyCreateManyNpcAction.test.ts b/app/game-engine/test/monthlyCreateManyNpcAction.test.ts new file mode 100644 index 0000000..73b6336 --- /dev/null +++ b/app/game-engine/test/monthlyCreateManyNpcAction.test.ts @@ -0,0 +1,356 @@ +import { describe, expect, it, vi } from 'vitest'; +import { LEGACY_RANDOM_GENERAL_FIRST_NAMES, LEGACY_RANDOM_GENERAL_LAST_NAMES, type City } from '@sammo-ts/logic'; + +import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js'; +import { createCreateManyNpcHandler } from '../src/turn/monthlyCreateManyNpcAction.js'; +import { InMemoryReservedTurnStore } from '../src/turn/reservedTurnStore.js'; +import { buildCommandEnv } from '../src/turn/reservedTurnCommands.js'; +import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js'; + +const buildCity = (id: number): City => ({ + id, + name: `도시${id}`, + 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 buildGeneral = (id: number, patch: Partial = {}): TurnGeneral => ({ + id, + userId: null, + name: '가가', + nationId: 1, + cityId: 1, + troopId: 0, + stats: { leadership: 50, strength: 50, intelligence: 50 }, + experience: 0, + dedication: 0, + officerLevel: 12, + role: { + personality: null, + specialDomestic: null, + specialWar: null, + items: { horse: null, weapon: null, book: null, item: null }, + }, + injury: 0, + gold: 1_000, + rice: 1_000, + crew: 0, + crewTypeId: 1100, + train: 0, + atmos: 0, + age: 30, + npcState: 0, + triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} }, + lastTurn: { command: '휴식' }, + turnTime: new Date('0200-05-01T00:00:00.000Z'), + recentWarTime: null, + meta: { killturn: 1_000 }, + ...patch, +}); + +const buildHarness = (generals: TurnGeneral[] = [], cityCount = 2) => { + const state: TurnWorldState = { + id: 1, + currentYear: 200, + currentMonth: 5, + tickSeconds: 600, + lastTurnTime: new Date('0200-05-01T00:00:00.000Z'), + meta: { hiddenSeed: 'create-many-npc-fixture' }, + }; + const snapshot: TurnWorldSnapshot = { + scenarioConfig: { + stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 50, npcMin: 10, chiefMin: 70 }, + iconPath: '', + map: {}, + const: { + defaultStatNPCTotal: 150, + defaultStatNPCMin: 10, + defaultStatNPCMax: 50, + retirementYear: 80, + randGenFirstName: ['가'], + randGenMiddleName: [''], + randGenLastName: ['가'], + availablePersonality: ['che_안전'], + }, + environment: { mapName: 'test', unitSet: 'default' }, + }, + map: { + id: 'test', + name: 'test', + cities: [], + defaults: { trust: 50, trade: 100, supplyState: 1, frontState: 0 }, + }, + generals, + cities: Array.from({ length: cityCount }, (_, index) => buildCity(index + 1)), + nations: [], + 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 commandEnv = buildCommandEnv(snapshot.scenarioConfig); + const handler = createCreateManyNpcHandler({ + getWorld: () => world, + reservedTurns, + env: commandEnv, + }); + const environment = { + year: 200, + month: 5, + startyear: 190, + currentEventID: 1, + turnTime: new Date('0200-05-01T00:00:00.000Z'), + }; + return { world, reservedTurns, handler, environment, commandEnv }; +}; + +describe('CreateManyNPC monthly action', () => { + it('creates the legacy random-name NPC state and initializes all 30 reserved turns', async () => { + const { world, reservedTurns, handler, environment } = buildHarness([buildGeneral(1)]); + + await handler([1, 0], environment, { + id: 1, + targetCode: 'month', + priority: 1, + condition: true, + action: [], + meta: {}, + }); + + const created = world.peekDirtyState().createdGenerals[0]!; + expect({ + id: created.id, + name: created.name, + cityId: created.cityId, + stats: created.stats, + experience: created.experience, + dedication: created.dedication, + age: created.age, + bornYear: created.bornYear, + deadYear: created.deadYear, + affinity: created.affinity, + personality: created.role.personality, + turnTime: created.turnTime.toISOString(), + meta: created.meta, + }).toMatchInlineSnapshot(` + { + "affinity": 94, + "age": 24, + "bornYear": 176, + "cityId": 2, + "deadYear": 226, + "dedication": 2400, + "experience": 2400, + "id": 2, + "meta": { + "belong": 0, + "dedlevel": 1, + "dex1": 0, + "dex2": 0, + "dex3": 0, + "dex4": 0, + "dex5": 0, + "killturn": 323, + "npcType": 3, + "npc_org": 3, + "specage": 27, + "specage2": 28, + }, + "name": "ⓜ가가2", + "personality": "che_안전", + "stats": { + "intelligence": 47, + "leadership": 11, + "strength": 92, + }, + "turnTime": "0200-05-01T00:05:45.821Z", + } + `); + expect(reservedTurns.getGeneralTurns(created.id)).toHaveLength(30); + expect(reservedTurns.peekDirtyState()).toEqual({ + generalIds: [], + generalInitializationIds: [created.id], + nationKeys: [], + nationInitializationKeys: [], + }); + expect(world.peekDirtyState().logs).toMatchInlineSnapshot(` + [ + { + "category": "ACTION", + "format": 4, + "month": 5, + "scope": "SYSTEM", + "text": "ⓜ가가2라는 장수가 등장하였습니다.", + "year": 200, + }, + { + "category": "HISTORY", + "format": 8, + "month": 5, + "scope": "SYSTEM", + "text": "장수 1명이 등장했습니다.", + "year": 200, + }, + ] + `); + }); + + it('uses the legacy fill count and does not reserve names created in the same batch', async () => { + const { world, handler, environment } = buildHarness([ + buildGeneral(1), + buildGeneral(2, { name: '부장', officerLevel: 0 }), + ]); + + await handler([1, 5], environment, { + id: 1, + targetCode: 'month', + priority: 1, + condition: true, + action: [], + meta: {}, + }); + + const created = world.peekDirtyState().createdGenerals; + expect(created).toHaveLength(4); + expect(created.map((general) => general.name)).toEqual(['ⓜ가가2', 'ⓜ가가2', 'ⓜ가가2', 'ⓜ가가2']); + }); + + it('returns without logs or RNG-visible state when both counts are non-positive', async () => { + const { world, reservedTurns, handler, environment } = buildHarness(); + + await handler([0, 0], environment, { + id: 1, + targetCode: 'month', + priority: 1, + condition: true, + action: [], + meta: {}, + }); + + 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 fixture including RNG order', async () => { + const { world, reservedTurns, handler, environment, commandEnv } = buildHarness([], 94); + world.updateWorldMeta({ hiddenSeed: process.env.REF_HIDDEN_SEED }); + commandEnv.npcStatTotal = 150; + commandEnv.npcStatMin = 10; + commandEnv.npcStatMax = 75; + commandEnv.randomGeneralFirstNames = [...LEGACY_RANDOM_GENERAL_FIRST_NAMES]; + commandEnv.randomGeneralMiddleNames = ['']; + commandEnv.randomGeneralLastNames = [...LEGACY_RANDOM_GENERAL_LAST_NAMES]; + commandEnv.availablePersonalities = [ + 'che_안전', + 'che_유지', + 'che_재간', + 'che_출세', + 'che_할거', + 'che_정복', + 'che_패권', + 'che_의협', + 'che_대의', + 'che_왕좌', + ]; + + await handler( + [2, 0], + { + ...environment, + year: 193, + month: 5, + startyear: 190, + turnTime: new Date('0193-05-01T00:00:00.000Z'), + }, + { + id: 1, + targetCode: 'month', + priority: 1, + condition: true, + action: [], + meta: {}, + } + ); + + expect( + world.peekDirtyState().createdGenerals.map((general) => ({ + name: general.name, + cityId: general.cityId, + stats: general.stats, + experience: general.experience, + dedication: general.dedication, + age: general.age, + bornYear: general.bornYear, + deadYear: general.deadYear, + affinity: general.affinity, + personality: general.role.personality, + turnTime: general.turnTime.toISOString(), + killturn: general.meta.killturn, + specage: general.meta.specage, + specage2: general.meta.specage2, + })) + ).toEqual([ + { + name: 'ⓜ심송', + cityId: 33, + stats: { leadership: 15, strength: 67, intelligence: 68 }, + experience: 2_000, + dedication: 2_000, + age: 20, + bornYear: 173, + deadYear: 238, + affinity: 144, + personality: 'che_유지', + turnTime: '0193-05-01T00:08:28.195Z', + killturn: 551, + specage: 24, + specage2: 29, + }, + { + name: 'ⓜ하후후', + cityId: 60, + stats: { leadership: 63, strength: 13, intelligence: 74 }, + experience: 2_000, + dedication: 2_000, + age: 20, + bornYear: 173, + deadYear: 236, + affinity: 141, + personality: 'che_정복', + turnTime: '0193-05-01T00:08:21.776Z', + killturn: 528, + specage: 24, + specage2: 29, + }, + ]); + expect(reservedTurns.peekDirtyState().generalInitializationIds).toEqual([1, 2]); + }); +}); diff --git a/app/game-engine/test/monthlyCreateManyNpcPersistence.integration.test.ts b/app/game-engine/test/monthlyCreateManyNpcPersistence.integration.test.ts new file mode 100644 index 0000000..b183b08 --- /dev/null +++ b/app/game-engine/test/monthlyCreateManyNpcPersistence.integration.test.ts @@ -0,0 +1,243 @@ +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 { createCreateManyNpcHandler } from '../src/turn/monthlyCreateManyNpcAction.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_081; +const createdGeneralId = 990_081; + +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: [['CreateManyNPC', 1, 0]], + meta: {}, +}; + +integration('CreateManyNPC database persistence', () => { + let db: GamePrismaClient; + let closeDb: (() => Promise) | undefined; + + const clean = async () => { + await db.logEntry.deleteMany({ + where: { + OR: [ + { generalId: createdGeneralId }, + { + text: '●5월:ⓜ가가라는 장수가 등장하였습니다.', + year: 193, + month: 5, + }, + { + text: '★193년 5월:장수 1명이 등장했습니다.', + year: 193, + month: 5, + }, + ], + }, + }); + 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 general, 30 resting turns, zeroed canonical rank rows, and logs 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-create-many-npc-persistence', + currentYear: 193, + currentMonth: 5, + tickSeconds: 600, + config: {}, + meta: { hiddenSeed: 'create-many-npc-persistence', lastGeneralId: createdGeneralId - 1 }, + }, + }); + const state: TurnWorldState = { + id: stateRow.id, + currentYear: 193, + currentMonth: 5, + tickSeconds: 600, + lastTurnTime: new Date('0193-05-01T00:00:00.000Z'), + meta: { hiddenSeed: 'create-many-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: { + randGenFirstName: ['가'], + randGenMiddleName: [''], + randGenLastName: ['가'], + availablePersonality: ['che_안전'], + }, + environment: { mapName: 'test', unitSet: 'default' }, + }, + 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 = createCreateManyNpcHandler({ + getWorld: () => world, + reservedTurns, + env: buildCommandEnv(snapshot.scenarioConfig), + }); + const dbHooks = await createDatabaseTurnHooks(databaseUrl!, world, { reservedTurns }); + + try { + await handler( + [1, 0], + { + year: 193, + month: 5, + 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, + npcState: 3, + affinity: expect.any(Number), + bornYear: expect.any(Number), + deadYear: expect.any(Number), + picture: 'default.jpg', + experience: expect.any(Number), + dedication: expect.any(Number), + personalCode: 'che_안전', + }); + const turns = await db.generalTurn.findMany({ + where: { generalId: createdGeneralId }, + orderBy: { turnIdx: 'asc' }, + }); + expect(turns).toHaveLength(30); + expect(new Set(turns.map((turn) => turn.actionCode))).toEqual(new Set(['휴식'])); + const ranks = await db.rankData.findMany({ where: { generalId: createdGeneralId } }); + // Legacy inserts 37 RankColumn rows. Core's canonical rank model + // additionally projects experience/dedication/dex, so it owns 41. + expect(ranks).toHaveLength(41); + expect(ranks.every((rank) => rank.nationId === 0 && rank.value === 0)).toBe(true); + expect( + await db.logEntry.findMany({ + where: { + year: 193, + month: 5, + text: { + in: [ + '●5월:ⓜ가가라는 장수가 등장하였습니다.', + '★193년 5월:장수 1명이 등장했습니다.', + ], + }, + }, + orderBy: { id: 'asc' }, + }) + ).toEqual( + expect.arrayContaining([ + expect.objectContaining({ category: 'ACTION', text: expect.stringContaining('장수가') }), + expect.objectContaining({ category: 'HISTORY', text: expect.stringContaining('장수 1명') }), + ]) + ); + } finally { + await dbHooks.close(); + await db.worldState.delete({ where: { id: stateRow.id } }); + } + }); +}); diff --git a/packages/infra/src/turnEngineDb.ts b/packages/infra/src/turnEngineDb.ts index 37f5bc3..0502eac 100644 --- a/packages/infra/src/turnEngineDb.ts +++ b/packages/infra/src/turnEngineDb.ts @@ -47,6 +47,7 @@ export interface TurnEngineGeneralRow { bornYear: number; deadYear: number; affinity: number | null; + picture: string | null; meta: JsonValue; penalty: JsonValue; turnTime: Date; @@ -168,6 +169,10 @@ export interface TurnEngineGeneralUpdateInput { atmos: number; age: number; npcState: number; + affinity: number | null; + bornYear?: number; + deadYear?: number; + picture: string | null; horseCode: string; weaponCode: string; bookCode: string;