From a0f7493f1aa99198bca2ffe864b1d155b8426b13 Mon Sep 17 00:00:00 2001 From: hided62 Date: Sat, 25 Jul 2026 18:56:34 +0000 Subject: [PATCH] feat: raise monthly NPC nations --- app/game-engine/src/turn/databaseHooks.ts | 4 + app/game-engine/src/turn/inMemoryWorld.ts | 11 + .../src/turn/monthlyCreateManyNpcAction.ts | 14 +- .../src/turn/monthlyRaiseNpcNationAction.ts | 399 ++++++++++++++++++ app/game-engine/src/turn/turnDaemon.ts | 29 +- .../test/monthlyRaiseNpcNationAction.test.ts | 314 ++++++++++++++ ...seNpcNationPersistence.integration.test.ts | 315 ++++++++++++++ 7 files changed, 1080 insertions(+), 6 deletions(-) create mode 100644 app/game-engine/src/turn/monthlyRaiseNpcNationAction.ts create mode 100644 app/game-engine/test/monthlyRaiseNpcNationAction.test.ts create mode 100644 app/game-engine/test/monthlyRaiseNpcNationPersistence.integration.test.ts diff --git a/app/game-engine/src/turn/databaseHooks.ts b/app/game-engine/src/turn/databaseHooks.ts index 0d93286..21d8a4f 100644 --- a/app/game-engine/src/turn/databaseHooks.ts +++ b/app/game-engine/src/turn/databaseHooks.ts @@ -572,6 +572,10 @@ export const createDatabaseTurnHooks = async ( chiefGeneralId: nation.chiefGeneralId, gold: nation.gold, rice: nation.rice, + tech: + typeof nation.meta.tech === 'number' && Number.isFinite(nation.meta.tech) + ? Math.trunc(nation.meta.tech) + : 0, level: nation.level, typeCode: nation.typeCode, meta: asJson(nation.meta), diff --git a/app/game-engine/src/turn/inMemoryWorld.ts b/app/game-engine/src/turn/inMemoryWorld.ts index ae259e1..76ad2e2 100644 --- a/app/game-engine/src/turn/inMemoryWorld.ts +++ b/app/game-engine/src/turn/inMemoryWorld.ts @@ -467,6 +467,17 @@ export class InMemoryTurnWorld { return true; } + addNation(nation: Nation): boolean { + if (this.nations.has(nation.id)) { + return false; + } + this.nations.set(nation.id, { ...nation, meta: { ...nation.meta } }); + this.dirtyNationIds.add(nation.id); + this.createdNationIds.add(nation.id); + this.ensureDiplomacyMatrix(); + 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 index 612d0a2..571d7a1 100644 --- a/app/game-engine/src/turn/monthlyCreateManyNpcAction.ts +++ b/app/game-engine/src/turn/monthlyCreateManyNpcAction.ts @@ -40,7 +40,7 @@ const countLegacyNameDuplicates = (generals: readonly TurnGeneral[], baseName: s return count; }; -const pickNames = ( +export const pickNpcNames = ( rng: RandUtil, count: number, existingGenerals: readonly TurnGeneral[], @@ -71,11 +71,15 @@ const pickNames = ( return names; }; -const buildStats = (rng: RandUtil, env: TurnCommandEnv): TurnGeneral['stats'] => { +export const buildNpcStats = ( + rng: RandUtil, + env: TurnCommandEnv, + weights: Readonly> = STAT_TYPE_WEIGHTS +): TurnGeneral['stats'] => { const totalStat = env.npcStatTotal ?? 150; const minStat = env.npcStatMin ?? 10; const maxStat = env.npcStatMax ?? 50; - const pickType = rng.choiceUsingWeight(STAT_TYPE_WEIGHTS); + const pickType = rng.choiceUsingWeight(weights); let mainStat = maxStat - rng.nextRangeInt(0, minStat); let otherStat = minStat + rng.nextRangeInt(0, Math.trunc(minStat / 2)); let subStat = totalStat - mainStat - otherStat; @@ -114,7 +118,7 @@ const buildNpc = (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 stats = buildNpcStats(rng, env); const affinity = rng.nextRangeInt(1, 150); const relativeYear = Math.max(environment.year - environment.startyear, 0); const configValues = asRecord(world.getScenarioConfig().const); @@ -234,7 +238,7 @@ export const createCreateManyNpcHandler = (options: { simpleSerialize(resolveHiddenSeed(world), 'CreateManyNPC', environment.year, environment.month) ) ); - const baseNames = pickNames(rng, requestedCount, world.listGenerals(), options.env); + const baseNames = pickNpcNames(rng, requestedCount, world.listGenerals(), options.env); const created = baseNames.map((baseName) => buildNpc({ world, diff --git a/app/game-engine/src/turn/monthlyRaiseNpcNationAction.ts b/app/game-engine/src/turn/monthlyRaiseNpcNationAction.ts new file mode 100644 index 0000000..fa86b5e --- /dev/null +++ b/app/game-engine/src/turn/monthlyRaiseNpcNationAction.ts @@ -0,0 +1,399 @@ +import { LiteHashDRBG, RandUtil, asRecord } from '@sammo-ts/common'; +import { + LogCategory, + LogFormat, + LogScope, + NATION_TRAIT_KEYS, + getCityDistance, + type City, + type MapDefinition, + type Nation, + type TurnCommandEnv, +} from '@sammo-ts/logic'; +import { simpleSerialize } from '@sammo-ts/logic/war/utils.js'; + +import type { InMemoryTurnWorld } from './inMemoryWorld.js'; +import { buildNpcStats, pickNpcNames } from './monthlyCreateManyNpcAction.js'; +import type { MonthlyEventActionHandler, MonthlyEventEnvironment } from './monthlyEventHandler.js'; +import type { InMemoryReservedTurnStore } from './reservedTurnStore.js'; +import type { TurnGeneral } from './types.js'; + +const CITY_KEYS = ['population', 'agriculture', 'commerce', 'security', 'defence', 'wall'] as const; +const NATION_COLORS = [ + '#FF0000', + '#800000', + '#A0522D', + '#FF6347', + '#FFA500', + '#FFDAB9', + '#FFD700', + '#FFFF00', + '#7CFC00', + '#00FF00', + '#808000', + '#008000', + '#2E8B57', + '#008080', + '#20B2AA', + '#6495ED', + '#7FFFD4', + '#AFEEEE', + '#87CEEB', + '#00FFFF', + '#00BFFF', + '#0000FF', + '#000080', + '#483D8B', + '#7B68EE', + '#BA55D3', + '#800080', + '#FF00FF', + '#FFC0CB', + '#F5F5DC', + '#E0FFFF', + '#FFFFFF', + '#A9A9A9', +] as const; +const AVAILABLE_NATION_TYPES = NATION_TRAIT_KEYS.filter((key) => key !== 'che_중립'); +const NPC_TYPE = 6; +const NPC_PREFIX = 'ⓤ'; +const STAT_TYPE_WEIGHTS = { 무: 1, 지: 1 } as const; + +type CityValues = Record<(typeof CITY_KEYS)[number], number>; + +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 trimAverage = (values: number[]): number => { + if (values.length === 0) { + return 0; + } + const sorted = [...values].sort((left, right) => left - right); + if (sorted.length >= 3) { + const reduceCount = Math.max(Math.round(sorted.length / 6), 1); + sorted.splice(sorted.length - reduceCount, reduceCount); + sorted.splice(0, reduceCount); + } + return Math.round(sorted.reduce((sum, value) => sum + value, 0) / sorted.length); +}; + +const calculateAverageCity = (rng: RandUtil, cities: City[]): CityValues => { + if (cities.length === 0) { + throw new Error('RaiseNPCNation requires at least one level 5 or 6 city.'); + } + const occupied = cities.filter((city) => city.nationId !== 0); + if (occupied.length === 0) { + const selected = rng.choice(cities); + return { + population: selected.populationMax, + agriculture: selected.agricultureMax, + commerce: selected.commerceMax, + security: selected.securityMax, + defence: selected.defenceMax, + wall: selected.wallMax, + }; + } + const sorted = [...occupied].sort((left, right) => { + const leftSum = left.agriculture + left.commerce + left.security + left.defence + left.wall; + const rightSum = right.agriculture + right.commerce + right.security + right.defence + right.wall; + return leftSum - rightSum; + }); + if (sorted.length >= 3) { + const reduceCount = Math.max(Math.round(sorted.length / 6), 1); + sorted.splice(sorted.length - reduceCount, reduceCount); + sorted.splice(0, reduceCount); + } + return Object.fromEntries( + CITY_KEYS.map((key) => [key, Math.trunc(sorted.reduce((sum, city) => sum + city[key], 0) / sorted.length)]) + ) as CityValues; +}; + +const shuffleLegacy = (values: T[]): T[] => { + const result = [...values]; + // ref Util::shuffle_assoc()도 action DRBG가 아닌 PHP의 process-global + // shuffle()을 사용한다. + for (let index = result.length - 1; index > 0; index -= 1) { + const target = Math.floor(Math.random() * (index + 1)); + [result[index], result[target]] = [result[target]!, result[index]!]; + } + return result; +}; + +const buildSpecialityAge = (retirementYear: number, age: number, relativeYear: number, divisor: number): number => + Math.max(Math.round((retirementYear - age) / divisor - relativeYear / 2), 3) + age; + +const createNpcGeneral = (options: { + world: InMemoryTurnWorld; + reservedTurns: InMemoryReservedTurnStore; + rng: RandUtil; + env: TurnCommandEnv; + environment: MonthlyEventEnvironment; + baseName: string; + nationId: number; + cityId: number; + officerLevel: number; + bornYear: number; + deadYear: number; + killturn?: number; +}): TurnGeneral => { + const { world, reservedTurns, rng, env, environment } = options; + const stats = buildNpcStats(rng, env, STAT_TYPE_WEIGHTS); + const affinity = rng.nextRangeInt(1, 150); + const personality = rng.choice(env.availablePersonalities ?? ['che_안전']); + const age = environment.year - options.bornYear; + const relativeYear = Math.max(environment.year - environment.startyear, 0); + const constValues = asRecord(world.getScenarioConfig().const); + const retirementYear = + typeof constValues.retirementYear === 'number' && Number.isFinite(constValues.retirementYear) + ? constValues.retirementYear + : 80; + const turnMinutes = world.getState().tickSeconds / 60; + if (!(turnMinutes > 0) || !Number.isInteger(turnMinutes)) { + throw new Error('RaiseNPCNation requires a positive integer turn term.'); + } + const turnSecond = rng.nextRangeInt(0, turnMinutes * 60 - 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 = + options.killturn ?? + (options.deadYear - environment.year) * 12 + + rng.nextRangeInt(0, 11) + + environment.month - + 1; + const id = world.getNextGeneralId(); + const general: TurnGeneral = { + id, + userId: null, + name: `${NPC_PREFIX}${options.baseName}`, + nationId: options.nationId, + cityId: options.cityId, + troopId: 0, + stats, + experience: age * 100, + dedication: age * 100, + officerLevel: options.officerLevel, + 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: options.bornYear, + deadYear: options.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: buildSpecialityAge(retirementYear, age, relativeYear, 12), + specage2: buildSpecialityAge(retirementYear, age, relativeYear, 6), + dex1: 0, + dex2: 0, + dex3: 0, + dex4: 0, + dex5: 0, + }, + }; + if (!world.addGeneral(general)) { + throw new Error(`RaiseNPCNation generated duplicate general id ${id}.`); + } + reservedTurns.ensureGeneralTurns(id); + return general; +}; + +const resolveServerId = (world: InMemoryTurnWorld): string | null => { + const value = world.getState().meta.serverId; + return typeof value === 'string' && value !== '' ? value : null; +}; + +export const createRaiseNpcNationHandler = (options: { + getWorld: () => InMemoryTurnWorld | null; + reservedTurns: InMemoryReservedTurnStore; + env: TurnCommandEnv; + map: MapDefinition; + loadArchivedNationMaxId?: (serverId: string) => Promise; +}): MonthlyEventActionHandler => { + return async (_args, environment) => { + const world = options.getWorld(); + if (!world) { + return; + } + const targetCities = world.listCities().filter((city) => city.level >= 5 && city.level <= 6); + const rng = new RandUtil( + new LiteHashDRBG( + simpleSerialize(resolveHiddenSeed(world), 'RaiseNPCNation', environment.year, environment.month) + ) + ); + const averageCity = calculateAverageCity(rng, targetCities); + const occupiedCityIds = targetCities.filter((city) => city.nationId !== 0).map((city) => city.id); + const emptyCities = shuffleLegacy(targetCities.filter((city) => city.nationId === 0)); + const activeNations = world.listNations().filter((nation) => nation.id !== 0 && nation.level > 0); + const generalCounts = activeNations.map( + (nation) => world.listGenerals().filter((general) => general.nationId === nation.id).length + ); + const averageGeneralCount = + generalCounts.length === 0 ? options.env.initialNationGenLimit : trimAverage(generalCounts); + const averageTech = + activeNations.length === 0 + ? 0 + : Math.trunc( + activeNations.reduce((sum, nation) => { + const tech = nation.meta.tech; + return sum + (typeof tech === 'number' && Number.isFinite(tech) ? tech : 0); + }, 0) / activeNations.length + ); + + const currentLast = world.getState().meta.lastNationId; + const currentLastNumber = + typeof currentLast === 'number' && Number.isFinite(currentLast) ? currentLast : 0; + const liveNationMax = world.listNations().reduce((maxId, nation) => Math.max(maxId, nation.id), 0); + let resolvedLastNationId = Math.max(currentLastNumber, liveNationMax); + const serverId = resolveServerId(world); + if (serverId && options.loadArchivedNationMaxId) { + const archivedMax = await options.loadArchivedNationMaxId(serverId); + resolvedLastNationId = Math.max(resolvedLastNationId, archivedMax); + } + if (resolvedLastNationId !== currentLastNumber) { + world.updateWorldMeta({ lastNationId: resolvedLastNationId }); + } + + const createdCityIds: number[] = []; + for (const city of emptyCities) { + const distanceFromOccupied = occupiedCityIds.reduce( + (distance, cityId) => Math.min(distance, getCityDistance(options.map, city.id, cityId)), + 999 + ); + if (distanceFromOccupied < 3) { + continue; + } + const distanceFromCreated = createdCityIds.reduce( + (distance, cityId) => Math.min(distance, getCityDistance(options.map, city.id, cityId)), + 999 + ); + if (distanceFromCreated < 2) { + continue; + } + + const nationId = world.getNextNationId(); + const color = rng.choice([...NATION_COLORS]); + const typeCode = rng.choice([...AVAILABLE_NATION_TYPES]); + const nation: Nation = { + id: nationId, + name: `${NPC_PREFIX}${city.name}`, + color, + capitalCityId: city.id, + chiefGeneralId: null, + gold: 0, + rice: 2_000, + power: 0, + level: 2, + typeCode, + meta: { + tech: averageTech, + infoText: `우리도 할 수 있다! ${city.name}군`, + bill: 100, + rate: 15, + scout: 0, + war: 0, + strategicCommandLimit: 24, + surrenderLimit: 72, + can_국기변경: 1, + }, + }; + if (!world.addNation(nation)) { + throw new Error(`RaiseNPCNation generated duplicate nation id ${nationId}.`); + } + + const ruler = createNpcGeneral({ + world, + reservedTurns: options.reservedTurns, + rng, + env: options.env, + environment, + baseName: `${city.name}태수`, + nationId, + cityId: city.id, + officerLevel: 12, + bornYear: environment.year - 20, + deadYear: environment.year + 60, + killturn: 240, + }); + const subordinateNames = pickNpcNames( + rng, + Math.max(averageGeneralCount - 1, 0), + world.listGenerals(), + options.env + ); + for (const baseName of subordinateNames) { + const deadYear = + environment.year + + 10 + + Math.trunc(60 * (1 - Math.log2(rng.nextRange(1, 1024)) / 10)); + createNpcGeneral({ + world, + reservedTurns: options.reservedTurns, + rng, + env: options.env, + environment, + baseName, + nationId, + cityId: city.id, + officerLevel: 1, + bornYear: environment.year - 20, + deadYear, + }); + } + world.updateNation(nationId, { + chiefGeneralId: ruler.id, + meta: { ...nation.meta, gennum: 1 + subordinateNames.length }, + }); + options.reservedTurns.ensureNationTurns(nationId, 12); + options.reservedTurns.ensureNationTurns(nationId, 11); + options.reservedTurns.ensureNationTurns(nationId, 10); + options.reservedTurns.ensureNationTurns(nationId, 9); + world.updateCity(city.id, { + nationId, + population: Math.min(city.populationMax, averageCity.population), + agriculture: Math.min(city.agricultureMax, averageCity.agriculture), + commerce: Math.min(city.commerceMax, averageCity.commerce), + security: Math.min(city.securityMax, averageCity.security), + defence: Math.min(city.defenceMax, averageCity.defence), + wall: Math.min(city.wallMax, averageCity.wall), + meta: { ...city.meta, trust: 100 }, + }); + createdCityIds.push(city.id); + } + + if (createdCityIds.length > 0) { + world.pushLog({ + scope: LogScope.SYSTEM, + category: LogCategory.HISTORY, + text: '【공지】공백지에 임의의 국가가 생성되었습니다.', + format: LogFormat.NOTICE_YEAR_MONTH, + year: environment.year, + month: environment.month, + }); + } + }; +}; diff --git a/app/game-engine/src/turn/turnDaemon.ts b/app/game-engine/src/turn/turnDaemon.ts index 075247d..93c6f9f 100644 --- a/app/game-engine/src/turn/turnDaemon.ts +++ b/app/game-engine/src/turn/turnDaemon.ts @@ -53,6 +53,7 @@ import { createProcessWarIncomeHandler } from './monthlyWarIncomeAction.js'; import { createCreateAdminNpcHandler } from './monthlyCreateAdminNpcAction.js'; import { createCreateManyNpcHandler } from './monthlyCreateManyNpcAction.js'; import { createRegisterNpcHandler } from './monthlyRegisterNpcAction.js'; +import { createRaiseNpcNationHandler } from './monthlyRaiseNpcNationAction.js'; import { buildCommandEnv } from './reservedTurnCommands.js'; import { DatabaseTurnDaemonLease, TurnDaemonLeaseUnavailableError } from '../lifecycle/databaseTurnDaemonLease.js'; @@ -126,6 +127,20 @@ const loadOccupiedAuctionUniqueCounts = async (databaseUrl: string): Promise => { + const connector = createGamePostgresConnector({ url: databaseUrl }); + await connector.connect(); + try { + const row = await connector.prisma.oldNation.aggregate({ + where: { serverId }, + _max: { nation: true }, + }); + return row._max.nation ?? 0; + } finally { + await connector.disconnect(); + } +}; + const resolveRedisConfig = (redisUrl?: string, env: NodeJS.ProcessEnv = process.env) => { if (redisUrl) { return { url: redisUrl }; @@ -160,7 +175,8 @@ const createTurnDaemonRuntimeWithLease = async ( hasEventAction('UpdateNationLevel') || hasEventAction('CreateManyNPC') || hasEventAction('RegNPC') || - hasEventAction('RegNeutralNPC'); + hasEventAction('RegNeutralNPC') || + hasEventAction('RaiseNPCNation'); const reservedTurnStoreHandle = options.generalTurnHandler && !eventRequiresReservedTurns ? null @@ -250,6 +266,17 @@ const createTurnDaemonRuntimeWithLease = async ( }) ); } + eventActions.set( + 'RaiseNPCNation', + createRaiseNpcNationHandler({ + getWorld: () => worldRef, + reservedTurns: reservedTurnStoreHandle.store, + env: monthlyCommandEnv, + map: snapshot.map, + loadArchivedNationMaxId: (serverId) => + loadArchivedNationMaxId(options.databaseUrl, serverId), + }) + ); eventActions.set( 'UpdateNationLevel', createUpdateNationLevelHandler({ diff --git a/app/game-engine/test/monthlyRaiseNpcNationAction.test.ts b/app/game-engine/test/monthlyRaiseNpcNationAction.test.ts new file mode 100644 index 0000000..709e712 --- /dev/null +++ b/app/game-engine/test/monthlyRaiseNpcNationAction.test.ts @@ -0,0 +1,314 @@ +import { describe, expect, it, vi } from 'vitest'; +import { PERSONALITY_TRAIT_KEYS, type City, type MapDefinition, type Nation } from '@sammo-ts/logic'; + +import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js'; +import { createRaiseNpcNationHandler } from '../src/turn/monthlyRaiseNpcNationAction.js'; +import { InMemoryReservedTurnStore } from '../src/turn/reservedTurnStore.js'; +import { buildCommandEnv } from '../src/turn/reservedTurnCommands.js'; +import type { TurnEvent, TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js'; + +const buildCity = (id: number, nationId: number, level = 5): City => ({ + id, + name: `도시${id}`, + nationId, + level, + state: 0, + population: 10_000 + id, + populationMax: 50_000 + id, + agriculture: 1_000 + id, + agricultureMax: 5_000 + id, + commerce: 2_000 + id, + commerceMax: 6_000 + id, + security: 3_000 + id, + securityMax: 7_000 + id, + supplyState: 1, + frontState: 0, + defence: 4_000 + id, + defenceMax: 8_000 + id, + wall: 5_000 + id, + wallMax: 9_000 + id, + meta: { trust: 50 }, +}); + +const buildNation = (id: number): Nation => ({ + id, + name: `국가${id}`, + color: '#777777', + capitalCityId: 1, + chiefGeneralId: 1, + gold: 1_000, + rice: 1_000, + power: 0, + level: 2, + typeCode: 'che_유가', + meta: { tech: 120 }, +}); + +const buildGeneral = (): TurnGeneral => ({ + id: 1, + userId: null, + name: '군주', + nationId: 1, + cityId: 1, + troopId: 0, + stats: { leadership: 50, strength: 50, intelligence: 50 }, + experience: 0, + dedication: 0, + officerLevel: 12, + role: { + personality: 'che_안전', + 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, + bornYear: 170, + deadYear: 250, + affinity: 1, + picture: 'default.jpg', + triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} }, + lastTurn: { command: '휴식' }, + turnTime: new Date('0200-01-01T00:00:00.000Z'), + recentWarTime: null, + meta: { killturn: 1_000 }, +}); + +const map: MapDefinition = { + id: 'test', + name: 'test', + cities: [1, 2, 3, 4, 5].map((id) => ({ + id, + name: `도시${id}`, + level: id === 3 ? 4 : 5, + region: 1, + position: { x: id, y: 0 }, + connections: [id - 1, id + 1].filter((target) => target >= 1 && target <= 5), + max: { + population: 50_000 + id, + agriculture: 5_000 + id, + commerce: 6_000 + id, + security: 7_000 + id, + defence: 8_000 + id, + wall: 9_000 + id, + }, + initial: { + population: 10_000 + id, + agriculture: 1_000 + id, + commerce: 2_000 + id, + security: 3_000 + id, + defence: 4_000 + id, + wall: 5_000 + id, + }, + })), +}; + +const event: TurnEvent = { + id: 1, + targetCode: 'month', + priority: 1_000, + condition: true, + action: [['RaiseNPCNation']], + meta: {}, +}; + +const buildHarness = (archivedNationMaxId = 0, hiddenSeed = 'raise-npc-nation-fixture') => { + const state: TurnWorldState = { + id: 1, + currentYear: 200, + currentMonth: 1, + tickSeconds: 600, + lastTurnTime: new Date('0200-01-01T00:00:00.000Z'), + meta: { hiddenSeed, serverId: 'fixture-server' }, + }; + 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_안전'], + randGenFirstName: ['가'], + randGenMiddleName: [''], + randGenLastName: ['나'], + }, + environment: { mapName: 'test', unitSet: 'default' }, + }, + map, + generals: [buildGeneral()], + cities: [ + buildCity(1, 1), + buildCity(2, 0), + buildCity(3, 0, 4), + buildCity(4, 0), + buildCity(5, 0, 4), + ], + nations: [buildNation(1)], + troops: [], + diplomacy: [], + events: [event], + 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 = createRaiseNpcNationHandler({ + getWorld: () => world, + reservedTurns, + env: commandEnv, + map, + loadArchivedNationMaxId: vi.fn().mockResolvedValue(archivedNationMaxId), + }); + return { + world, + reservedTurns, + commandEnv, + handler, + environment: { + year: 200, + month: 1, + startyear: 190, + currentEventID: 1, + turnTime: state.lastTurnTime, + }, + }; +}; + +describe('RaiseNPCNation monthly action', () => { + it('creates only distance-qualified NPC nations and initializes their ruler and turns', async () => { + const { world, reservedTurns, handler, environment } = buildHarness(); + + await handler([], environment, event); + + const dirty = world.peekDirtyState(); + expect(dirty.createdNations).toHaveLength(1); + expect(dirty.createdGenerals).toHaveLength(1); + expect(dirty.createdNations[0]).toMatchInlineSnapshot(` + { + "capitalCityId": 4, + "chiefGeneralId": 2, + "color": "#2E8B57", + "gold": 0, + "id": 2, + "level": 2, + "meta": { + "bill": 100, + "can_국기변경": 1, + "gennum": 1, + "infoText": "우리도 할 수 있다! 도시4군", + "rate": 15, + "scout": 0, + "strategicCommandLimit": 24, + "surrenderLimit": 72, + "tech": 120, + "war": 0, + }, + "name": "ⓤ도시4", + "power": 0, + "rice": 2000, + "typeCode": "che_오두미도", + } + `); + expect(dirty.createdGenerals[0]).toMatchObject({ + id: 2, + name: 'ⓤ도시4태수', + nationId: 2, + cityId: 4, + officerLevel: 12, + age: 20, + npcState: 6, + bornYear: 180, + deadYear: 260, + meta: { killturn: 240, npc_org: 6 }, + }); + expect(world.getCityById(2)?.nationId).toBe(0); + expect(world.getCityById(4)).toMatchObject({ + nationId: 2, + population: 10_001, + agriculture: 1_001, + commerce: 2_001, + security: 3_001, + defence: 4_001, + wall: 5_001, + meta: { trust: 100 }, + }); + expect(world.getCityById(5)?.nationId).toBe(0); + expect(reservedTurns.getGeneralTurns(2)).toHaveLength(30); + expect(reservedTurns.peekDirtyState().nationInitializationKeys).toEqual([ + '2:12', + '2:11', + '2:10', + '2:9', + ]); + expect(dirty.logs).toEqual([ + expect.objectContaining({ + category: 'HISTORY', + text: '【공지】공백지에 임의의 국가가 생성되었습니다.', + }), + ]); + }); + + it('starts after the archived nation id for the same server', async () => { + const { world, handler, environment } = buildHarness(9); + + await handler([], environment, event); + + expect(world.peekDirtyState().createdNations[0]?.id).toBe(10); + }); + + it.skipIf(!process.env.REF_HIDDEN_SEED)('matches the fixed-seed legacy nation and ruler fields', async () => { + const { world, handler, commandEnv, environment } = buildHarness(99, process.env.REF_HIDDEN_SEED); + commandEnv.availablePersonalities = [...PERSONALITY_TRAIT_KEYS]; + + await handler([], environment, event); + + const nation = world.peekDirtyState().createdNations[0]!; + const ruler = world.peekDirtyState().createdGenerals[0]!; + expect({ + id: nation.id, + color: nation.color, + typeCode: nation.typeCode, + tech: nation.meta.tech, + ruler: { + stats: ruler.stats, + affinity: ruler.affinity, + personality: ruler.role.personality, + turnTime: ruler.turnTime.toISOString(), + killturn: ruler.meta.killturn, + specAge: ruler.meta.specage, + specAge2: ruler.meta.specage2, + }, + }).toEqual({ + id: 100, + color: '#FFA500', + typeCode: 'che_음양가', + tech: 120, + ruler: { + stats: { leadership: 70, strength: 65, intelligence: 15 }, + affinity: 141, + personality: 'che_패권', + turnTime: '0200-01-01T00:08:56.503Z', + killturn: 240, + specAge: 23, + specAge2: 25, + }, + }); + }); +}); diff --git a/app/game-engine/test/monthlyRaiseNpcNationPersistence.integration.test.ts b/app/game-engine/test/monthlyRaiseNpcNationPersistence.integration.test.ts new file mode 100644 index 0000000..4a8dde7 --- /dev/null +++ b/app/game-engine/test/monthlyRaiseNpcNationPersistence.integration.test.ts @@ -0,0 +1,315 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { createGamePostgresConnector, type GamePrismaClient } from '@sammo-ts/infra'; +import type { City, MapDefinition, Nation } from '@sammo-ts/logic'; + +import { createDatabaseTurnHooks } from '../src/turn/databaseHooks.js'; +import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js'; +import { createRaiseNpcNationHandler } from '../src/turn/monthlyRaiseNpcNationAction.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 existingNationId = 990_083; +const createdNationId = 990_086; +const occupiedCityId = 990_083; +const targetCityId = 990_086; +const createdGeneralId = 990_086; + +const buildCity = (id: number, nationId: number, name: string): City => ({ + id, + name, + nationId, + level: 5, + state: 0, + population: 10_000, + populationMax: 50_000, + agriculture: 1_000, + agricultureMax: 5_000, + commerce: 2_000, + commerceMax: 6_000, + security: 3_000, + securityMax: 7_000, + supplyState: 1, + frontState: 0, + defence: 4_000, + defenceMax: 8_000, + wall: 5_000, + wallMax: 9_000, + meta: { trust: 50 }, +}); + +const occupiedCity = buildCity(occupiedCityId, existingNationId, '기준도시'); +const targetCity = buildCity(targetCityId, 0, 'NPC건국도시'); +const existingNation: Nation = { + id: existingNationId, + name: '기준국', + color: '#777777', + capitalCityId: occupiedCityId, + chiefGeneralId: null, + gold: 1_000, + rice: 1_000, + power: 0, + level: 2, + typeCode: 'che_유가', + meta: { tech: 120 }, +}; + +const map: MapDefinition = { + id: 'test', + name: 'test', + cities: [occupiedCityId, 990_084, 990_085, targetCityId].map((id, index, rows) => ({ + id, + name: `지도도시${id}`, + level: 5, + region: 1, + position: { x: index, y: 0 }, + connections: [ + ...(index > 0 ? [rows[index - 1]!] : []), + ...(index + 1 < rows.length ? [rows[index + 1]!] : []), + ], + max: { + population: 50_000, + agriculture: 5_000, + commerce: 6_000, + security: 7_000, + defence: 8_000, + wall: 9_000, + }, + initial: { + population: 10_000, + agriculture: 1_000, + commerce: 2_000, + security: 3_000, + defence: 4_000, + wall: 5_000, + }, + })), +}; + +const event: TurnEvent = { + id: 1, + targetCode: 'month', + priority: 1_000, + condition: true, + action: [['RaiseNPCNation']], + meta: {}, +}; + +integration('RaiseNPCNation 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.nationTurn.deleteMany({ where: { nationId: createdNationId } }); + await db.diplomacy.deleteMany({ + where: { + OR: [{ srcNationId: createdNationId }, { destNationId: createdNationId }], + }, + }); + await db.nation.deleteMany({ where: { id: { in: [createdNationId, existingNationId] } } }); + await db.city.deleteMany({ where: { id: { in: [occupiedCityId, targetCityId] } } }); + }; + + 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 nation, city, diplomacy, ruler, turns, ranks, and history in one flush', async () => { + const createCity = (city: City) => + 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: {}, + }, + }); + await createCity(occupiedCity); + await createCity(targetCity); + await db.nation.create({ + data: { + id: existingNation.id, + name: existingNation.name, + color: existingNation.color, + capitalCityId: existingNation.capitalCityId, + chiefGeneralId: null, + gold: existingNation.gold, + rice: existingNation.rice, + tech: 120, + level: existingNation.level, + typeCode: existingNation.typeCode, + meta: existingNation.meta, + }, + }); + const stateRow = await db.worldState.create({ + data: { + scenarioCode: 'monthly-raise-npc-nation-persistence', + currentYear: 200, + currentMonth: 1, + tickSeconds: 600, + config: {}, + meta: { + hiddenSeed: 'raise-npc-nation-persistence', + lastGeneralId: createdGeneralId - 1, + lastNationId: createdNationId - 1, + serverId: 'raise-npc-nation-persistence', + }, + }, + }); + const state: TurnWorldState = { + id: stateRow.id, + currentYear: 200, + currentMonth: 1, + tickSeconds: 600, + lastTurnTime: new Date('0200-01-01T00:00:00.000Z'), + meta: { + hiddenSeed: 'raise-npc-nation-persistence', + lastGeneralId: createdGeneralId - 1, + lastNationId: createdNationId - 1, + serverId: 'raise-npc-nation-persistence', + }, + }; + 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_안전'], + randGenFirstName: ['가'], + randGenMiddleName: [''], + randGenLastName: ['나'], + }, + environment: { mapName: 'test', unitSet: 'default' }, + }, + map, + generals: [], + cities: [occupiedCity, targetCity], + nations: [existingNation], + 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 = createRaiseNpcNationHandler({ + getWorld: () => world, + reservedTurns, + env: buildCommandEnv(snapshot.scenarioConfig), + map, + loadArchivedNationMaxId: async () => 0, + }); + const dbHooks = await createDatabaseTurnHooks(databaseUrl!, world, { reservedTurns }); + + try { + await handler( + [], + { + 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.nation.findUniqueOrThrow({ where: { id: createdNationId } })).toMatchObject({ + name: 'ⓤNPC건국도시', + capitalCityId: targetCityId, + chiefGeneralId: createdGeneralId, + gold: 0, + rice: 2_000, + tech: 120, + level: 2, + }); + expect(await db.city.findUniqueOrThrow({ where: { id: targetCityId } })).toMatchObject({ + nationId: createdNationId, + trust: 100, + population: occupiedCity.population, + agriculture: occupiedCity.agriculture, + commerce: occupiedCity.commerce, + security: occupiedCity.security, + defence: occupiedCity.defence, + wall: occupiedCity.wall, + }); + expect(await db.general.findUniqueOrThrow({ where: { id: createdGeneralId } })).toMatchObject({ + name: 'ⓤNPC건국도시태수', + nationId: createdNationId, + cityId: targetCityId, + officerLevel: 12, + npcState: 6, + }); + expect(await db.generalTurn.count({ where: { generalId: createdGeneralId } })).toBe(30); + expect(await db.nationTurn.count({ where: { nationId: createdNationId } })).toBe(48); + expect(await db.rankData.count({ where: { generalId: createdGeneralId } })).toBe(41); + expect( + await db.diplomacy.count({ + where: { + OR: [{ srcNationId: createdNationId }, { destNationId: createdNationId }], + }, + }) + ).toBe(2); + expect( + await db.logEntry.findFirst({ + where: { year: 200, month: 1, text: { contains: '공백지에 임의의 국가' } }, + }) + ).not.toBeNull(); + } finally { + await dbHooks.close(); + await db.worldState.delete({ where: { id: stateRow.id } }); + } + }); +});