From 09e4a2233c557c3f5ce3dc0be2dc3c39a5f1814a Mon Sep 17 00:00:00 2001 From: hided62 Date: Sat, 25 Jul 2026 19:47:32 +0000 Subject: [PATCH] feat(game-engine): port city changes and NPC troop leaders --- .../src/scenario/scenarioSeeder.ts | 6 +- .../src/turn/monthlyChangeCityAction.ts | 253 ++++++++++++++++++ .../monthlyProvideNpcTroopLeaderAction.ts | 158 +++++++++++ app/game-engine/src/turn/turnDaemon.ts | 19 +- .../test/monthlyChangeCityAction.test.ts | 79 ++++++ ...yNpcSupportPersistence.integration.test.ts | 249 +++++++++++++++++ ...monthlyProvideNpcTroopLeaderAction.test.ts | 206 ++++++++++++++ app/game-engine/test/scenarioSeeder.test.ts | 57 +++- 8 files changed, 1020 insertions(+), 7 deletions(-) create mode 100644 app/game-engine/src/turn/monthlyChangeCityAction.ts create mode 100644 app/game-engine/src/turn/monthlyProvideNpcTroopLeaderAction.ts create mode 100644 app/game-engine/test/monthlyChangeCityAction.test.ts create mode 100644 app/game-engine/test/monthlyNpcSupportPersistence.integration.test.ts create mode 100644 app/game-engine/test/monthlyProvideNpcTroopLeaderAction.test.ts diff --git a/app/game-engine/src/scenario/scenarioSeeder.ts b/app/game-engine/src/scenario/scenarioSeeder.ts index f2f4c78..93e4b61 100644 --- a/app/game-engine/src/scenario/scenarioSeeder.ts +++ b/app/game-engine/src/scenario/scenarioSeeder.ts @@ -8,6 +8,7 @@ import type { ScenarioLoaderOptions } from './scenarioLoader.js'; import { loadScenarioDefinitionById } from './scenarioLoader.js'; import type { UnitSetLoaderOptions } from './unitSetLoader.js'; import { loadUnitSetDefinitionByName } from './unitSetLoader.js'; +import { applyInitialChangeCityEvents } from '../turn/monthlyChangeCityAction.js'; const DEFAULT_TICK_SECONDS = 120 * 60; const DEFAULT_GENERAL_GOLD = 1000; @@ -75,7 +76,7 @@ const resolveSchemaName = (databaseUrl: string): string => { const hasEventTable = async (prisma: RawQueryClient, schema: string): Promise => { try { const result = await prisma.$queryRawUnsafe>( - `SELECT to_regclass('${schema}.event') as regclass` + `SELECT to_regclass('${schema}.event')::text as regclass` ); return Array.isArray(result) && result.length > 0 && result[0]?.regclass !== null; } catch { @@ -211,6 +212,7 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom includeNeutralNationInSeed: options.includeNeutralNationInSeed ?? true, }, }); + seed.cities = applyInitialChangeCityEvents(seed.cities, seed.initialEvents); const connector = createGamePostgresConnector({ url: options.databaseUrl }); const now = options.now ?? new Date(); @@ -513,7 +515,7 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom }); } - const eventRows = [...buildEventRows(seed.events), ...buildEventRows(seed.initialEvents, 'initial')]; + const eventRows = buildEventRows(seed.events); if (eventRows.length > 0 && eventTableReady) { await prisma.event.createMany({ data: eventRows, diff --git a/app/game-engine/src/turn/monthlyChangeCityAction.ts b/app/game-engine/src/turn/monthlyChangeCityAction.ts new file mode 100644 index 0000000..5b2e492 --- /dev/null +++ b/app/game-engine/src/turn/monthlyChangeCityAction.ts @@ -0,0 +1,253 @@ +import type { City, CitySeed } from '@sammo-ts/logic'; + +import type { InMemoryTurnWorld } from './inMemoryWorld.js'; +import type { MonthlyEventActionHandler } from './monthlyEventHandler.js'; + +type MutableCity = City | CitySeed; +type CityNumericKey = + | 'population' + | 'agriculture' + | 'commerce' + | 'security' + | 'defence' + | 'wall'; +type CityMaximumKey = + | 'populationMax' + | 'agricultureMax' + | 'commerceMax' + | 'securityMax' + | 'defenceMax' + | 'wallMax'; +type ChangeCityKey = CityNumericKey | CityMaximumKey | 'trust' | 'trade'; + +const KEY_MAP: Readonly> = { + pop: 'population', + agri: 'agriculture', + comm: 'commerce', + secu: 'security', + trust: 'trust', + def: 'defence', + wall: 'wall', + trade: 'trade', + pop_max: 'populationMax', + agri_max: 'agricultureMax', + comm_max: 'commerceMax', + secu_max: 'securityMax', + def_max: 'defenceMax', + wall_max: 'wallMax', +}; +const MAX_KEY_MAP: Readonly> = { + population: 'populationMax', + agriculture: 'agricultureMax', + commerce: 'commerceMax', + security: 'securityMax', + defence: 'defenceMax', + wall: 'wallMax', +}; +const PERCENT_PATTERN = /^(\d+(?:\.\d+)?)%$/; +const MATH_PATTERN = /^([+\-/*])(\d+(?:\.\d+)?)$/; + +const legacyRound = (value: number): number => + value >= 0 ? Math.floor(value + 0.5) : Math.ceil(value - 0.5); + +const clamp = (value: number, minimum: number, maximum: number): number => + Math.min(maximum, Math.max(minimum, value)); + +const readCityTrust = (city: MutableCity): number => { + if ('trust' in city && typeof city.trust === 'number') { + return city.trust; + } + const value = city.meta.trust; + return typeof value === 'number' ? value : 0; +}; + +const applyOperator = (current: number, operator: string, operand: number): number => { + switch (operator) { + case '+': + return current + operand; + case '-': + return current - operand; + case '*': + return current * operand; + case '/': + if (operand === 0) { + throw new Error('ChangeCity cannot divide by zero.'); + } + return current / operand; + default: + throw new Error(`Unsupported ChangeCity operator: ${operator}`); + } +}; + +const resolveTargets = (cities: readonly MutableCity[], rawTarget: unknown): MutableCity[] => { + if (!rawTarget) { + return [...cities]; + } + const targetType = + typeof rawTarget === 'string' + ? rawTarget + : Array.isArray(rawTarget) && typeof rawTarget[0] === 'string' + ? rawTarget[0] + : null; + const targetArgs = Array.isArray(rawTarget) ? rawTarget.slice(1) : []; + if (targetType === 'all') { + return [...cities]; + } + if (targetType === 'free') { + return cities.filter((city) => city.nationId === 0); + } + if (targetType === 'occupied') { + return cities.filter((city) => city.nationId !== 0); + } + if (targetType === 'cities') { + // ref는 is_numeric(array)를 검사하므로 이 경로의 인자는 항상 도시명 + // 목록으로 SQL에 전달된다. + const names = new Set(targetArgs.map(String)); + return cities.filter((city) => names.has(city.name)); + } + throw new Error('ChangeCity target type is invalid.'); +}; + +const resolveChangedValue = ( + city: MutableCity, + key: ChangeCityKey, + rawValue: unknown +): number => { + if (typeof rawValue !== 'number' && typeof rawValue !== 'string') { + throw new Error('ChangeCity values must be numbers or strings.'); + } + if (key === 'trade') { + const value = Number(rawValue); + if (!Number.isFinite(value)) { + throw new Error('ChangeCity trade must be numeric.'); + } + return clamp(value, 95, 105); + } + if (key === 'trust') { + if (typeof rawValue === 'number') { + if (!Number.isInteger(rawValue)) { + if (rawValue < 0) { + throw new Error('ChangeCity cannot multiply trust by a negative number.'); + } + return Math.min(100, readCityTrust(city) * rawValue); + } + return clamp(rawValue, 0, 100); + } + const percent = rawValue.match(PERCENT_PATTERN); + if (percent) { + return clamp(legacyRound(Number(percent[1])), 0, 100); + } + const math = rawValue.match(MATH_PATTERN); + if (math) { + return clamp(applyOperator(readCityTrust(city), math[1]!, Number(math[2])), 0, 100); + } + throw new Error('ChangeCity trust pattern is invalid.'); + } + + const current = city[key]; + if (typeof rawValue === 'number') { + if (!Number.isInteger(rawValue)) { + if (rawValue < 0) { + throw new Error('ChangeCity cannot multiply a city value by a negative number.'); + } + const maximumKey = MAX_KEY_MAP[key as CityNumericKey]; + if (!maximumKey) { + throw new Error(`ChangeCity float operation is invalid for ${key}.`); + } + return Math.min(city[maximumKey], legacyRound(current * rawValue)); + } + const maximumKey = MAX_KEY_MAP[key as CityNumericKey]; + if (!maximumKey) { + throw new Error(`ChangeCity integer operation is invalid for ${key}.`); + } + return Math.min(city[maximumKey], Math.max(0, rawValue)); + } + + const percent = rawValue.match(PERCENT_PATTERN); + if (percent) { + const maximumKey = MAX_KEY_MAP[key as CityNumericKey]; + if (!maximumKey) { + throw new Error(`ChangeCity percent operation is invalid for ${key}.`); + } + return legacyRound(city[maximumKey] * (legacyRound(Number(percent[1])) / 100)); + } + const math = rawValue.match(MATH_PATTERN); + if (!math) { + throw new Error('ChangeCity value pattern is invalid.'); + } + const result = legacyRound(applyOperator(current, math[1]!, Number(math[2]))); + if (key.endsWith('Max')) { + return Math.max(0, result); + } + const maximumKey = MAX_KEY_MAP[key as CityNumericKey]; + if (!maximumKey) { + throw new Error(`ChangeCity math operation is invalid for ${key}.`); + } + return Math.min(city[maximumKey], Math.max(0, result)); +}; + +export const applyChangeCity = ( + cities: readonly T[], + rawTarget: unknown, + rawActions: unknown +): T[] => { + if (!rawActions || typeof rawActions !== 'object' || Array.isArray(rawActions)) { + throw new Error('ChangeCity actions must be an object.'); + } + const targets = resolveTargets(cities, rawTarget); + return targets.map((city) => { + const next = { ...city, meta: { ...city.meta } } as T & MutableCity; + for (const [rawKey, rawValue] of Object.entries(rawActions)) { + const key = KEY_MAP[rawKey]; + if (!key) { + throw new Error(`Unsupported ChangeCity key: ${rawKey}`); + } + const value = resolveChangedValue(next, key, rawValue); + if (key === 'trust' || key === 'trade') { + if (key in next) { + (next as CitySeed)[key] = value; + } else { + next.meta[key] = value; + } + } else { + next[key] = value; + } + } + return next as T; + }); +}; + +export const createChangeCityHandler = (options: { + getWorld: () => InMemoryTurnWorld | null; +}): MonthlyEventActionHandler => { + return (args) => { + const world = options.getWorld(); + if (!world) { + return; + } + for (const city of applyChangeCity(world.listCities(), args[0], args[1])) { + world.updateCity(city.id, city); + } + }; +}; + +export const applyInitialChangeCityEvents = ( + cities: readonly T[], + initialEvents: readonly unknown[] +): T[] => { + let result = cities.map((city) => ({ ...city })); + for (const rawEvent of initialEvents) { + if (!Array.isArray(rawEvent) || rawEvent[0] !== true) { + throw new Error('Only unconditional initial events are supported.'); + } + for (const rawAction of rawEvent.slice(1)) { + if (!Array.isArray(rawAction) || rawAction[0] !== 'ChangeCity') { + throw new Error('Only ChangeCity initial actions are supported.'); + } + const changed = applyChangeCity(result, rawAction[1], rawAction[2]); + const changedById = new Map(changed.map((city) => [city.id, city])); + result = result.map((city) => changedById.get(city.id) ?? city); + } + } + return result; +}; diff --git a/app/game-engine/src/turn/monthlyProvideNpcTroopLeaderAction.ts b/app/game-engine/src/turn/monthlyProvideNpcTroopLeaderAction.ts new file mode 100644 index 0000000..a938943 --- /dev/null +++ b/app/game-engine/src/turn/monthlyProvideNpcTroopLeaderAction.ts @@ -0,0 +1,158 @@ +import { LiteHashDRBG, RandUtil } from '@sammo-ts/common'; +import 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 = 5; +const NPC_PREFIX = '㉥'; +const MAX_LEADERS_BY_NATION_LEVEL: Readonly> = { + 1: 0, + 2: 1, + 3: 3, + 4: 4, + 5: 6, + 6: 7, + 7: 9, +}; + +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 createTurnTime = ( + rng: RandUtil, + environment: MonthlyEventEnvironment, + tickSeconds: number +): Date => { + const turnMinutes = tickSeconds / 60; + if (!(turnMinutes > 0) || !Number.isInteger(turnMinutes)) { + throw new Error('ProvideNPCTroopLeader requires a positive integer turn term.'); + } + const seconds = rng.nextRangeInt(0, turnMinutes * 60 - 1); + const fraction = rng.nextRangeInt(0, 999_999); + return new Date(environment.turnTime.getTime() + seconds * 1_000 + Math.floor(fraction / 1_000)); +}; + +export const createProvideNpcTroopLeaderHandler = (options: { + getWorld: () => InMemoryTurnWorld | null; + reservedTurns: InMemoryReservedTurnStore; + env: TurnCommandEnv; +}): MonthlyEventActionHandler => { + return (_args, environment) => { + const world = options.getWorld(); + if (!world) { + return; + } + const currentLastId = world.getState().meta.lastNPCTroopLeaderID; + let lastNpcTroopLeaderId = + typeof currentLastId === 'number' && Number.isFinite(currentLastId) + ? Math.trunc(currentLastId) + : 0; + + for (const nation of world.listNations().sort((left, right) => left.id - right.id)) { + const maximum = MAX_LEADERS_BY_NATION_LEVEL[nation.level] ?? 0; + let current = world + .listGenerals() + .filter((general) => general.nationId === nation.id && general.npcState === NPC_TYPE).length; + if (current >= maximum) { + continue; + } + const rng = new RandUtil( + new LiteHashDRBG( + simpleSerialize( + resolveHiddenSeed(world), + 'troopLeader', + environment.year, + environment.month, + nation.id + ) + ) + ); + while (current < maximum) { + lastNpcTroopLeaderId += 1; + const allCities = world.listCities().sort((left, right) => left.id - right.id); + const cityCandidates = allCities.filter((city) => city.nationId === nation.id); + const cityPool = cityCandidates.length > 0 ? cityCandidates : allCities; + if (cityPool.length === 0) { + throw new Error('ProvideNPCTroopLeader requires at least one city.'); + } + const city = rng.choice(cityPool); + const id = world.getNextGeneralId(); + const age = 20; + const general: TurnGeneral = { + id, + userId: null, + name: `${NPC_PREFIX}부대장${String(lastNpcTroopLeaderId).padStart(4, ' ')}`, + nationId: nation.id, + cityId: city.id, + troopId: id, + stats: { leadership: 10, strength: 10, intelligence: 10 }, + experience: age * 100, + dedication: age * 100, + officerLevel: 1, + role: { + personality: 'che_은둔', + specialDomestic: options.env.defaultSpecialDomestic, + specialWar: options.env.defaultSpecialWar, + items: { horse: null, weapon: null, book: null, item: null }, + }, + injury: 0, + gold: 0, + rice: 0, + crew: 0, + crewTypeId: options.env.defaultCrewTypeId, + train: 0, + atmos: 0, + age, + npcState: NPC_TYPE, + bornYear: environment.year - 20, + deadYear: environment.year + 60, + affinity: 999, + picture: 'default.jpg', + triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} }, + lastTurn: { command: '휴식' }, + turnTime: createTurnTime(rng, environment, world.getState().tickSeconds), + recentWarTime: null, + meta: { + killturn: 70, + npcType: NPC_TYPE, + npc_org: NPC_TYPE, + belong: 0, + dedlevel: 1, + specage: 999, + specage2: 999, + dex1: 0, + dex2: 0, + dex3: 0, + dex4: 0, + dex5: 0, + }, + }; + if (!world.addGeneral(general)) { + throw new Error(`ProvideNPCTroopLeader generated duplicate general id ${id}.`); + } + if ( + !world.createTroop({ + id, + nationId: nation.id, + name: general.name, + }) + ) { + throw new Error(`ProvideNPCTroopLeader generated duplicate troop id ${id}.`); + } + options.reservedTurns.replaceGeneralTurns(id, { + action: 'che_집합', + args: {}, + }); + current += 1; + world.updateWorldMeta({ lastNPCTroopLeaderID: lastNpcTroopLeaderId }); + } + } + }; +}; diff --git a/app/game-engine/src/turn/turnDaemon.ts b/app/game-engine/src/turn/turnDaemon.ts index e2aaa8e..c54c1e4 100644 --- a/app/game-engine/src/turn/turnDaemon.ts +++ b/app/game-engine/src/turn/turnDaemon.ts @@ -59,6 +59,8 @@ import { createInvaderEndingHandler, createRaiseInvaderHandler, } from './monthlyInvaderAction.js'; +import { createChangeCityHandler } from './monthlyChangeCityAction.js'; +import { createProvideNpcTroopLeaderHandler } from './monthlyProvideNpcTroopLeaderAction.js'; import { buildCommandEnv } from './reservedTurnCommands.js'; import { DatabaseTurnDaemonLease, TurnDaemonLeaseUnavailableError } from '../lifecycle/databaseTurnDaemonLease.js'; @@ -183,7 +185,8 @@ const createTurnDaemonRuntimeWithLease = async ( hasEventAction('RegNeutralNPC') || hasEventAction('RaiseNPCNation') || hasEventAction('RaiseInvader') || - hasEventAction('AutoDeleteInvader'); + hasEventAction('AutoDeleteInvader') || + hasEventAction('ProvideNPCTroopLeader'); const reservedTurnStoreHandle = options.generalTurnHandler && !eventRequiresReservedTurns ? null @@ -299,6 +302,14 @@ const createTurnDaemonRuntimeWithLease = async ( reservedTurns: reservedTurnStoreHandle.store, }) ); + eventActions.set( + 'ProvideNPCTroopLeader', + createProvideNpcTroopLeaderHandler({ + getWorld: () => worldRef, + reservedTurns: reservedTurnStoreHandle.store, + env: monthlyCommandEnv, + }) + ); eventActions.set( 'UpdateNationLevel', createUpdateNationLevelHandler({ @@ -315,6 +326,12 @@ const createTurnDaemonRuntimeWithLease = async ( getWorld: () => worldRef, }) ); + eventActions.set( + 'ChangeCity', + createChangeCityHandler({ + getWorld: () => worldRef, + }) + ); eventActions.set('ProcessIncome', async (_args, environment) => { await incomeHandler.onMonthChanged?.({ previousYear: environment.month === 1 ? environment.year - 1 : environment.year, diff --git a/app/game-engine/test/monthlyChangeCityAction.test.ts b/app/game-engine/test/monthlyChangeCityAction.test.ts new file mode 100644 index 0000000..c01239f --- /dev/null +++ b/app/game-engine/test/monthlyChangeCityAction.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from 'vitest'; +import type { City, CitySeed } from '@sammo-ts/logic'; + +import { applyChangeCity, applyInitialChangeCityEvents } from '../src/turn/monthlyChangeCityAction.js'; + +const buildCity = (id: number, nationId: number, name = `도시${id}`): City => ({ + id, + name, + nationId, + level: 4, + state: 0, + population: 1_001, + populationMax: 2_000, + agriculture: 501, + agricultureMax: 1_000, + commerce: 499, + commerceMax: 1_000, + security: 99, + securityMax: 1_000, + supplyState: 1, + frontState: 0, + defence: 101, + defenceMax: 1_000, + wall: 50, + wallMax: 1_000, + meta: { trust: 40, trade: 100 }, +}); + +describe('ChangeCity monthly action', () => { + it('applies legacy target selection, percentage rounding, clamping, and ordered max changes', () => { + const cities = [buildCity(1, 0, '낙양'), buildCity(2, 1, '장안')]; + + expect(applyChangeCity(cities, 'free', { pop: '50%', trust: '+70', trade: 999 })).toEqual([ + expect.objectContaining({ + id: 1, + population: 1_000, + meta: expect.objectContaining({ trust: 100, trade: 105 }), + }), + ]); + expect(applyChangeCity(cities, 'occupied', { agri: '*2', comm: '-600' })).toEqual([ + expect.objectContaining({ id: 2, agriculture: 1_000, commerce: 0 }), + ]); + expect( + applyChangeCity(cities, ['cities', 1, '장안'], { + pop_max: '+100', + pop: '100%', + }) + ).toEqual([ + expect.objectContaining({ id: 2, populationMax: 2_100, population: 2_100 }), + ]); + }); + + it('applies unconditional scenario initial events without changing non-target cities', () => { + const cities = [ + { ...buildCity(1, 0), trust: 40, trade: 100 }, + { ...buildCity(2, 1), trust: 40, trade: 100 }, + ] as CitySeed[]; + + const result = applyInitialChangeCityEvents(cities, [ + [ + true, + ['ChangeCity', 'free', { pop: '70%', trust: 80 }], + ['ChangeCity', 'occupied', { def: '70%', wall: '70%' }], + ], + ]); + + expect(result[0]).toMatchObject({ population: 1_400, trust: 80, defence: 101, wall: 50 }); + expect(result[1]).toMatchObject({ population: 1_001, trust: 40, defence: 700, wall: 700 }); + }); + + it('rejects invalid fields and division by zero', () => { + expect(() => applyChangeCity([buildCity(1, 0)], 'all', { unknown: 1 })).toThrow( + 'Unsupported ChangeCity key' + ); + expect(() => applyChangeCity([buildCity(1, 0)], 'all', { pop: '/0' })).toThrow( + 'divide by zero' + ); + }); +}); diff --git a/app/game-engine/test/monthlyNpcSupportPersistence.integration.test.ts b/app/game-engine/test/monthlyNpcSupportPersistence.integration.test.ts new file mode 100644 index 0000000..d9af8a5 --- /dev/null +++ b/app/game-engine/test/monthlyNpcSupportPersistence.integration.test.ts @@ -0,0 +1,249 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { createGamePostgresConnector, type GamePrismaClient } from '@sammo-ts/infra'; +import type { City, Nation } from '@sammo-ts/logic'; + +import { createDatabaseTurnHooks } from '../src/turn/databaseHooks.js'; +import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js'; +import { createChangeCityHandler } from '../src/turn/monthlyChangeCityAction.js'; +import { createProvideNpcTroopLeaderHandler } from '../src/turn/monthlyProvideNpcTroopLeaderAction.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 nationId = 990_091; +const cityId = 990_091; +const generalId = 990_091; + +const city: City = { + id: cityId, + name: '부대장지원도시', + nationId, + level: 4, + state: 0, + population: 1_001, + populationMax: 2_000, + agriculture: 501, + agricultureMax: 1_000, + commerce: 499, + commerceMax: 1_000, + security: 99, + securityMax: 1_000, + supplyState: 1, + frontState: 0, + defence: 101, + defenceMax: 1_000, + wall: 50, + wallMax: 1_000, + meta: { trust: 40, trade: 100 }, +}; + +const nation: Nation = { + id: nationId, + name: '지원국', + color: '#777777', + capitalCityId: cityId, + chiefGeneralId: null, + gold: 0, + rice: 0, + power: 0, + level: 2, + typeCode: 'che_중립', + meta: {}, +}; + +const event: TurnEvent = { + id: 1, + targetCode: 'month', + priority: 1_000, + condition: true, + action: [['ProvideNPCTroopLeader']], + meta: {}, +}; + +integration('monthly NPC support database persistence', () => { + let db: GamePrismaClient; + let closeDb: (() => Promise) | undefined; + + const clean = async () => { + await db.generalTurn.deleteMany({ where: { generalId } }); + await db.rankData.deleteMany({ where: { generalId } }); + await db.troop.deleteMany({ where: { troopLeaderId: generalId } }); + await db.general.deleteMany({ where: { id: generalId } }); + await db.nation.deleteMany({ where: { id: nationId } }); + 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 changed city, leader, troop, ranks, assembly turns, and world sequence', async () => { + await db.city.create({ + data: { + id: city.id, + name: city.name, + nationId: 0, + 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: 40, + trade: 100, + defence: city.defence, + defenceMax: city.defenceMax, + wall: city.wall, + wallMax: city.wallMax, + region: 1, + conflict: {}, + meta: {}, + }, + }); + await db.nation.create({ + data: { + id: nation.id, + name: nation.name, + color: nation.color, + capitalCityId: nation.capitalCityId, + chiefGeneralId: null, + gold: nation.gold, + rice: nation.rice, + tech: 0, + level: nation.level, + typeCode: nation.typeCode, + meta: {}, + }, + }); + await db.city.update({ where: { id: cityId }, data: { nationId } }); + const stateRow = await db.worldState.create({ + data: { + scenarioCode: 'monthly-npc-support-persistence', + currentYear: 200, + currentMonth: 1, + tickSeconds: 600, + config: {}, + meta: { + hiddenSeed: 'monthly-npc-support-persistence', + lastGeneralId: generalId - 1, + lastNPCTroopLeaderID: 40, + }, + }, + }); + const state: TurnWorldState = { + id: stateRow.id, + currentYear: 200, + currentMonth: 1, + tickSeconds: 600, + lastTurnTime: new Date('0200-01-01T00:00:00.000Z'), + meta: { + hiddenSeed: 'monthly-npc-support-persistence', + lastGeneralId: generalId - 1, + lastNPCTroopLeaderID: 40, + }, + }; + const snapshot: TurnWorldSnapshot = { + scenarioConfig: { + stat: { total: 165, min: 15, max: 80, npcTotal: 150, npcMax: 75, npcMin: 10, chiefMin: 65 }, + iconPath: '.', + map: {}, + const: {}, + environment: { mapName: 'test', unitSet: 'default' }, + }, + map: { id: 'test', name: 'test', cities: [] }, + generals: [], + cities: [city], + nations: [nation], + 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 provide = createProvideNpcTroopLeaderHandler({ + getWorld: () => world, + reservedTurns, + env: buildCommandEnv(snapshot.scenarioConfig), + }); + const changeCity = createChangeCityHandler({ getWorld: () => world }); + const dbHooks = await createDatabaseTurnHooks(databaseUrl!, world, { reservedTurns }); + + try { + const environment = { + year: 200, + month: 1, + startyear: 190, + currentEventID: 1, + turnTime: state.lastTurnTime, + }; + await provide([], environment, event); + await changeCity(['all', { pop_max: '+100', pop: '50%', trust: 80, trade: 105 }], environment, event); + await dbHooks.hooks.flushChanges?.({ + lastTurnTime: state.lastTurnTime.toISOString(), + processedGenerals: 0, + processedTurns: 1, + durationMs: 0, + partial: false, + }); + + expect(await db.city.findUniqueOrThrow({ where: { id: cityId } })).toMatchObject({ + populationMax: 2_100, + population: 1_050, + trust: 80, + trade: 105, + }); + expect(await db.general.findUniqueOrThrow({ where: { id: generalId } })).toMatchObject({ + name: '㉥부대장 41', + nationId, + cityId, + troopId: generalId, + leadership: 10, + strength: 10, + intel: 10, + experience: 2_000, + dedication: 2_000, + npcState: 5, + affinity: 999, + meta: expect.objectContaining({ killturn: 70 }), + }); + expect( + await db.troop.findUniqueOrThrow({ where: { troopLeaderId: generalId } }) + ).toMatchObject({ + nationId, + name: '㉥부대장 41', + }); + const turns = await db.generalTurn.findMany({ where: { generalId } }); + expect(turns).toHaveLength(30); + expect(new Set(turns.map((turn) => turn.actionCode))).toEqual(new Set(['che_집합'])); + expect(await db.rankData.count({ where: { generalId } })).toBe(41); + expect((await db.worldState.findUniqueOrThrow({ where: { id: stateRow.id } })).meta).toMatchObject({ + lastNPCTroopLeaderID: 41, + }); + } finally { + await dbHooks.close(); + await db.worldState.deleteMany({ where: { id: stateRow.id } }); + } + }); +}); diff --git a/app/game-engine/test/monthlyProvideNpcTroopLeaderAction.test.ts b/app/game-engine/test/monthlyProvideNpcTroopLeaderAction.test.ts new file mode 100644 index 0000000..403e3e7 --- /dev/null +++ b/app/game-engine/test/monthlyProvideNpcTroopLeaderAction.test.ts @@ -0,0 +1,206 @@ +import { LiteHashDRBG, RandUtil } from '@sammo-ts/common'; +import { simpleSerialize } from '@sammo-ts/logic/war/utils.js'; +import { describe, expect, it, vi } from 'vitest'; +import type { City, Nation } from '@sammo-ts/logic'; + +import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js'; +import { createProvideNpcTroopLeaderHandler } from '../src/turn/monthlyProvideNpcTroopLeaderAction.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, nationId: number): City => ({ + id, + name: `도시${id}`, + nationId, + level: 4, + state: 0, + population: 1_000, + populationMax: 2_000, + agriculture: 500, + agricultureMax: 1_000, + commerce: 500, + commerceMax: 1_000, + security: 500, + securityMax: 1_000, + supplyState: 1, + frontState: 0, + defence: 500, + defenceMax: 1_000, + wall: 500, + wallMax: 1_000, + meta: {}, +}); + +const buildNation = (id: number, level: number): Nation => ({ + id, + name: `국가${id}`, + color: '#777777', + capitalCityId: id, + chiefGeneralId: null, + gold: 0, + rice: 0, + power: 0, + level, + typeCode: 'che_중립', + meta: {}, +}); + +const buildGeneral = (id: number, nationId: number, npcState = 0): TurnGeneral => ({ + id, + userId: null, + name: `장수${id}`, + nationId, + cityId: nationId, + troopId: 0, + stats: { leadership: 50, strength: 50, intelligence: 50 }, + experience: 0, + dedication: 0, + officerLevel: 1, + role: { + personality: null, + specialDomestic: null, + specialWar: null, + items: { horse: null, weapon: null, book: null, item: null }, + }, + injury: 0, + gold: 0, + rice: 0, + crew: 0, + crewTypeId: 1100, + train: 0, + atmos: 0, + age: 30, + npcState, + 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 scenarioConfig: TurnWorldSnapshot['scenarioConfig'] = { + stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 75, npcMin: 10, chiefMin: 70 }, + iconPath: '.', + map: {}, + const: {}, + environment: { mapName: 'test', unitSet: 'default' }, +}; + +describe('ProvideNPCTroopLeader monthly action', () => { + it('fills each nation level quota and creates matching troops and 30 assembly turns', async () => { + const state: TurnWorldState = { + id: 1, + currentYear: 200, + currentMonth: 1, + tickSeconds: 600, + lastTurnTime: new Date('0200-01-01T00:00:00.000Z'), + meta: { + hiddenSeed: process.env.REF_HIDDEN_SEED ?? 'troop-leader-fixture', + lastNPCTroopLeaderID: 8, + }, + }; + const snapshot: TurnWorldSnapshot = { + scenarioConfig, + map: { id: 'test', name: 'test', cities: [] }, + generals: [buildGeneral(1, 1), buildGeneral(2, 2, 5)], + cities: [buildCity(1, 1), buildCity(2, 1)], + nations: [buildNation(1, 3), buildNation(2, 2)], + 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 handler = createProvideNpcTroopLeaderHandler({ + getWorld: () => world, + reservedTurns, + env: buildCommandEnv(scenarioConfig), + }); + + await handler( + [], + { + year: 200, + month: 1, + startyear: 190, + currentEventID: 1, + turnTime: state.lastTurnTime, + }, + { id: 1, targetCode: 'month', priority: 1, condition: true, action: [], meta: {} } + ); + + const created = world.peekDirtyState().createdGenerals; + expect(created).toHaveLength(3); + expect(created.map((general) => general.name)).toEqual([ + '㉥부대장 9', + '㉥부대장 10', + '㉥부대장 11', + ]); + expect(created[0]).toMatchObject({ + nationId: 1, + cityId: process.env.REF_HIDDEN_SEED ? 2 : 1, + troopId: 3, + stats: { leadership: 10, strength: 10, intelligence: 10 }, + experience: 2_000, + dedication: 2_000, + officerLevel: 1, + role: { personality: 'che_은둔' }, + gold: 0, + rice: 0, + age: 20, + npcState: 5, + bornYear: 180, + deadYear: 260, + affinity: 999, + meta: { killturn: 70, specage: 999, specage2: 999 }, + }); + expect(world.peekDirtyState().createdTroops).toEqual( + created.map((general) => ({ + id: general.id, + nationId: general.nationId, + name: general.name, + })) + ); + for (const general of created) { + expect(reservedTurns.getGeneralTurns(general.id)).toEqual( + Array.from({ length: 30 }, () => ({ action: 'che_집합', args: {} })) + ); + } + expect(world.getState().meta.lastNPCTroopLeaderID).toBe(11); + if (process.env.REF_HIDDEN_SEED) { + const probe = new RandUtil( + new LiteHashDRBG(simpleSerialize(process.env.REF_HIDDEN_SEED, 'troopLeader', 200, 1, 1)) + ); + expect([ + probe.choice([1, 2]), + probe.nextRangeInt(0, 599), + probe.nextRangeInt(0, 999_999), + ]).toEqual([2, 567, 821_811]); + expect( + created.map((general) => ({ + cityId: general.cityId, + turnTime: general.turnTime.toISOString(), + })) + ).toEqual([ + { cityId: 2, turnTime: '0200-01-01T00:09:27.821Z' }, + { cityId: 1, turnTime: '0200-01-01T00:01:59.665Z' }, + { cityId: 2, turnTime: '0200-01-01T00:07:50.470Z' }, + ]); + } + }); +}); diff --git a/app/game-engine/test/scenarioSeeder.test.ts b/app/game-engine/test/scenarioSeeder.test.ts index 00475c4..e453fc5 100644 --- a/app/game-engine/test/scenarioSeeder.test.ts +++ b/app/game-engine/test/scenarioSeeder.test.ts @@ -16,6 +16,15 @@ type ScenarioSeederPrismaClient = { }; city: { count(): Promise; + findUnique(args: { where: { id: number } }): Promise<{ + population: number; + agriculture: number; + commerce: number; + security: number; + trust: number; + defence: number; + wall: number; + } | null>; }; general: { count(): Promise; @@ -26,6 +35,9 @@ type ScenarioSeederPrismaClient = { where: { srcNationId: number; destNationId: number }; }): Promise<{ stateCode: number; term: number } | null>; }; + event: { + count(): Promise; + }; worldState: { findFirst(): Promise<{ config: unknown; @@ -50,7 +62,7 @@ const requiredTables = [ const hasRequiredTables = async (prisma: ScenarioSeederPrismaClient, schemaName: string): Promise => { for (const table of requiredTables) { const result = (await prisma.$queryRawUnsafe( - `SELECT to_regclass('${schemaName}.${table}') as regclass` + `SELECT to_regclass('${schemaName}.${table}')::text as regclass` )) as Array<{ regclass: string | null }>; if (!Array.isArray(result) || result.length === 0 || result[0]?.regclass === null) { return false; @@ -90,19 +102,52 @@ describeDb('scenario database seed', () => { await connector.connect(); try { const prisma = connector.prisma as unknown as ScenarioSeederPrismaClient; - const [nationCount, cityCount, generalCount, diplomacyCount] = await Promise.all([ + const [nationCount, cityCount, generalCount, diplomacyCount, eventCount] = await Promise.all([ prisma.nation.count(), prisma.city.count(), prisma.general.count(), prisma.diplomacy.count(), + prisma.event.count(), ]); expect(nationCount).toBe(seed.nations.length); expect(cityCount).toBe(seed.cities.length); expect(generalCount).toBe(seed.generals.length); expect(diplomacyCount).toBe(seed.nations.length * Math.max(0, seed.nations.length - 1)); + expect(eventCount).toBe(seed.events.length); expect(generalCount).toBeGreaterThan(0); + const freeCity = seed.cities.find((city) => city.nationId === 0); + const occupiedCity = seed.cities.find((city) => city.nationId !== 0); + expect(freeCity).toMatchObject({ + population: freeCity ? Math.round(freeCity.populationMax * 0.7) : undefined, + agriculture: freeCity ? Math.round(freeCity.agricultureMax * 0.7) : undefined, + commerce: freeCity ? Math.round(freeCity.commerceMax * 0.7) : undefined, + security: freeCity ? Math.round(freeCity.securityMax * 0.7) : undefined, + trust: 80, + }); + expect(occupiedCity).toMatchObject({ + population: occupiedCity ? Math.round(occupiedCity.populationMax * 0.7) : undefined, + defence: occupiedCity ? Math.round(occupiedCity.defenceMax * 0.7) : undefined, + wall: occupiedCity ? Math.round(occupiedCity.wallMax * 0.7) : undefined, + trust: 80, + }); + for (const city of [freeCity, occupiedCity]) { + expect(city).toBeDefined(); + if (!city) { + continue; + } + expect(await prisma.city.findUnique({ where: { id: city.id } })).toMatchObject({ + population: city.population, + agriculture: city.agriculture, + commerce: city.commerce, + security: city.security, + trust: city.trust, + defence: city.defence, + wall: city.wall, + }); + } + if (seed.diplomacy.length > 0) { const sample = seed.diplomacy[0]; const row = await prisma.diplomacy.findFirst({ @@ -158,8 +203,12 @@ describeDb('scenario database seed', () => { }, }); - const expectedGenerals = scenario.generals.length + scenario.generalsNeutral.length; - expect(seed.generals.length).toBe(expectedGenerals); + // Future-born entries are converted to delayed events, so the raw + // scenario array length is not the installed general count. + expect(seed.generals.length).toBeGreaterThan(0); + expect(seed.generals.length).toBeLessThan( + scenario.generals.length + scenario.generalsNeutral.length + scenario.generalsEx.length + ); const connector = createGamePostgresConnector({ url: databaseUrl }); await connector.connect();