diff --git a/app/game-engine/src/turn/monthlyCitySupplyAction.ts b/app/game-engine/src/turn/monthlyCitySupplyAction.ts new file mode 100644 index 0000000..b80f033 --- /dev/null +++ b/app/game-engine/src/turn/monthlyCitySupplyAction.ts @@ -0,0 +1,159 @@ +import { JosaUtil } from '@sammo-ts/common'; +import { LogCategory, LogFormat, LogScope, type MapDefinition } from '@sammo-ts/logic'; + +import type { InMemoryTurnWorld } from './inMemoryWorld.js'; +import type { MonthlyEventActionHandler } from './monthlyEventHandler.js'; + +const roundLegacyIntegerColumn = (value: number): number => Math.round(value); + +const resolveOfficerCity = (meta: Record): number => { + const camel = meta.officerCity; + if (typeof camel === 'number' && Number.isFinite(camel)) { + return Math.floor(camel); + } + const snake = meta.officer_city; + return typeof snake === 'number' && Number.isFinite(snake) ? Math.floor(snake) : 0; +}; + +export const createUpdateCitySupplyHandler = (options: { + getWorld: () => InMemoryTurnWorld | null; + map: MapDefinition; +}): MonthlyEventActionHandler => { + const mapCityById = new Map(options.map.cities.map((city) => [city.id, city])); + + return (_args, environment) => { + const world = options.getWorld(); + if (!world) { + return; + } + + const cities = world.listCities().sort((left, right) => left.id - right.id); + const ownedCityById = new Map( + cities.filter((city) => city.nationId !== 0).map((city) => [city.id, { ...city, supplied: false }]) + ); + const queue: Array<{ id: number; nationId: number }> = []; + + for (const nation of world + .listNations() + .filter((candidate) => candidate.level > 0) + .sort((left, right) => left.id - right.id)) { + if (nation.capitalCityId === null) { + continue; + } + const capital = ownedCityById.get(nation.capitalCityId); + if (!capital || capital.nationId !== nation.id) { + continue; + } + capital.supplied = true; + queue.push({ id: capital.id, nationId: nation.id }); + } + + for (let cursor = 0; cursor < queue.length; cursor += 1) { + const current = queue[cursor]!; + const mapCity = mapCityById.get(current.id); + if (!mapCity) { + throw new Error(`UpdateCitySupply map city is missing (cityId=${current.id})`); + } + for (const connectedCityId of mapCity.connections) { + const connected = ownedCityById.get(connectedCityId); + if (!connected || connected.nationId !== current.nationId || connected.supplied) { + continue; + } + connected.supplied = true; + queue.push({ id: connected.id, nationId: current.nationId }); + } + } + + const unsuppliedCities = []; + for (const city of cities) { + const supplied = city.nationId === 0 || ownedCityById.get(city.id)?.supplied === true; + if (supplied) { + world.updateCity(city.id, { supplyState: 1 }); + continue; + } + const trust = typeof city.meta.trust === 'number' ? city.meta.trust : 0; + const damaged = world.updateCity(city.id, { + supplyState: 0, + population: roundLegacyIntegerColumn(city.population * 0.9), + agriculture: roundLegacyIntegerColumn(city.agriculture * 0.9), + commerce: roundLegacyIntegerColumn(city.commerce * 0.9), + security: roundLegacyIntegerColumn(city.security * 0.9), + defence: roundLegacyIntegerColumn(city.defence * 0.9), + wall: roundLegacyIntegerColumn(city.wall * 0.9), + meta: { + ...city.meta, + // 레거시 FLOAT column에 SQL 곱셈 결과가 먼저 저장된 뒤 + // 민심 30 threshold를 판정하므로 float32 양자화를 보존한다. + trust: Math.fround(trust * 0.9), + }, + }); + if (damaged) { + unsuppliedCities.push(damaged); + } + } + + const generals = world.listGenerals().sort((left, right) => left.id - right.id); + const unsuppliedNationByCityId = new Map(unsuppliedCities.map((city) => [city.id, city.nationId])); + for (const general of generals) { + if (unsuppliedNationByCityId.get(general.cityId) !== general.nationId) { + continue; + } + world.updateGeneral(general.id, { + crew: roundLegacyIntegerColumn(general.crew * 0.95), + atmos: roundLegacyIntegerColumn(general.atmos * 0.95), + train: roundLegacyIntegerColumn(general.train * 0.95), + }); + } + + const lostCities = unsuppliedCities.filter((city) => { + const trust = city.meta.trust; + return typeof trust === 'number' && trust < 30; + }); + if (lostCities.length === 0) { + return; + } + + const lostCityIds = new Set(lostCities.map((city) => city.id)); + for (const lostCity of lostCities) { + const josaYi = JosaUtil.pick(lostCity.name, '이'); + world.pushLog({ + scope: LogScope.SYSTEM, + category: LogCategory.HISTORY, + text: `【고립】${lostCity.name}${josaYi} 보급이 끊겨 미지배 도시가 되었습니다.`, + format: LogFormat.YEAR_MONTH, + year: environment.year, + month: environment.month, + }); + } + + for (const general of generals) { + if (!lostCityIds.has(resolveOfficerCity(general.meta))) { + continue; + } + const current = world.getGeneralById(general.id); + if (!current) { + continue; + } + world.updateGeneral(general.id, { + officerLevel: 1, + meta: { + ...current.meta, + officerCity: 0, + officer_city: 0, + }, + }); + } + for (const lostCity of lostCities) { + world.updateCity(lostCity.id, { + nationId: 0, + frontState: 0, + conflict: {}, + meta: { + ...lostCity.meta, + officer_set: 0, + term: 0, + }, + }); + } + }; +}; diff --git a/app/game-engine/src/turn/turnDaemon.ts b/app/game-engine/src/turn/turnDaemon.ts index 40fd1fb..40fce07 100644 --- a/app/game-engine/src/turn/turnDaemon.ts +++ b/app/game-engine/src/turn/turnDaemon.ts @@ -46,6 +46,7 @@ import { type MonthlyEventActionHandler, } from './monthlyEventHandler.js'; import { createRaiseDisasterHandler } from './monthlyDisasterAction.js'; +import { createUpdateCitySupplyHandler } from './monthlyCitySupplyAction.js'; import { DatabaseTurnDaemonLease, TurnDaemonLeaseUnavailableError } from '../lifecycle/databaseTurnDaemonLease.js'; export interface TurnDaemonRuntimeOptions { @@ -167,6 +168,13 @@ const createTurnDaemonRuntimeWithLease = async ( generalActionModules: monthlyActionModules.general, }) ); + eventActions.set( + 'UpdateCitySupply', + createUpdateCitySupplyHandler({ + getWorld: () => worldRef, + map: snapshot.map, + }) + ); eventActions.set('ProcessIncome', (_args, environment) => { void incomeHandler.onMonthChanged?.({ previousYear: environment.month === 1 ? environment.year - 1 : environment.year, diff --git a/app/game-engine/test/monthlyCitySupplyAction.test.ts b/app/game-engine/test/monthlyCitySupplyAction.test.ts new file mode 100644 index 0000000..1379fc3 --- /dev/null +++ b/app/game-engine/test/monthlyCitySupplyAction.test.ts @@ -0,0 +1,282 @@ +import { describe, expect, it } from 'vitest'; +import { + LogCategory, + LogFormat, + LogScope, + type City, + type MapDefinition, + type Nation, +} from '@sammo-ts/logic'; + +import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js'; +import { createUpdateCitySupplyHandler } from '../src/turn/monthlyCitySupplyAction.js'; +import { createMonthlyEventHandler, type MonthlyEventActionHandler } from '../src/turn/monthlyEventHandler.js'; +import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js'; + +const map: MapDefinition = { + id: 'city-supply-test', + name: 'city-supply-test', + cities: [ + { + id: 1, + name: '수도', + level: 1, + region: 1, + position: { x: 0, y: 0 }, + connections: [2], + max: { population: 2_000, agriculture: 1_000, commerce: 1_000, security: 1_000, defence: 1_000, wall: 1_000 }, + initial: { population: 1_000, agriculture: 500, commerce: 500, security: 500, defence: 500, wall: 500 }, + }, + { + id: 2, + name: '연결도시', + level: 1, + region: 1, + position: { x: 1, y: 0 }, + connections: [1], + max: { population: 2_000, agriculture: 1_000, commerce: 1_000, security: 1_000, defence: 1_000, wall: 1_000 }, + initial: { population: 1_000, agriculture: 500, commerce: 500, security: 500, defence: 500, wall: 500 }, + }, + { + id: 3, + name: '고립성', + level: 1, + region: 2, + position: { x: 3, y: 0 }, + connections: [], + max: { population: 2_000, agriculture: 1_000, commerce: 1_000, security: 1_000, defence: 1_000, wall: 1_000 }, + initial: { population: 1_000, agriculture: 500, commerce: 500, security: 500, defence: 500, wall: 500 }, + }, + { + id: 4, + name: '공백지', + level: 1, + region: 2, + position: { x: 4, y: 0 }, + connections: [], + max: { population: 2_000, agriculture: 1_000, commerce: 1_000, security: 1_000, defence: 1_000, wall: 1_000 }, + initial: { population: 1_000, agriculture: 500, commerce: 500, security: 500, defence: 500, wall: 500 }, + }, + ], + defaults: { trust: 50, trade: 100, supplyState: 1, frontState: 0 }, +}; + +const buildCity = (id: number, nationId: number, trust = 40): City => ({ + id, + name: map.cities.find((city) => city.id === id)?.name ?? `도시${id}`, + nationId, + level: 1, + state: 0, + population: 1_001, + populationMax: 2_000, + agriculture: 501, + agricultureMax: 1_000, + commerce: 499, + commerceMax: 1_000, + security: 99, + securityMax: 1_000, + supplyState: 0, + frontState: 2, + defence: 101, + defenceMax: 1_000, + wall: 50, + wallMax: 1_000, + conflict: { 2: 3 }, + meta: { trust, trade: 100, officer_set: 7, term: 2, marker: id }, +}); + +const buildNation = (id: number, capitalCityId: number | null, level = 1): Nation => ({ + id, + name: `국가${id}`, + color: '#000000', + capitalCityId, + chiefGeneralId: null, + gold: 1_000, + rice: 1_000, + power: 0, + level, + typeCode: 'che_중립', + meta: {}, +}); + +const buildGeneral = (id: number, patch: Partial = {}): TurnGeneral => ({ + id, + name: `장수${id}`, + nationId: 1, + cityId: 3, + troopId: 0, + stats: { leadership: 50, strength: 50, intelligence: 50 }, + experience: 0, + dedication: 0, + officerLevel: 4, + 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: 101, + crewTypeId: 1100, + train: 51, + atmos: 99, + age: 30, + npcState: 0, + triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} }, + meta: { killturn: 24, officerCity: 3, officer_city: 3 }, + turnTime: new Date('0193-01-01T00:00:00.000Z'), + ...patch, +}); + +const buildWorld = (options: { + cities: City[]; + nations: Nation[]; + generals?: TurnGeneral[]; +}): InMemoryTurnWorld => { + const state: TurnWorldState = { + id: 1, + currentYear: 193, + currentMonth: 1, + tickSeconds: 600, + lastTurnTime: new Date('0193-01-01T00:00:00.000Z'), + meta: {}, + }; + const actions = new Map(); + const snapshot: TurnWorldSnapshot = { + scenarioConfig: { + stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 50, npcMin: 10, chiefMin: 70 }, + iconPath: '', + map: {}, + const: {}, + environment: { mapName: map.id, unitSet: 'default' }, + }, + map, + generals: options.generals ?? [], + cities: options.cities, + nations: options.nations, + troops: [], + diplomacy: [], + events: [ + { + id: 1, + targetCode: 'pre_month', + priority: 9_000, + condition: true, + action: [['UpdateCitySupply']], + meta: {}, + }, + ], + initialEvents: [], + }; + let world: InMemoryTurnWorld | null = null; + const calendarHandler = createMonthlyEventHandler({ + getWorld: () => world, + startYear: 190, + actions, + }); + world = new InMemoryTurnWorld(state, snapshot, { + schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] }, + calendarHandler, + }); + actions.set('UpdateCitySupply', createUpdateCitySupplyHandler({ getWorld: () => world, map })); + return world; +}; + +describe('UpdateCitySupply monthly action', () => { + it('propagates supply from an owned capital and damages only disconnected owned cities', async () => { + const matchingGeneral = buildGeneral(1); + const foreignGeneral = buildGeneral(2, { nationId: 2 }); + const suppliedGeneral = buildGeneral(3, { cityId: 2 }); + const world = buildWorld({ + cities: [buildCity(3, 1), buildCity(1, 1), buildCity(4, 0), buildCity(2, 1)], + nations: [buildNation(2, 3), buildNation(1, 1)], + generals: [foreignGeneral, suppliedGeneral, matchingGeneral], + }); + + await world.advanceMonth(new Date('0193-02-01T00:00:00.000Z')); + + expect(world.getCityById(1)?.supplyState).toBe(1); + expect(world.getCityById(2)?.supplyState).toBe(1); + expect(world.getCityById(4)?.supplyState).toBe(1); + expect(world.getCityById(3)).toMatchObject({ + nationId: 1, + supplyState: 0, + population: 901, + agriculture: 451, + commerce: 449, + security: 89, + defence: 91, + wall: 45, + }); + expect(world.getCityById(3)?.meta.trust).toBeCloseTo(36); + expect(world.getGeneralById(1)).toMatchObject({ crew: 96, atmos: 94, train: 48 }); + expect(world.getGeneralById(2)).toMatchObject({ crew: 101, atmos: 99, train: 51 }); + expect(world.getGeneralById(3)).toMatchObject({ crew: 101, atmos: 99, train: 51 }); + expect(world.peekDirtyState().logs).toEqual([]); + }); + + it('neutralizes a city below 30 trust after damage and clears every assigned city officer', async () => { + const localOfficer = buildGeneral(1); + const remoteOfficer = buildGeneral(2, { cityId: 1, meta: { killturn: 24, officer_city: 3 } }); + const unrelated = buildGeneral(3, { + cityId: 3, + nationId: 2, + officerLevel: 3, + meta: { killturn: 24, officerCity: 2, officer_city: 2 }, + }); + const world = buildWorld({ + cities: [buildCity(1, 1), buildCity(2, 1), buildCity(3, 1, 33), buildCity(4, 0)], + nations: [buildNation(1, 1), buildNation(2, 3)], + generals: [unrelated, remoteOfficer, localOfficer], + }); + + await world.advanceMonth(new Date('0193-02-01T00:00:00.000Z')); + + expect(world.getCityById(3)).toMatchObject({ + nationId: 0, + supplyState: 0, + frontState: 0, + conflict: {}, + meta: { + trust: expect.closeTo(29.7), + trade: 100, + officer_set: 0, + term: 0, + marker: 3, + }, + }); + expect(world.getGeneralById(1)).toMatchObject({ + officerLevel: 1, + crew: 96, + atmos: 94, + train: 48, + meta: { officerCity: 0, officer_city: 0 }, + }); + expect(world.getGeneralById(2)).toMatchObject({ + officerLevel: 1, + crew: 101, + atmos: 99, + train: 51, + meta: { officerCity: 0, officer_city: 0 }, + }); + expect(world.getGeneralById(3)).toMatchObject({ + officerLevel: 3, + crew: 101, + atmos: 99, + train: 51, + meta: { officerCity: 2, officer_city: 2 }, + }); + expect(world.peekDirtyState().logs).toEqual([ + { + scope: LogScope.SYSTEM, + category: LogCategory.HISTORY, + text: '【고립】고립성이 보급이 끊겨 미지배 도시가 되었습니다.', + format: LogFormat.YEAR_MONTH, + year: 193, + month: 1, + }, + ]); + }); +}); diff --git a/app/game-engine/test/monthlyCitySupplyPersistence.integration.test.ts b/app/game-engine/test/monthlyCitySupplyPersistence.integration.test.ts new file mode 100644 index 0000000..2fe58ca --- /dev/null +++ b/app/game-engine/test/monthlyCitySupplyPersistence.integration.test.ts @@ -0,0 +1,319 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import type { City, MapDefinition, Nation } from '@sammo-ts/logic'; +import { createGamePostgresConnector, type GamePrismaClient } from '@sammo-ts/infra'; + +import { createDatabaseTurnHooks } from '../src/turn/databaseHooks.js'; +import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js'; +import { createUpdateCitySupplyHandler } from '../src/turn/monthlyCitySupplyAction.js'; +import { createMonthlyEventHandler, type MonthlyEventActionHandler } from '../src/turn/monthlyEventHandler.js'; +import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js'; + +const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL; +const integration = describe.skipIf(!databaseUrl); +const nationId = 990_051; +const cityIds = [990_051, 990_052, 990_053] as const; +const generalIds = [990_051, 990_052] as const; +const lostCityName = '고립저장성'; + +const stats = { + population: 1_001, + agriculture: 501, + commerce: 499, + security: 99, + defence: 101, + wall: 50, +}; +const maxStats = { + population: 2_000, + agriculture: 1_000, + commerce: 1_000, + security: 1_000, + defence: 1_000, + wall: 1_000, +}; +const map: MapDefinition = { + id: 'city-supply-persistence', + name: 'city-supply-persistence', + cities: cityIds.map((id, index) => ({ + id, + name: index === 2 ? lostCityName : `보급저장도시${index + 1}`, + level: 1, + region: 1, + position: { x: index, y: 0 }, + connections: index === 0 ? [cityIds[1]] : index === 1 ? [cityIds[0]] : [], + max: maxStats, + initial: stats, + })), + defaults: { trust: 50, trade: 100, supplyState: 1, frontState: 0 }, +}; + +const buildCity = (id: number, trust: number): City => ({ + id, + name: map.cities.find((city) => city.id === id)?.name ?? String(id), + nationId, + level: 1, + state: 0, + population: stats.population, + populationMax: maxStats.population, + agriculture: stats.agriculture, + agricultureMax: maxStats.agriculture, + commerce: stats.commerce, + commerceMax: maxStats.commerce, + security: stats.security, + securityMax: maxStats.security, + supplyState: 0, + frontState: 2, + defence: stats.defence, + defenceMax: maxStats.defence, + wall: stats.wall, + wallMax: maxStats.wall, + conflict: { 2: 3 }, + meta: { trust, trade: 100, region: 1, officer_set: 7, term: 2 }, +}); + +const nation: Nation = { + id: nationId, + name: '보급저장국', + color: '#000000', + capitalCityId: cityIds[0], + chiefGeneralId: null, + gold: 1_000, + rice: 1_000, + power: 0, + level: 1, + typeCode: 'che_중립', + meta: {}, +}; + +const buildGeneral = (id: number, cityId: number): TurnGeneral => ({ + id, + name: `보급장수${id}`, + nationId, + cityId, + troopId: 0, + stats: { leadership: 50, strength: 50, intelligence: 50 }, + experience: 0, + dedication: 0, + officerLevel: 4, + 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: 101, + crewTypeId: 1100, + train: 51, + atmos: 99, + age: 30, + npcState: 0, + triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} }, + meta: { killturn: 24, officerCity: cityIds[2], officer_city: cityIds[2] }, + turnTime: new Date('0193-01-01T00:00:00.000Z'), +}); + +integration('monthly city supply database persistence', () => { + let db: GamePrismaClient; + let closeDb: (() => Promise) | undefined; + + beforeAll(async () => { + const connector = createGamePostgresConnector({ url: databaseUrl! }); + await connector.connect(); + db = connector.prisma; + closeDb = () => connector.disconnect(); + await db.logEntry.deleteMany({ where: { text: { contains: lostCityName } } }); + await db.general.deleteMany({ where: { id: { in: [...generalIds] } } }); + await db.city.deleteMany({ where: { id: { in: [...cityIds] } } }); + await db.nation.deleteMany({ where: { id: nationId } }); + }); + + afterAll(async () => { + await db.logEntry.deleteMany({ where: { text: { contains: lostCityName } } }); + await db.general.deleteMany({ where: { id: { in: [...generalIds] } } }); + await db.city.deleteMany({ where: { id: { in: [...cityIds] } } }); + await db.nation.deleteMany({ where: { id: nationId } }); + await closeDb?.(); + }); + + it('persists supply damage, isolation, officer reset, and the pre-month log date', async () => { + 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: nation.power, + level: nation.level, + typeCode: nation.typeCode, + meta: {}, + }, + }); + const cities = [buildCity(cityIds[0], 50), buildCity(cityIds[1], 50), buildCity(cityIds[2], 33)]; + await db.city.createMany({ + data: cities.map((city) => ({ + id: city.id, + name: city.name, + level: city.level, + nationId: city.nationId, + 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: city.meta.trust as number, + trade: 100, + defence: city.defence, + defenceMax: city.defenceMax, + wall: city.wall, + wallMax: city.wallMax, + region: 1, + conflict: city.conflict ?? {}, + meta: { officer_set: 7, term: 2 }, + })), + }); + const generals = [buildGeneral(generalIds[0], cityIds[2]), buildGeneral(generalIds[1], cityIds[0])]; + await db.general.createMany({ + data: generals.map((general) => ({ + id: general.id, + name: general.name, + nationId: general.nationId, + cityId: general.cityId, + troopId: general.troopId, + npcState: general.npcState, + leadership: general.stats.leadership, + strength: general.stats.strength, + intel: general.stats.intelligence, + experience: general.experience, + dedication: general.dedication, + officerLevel: general.officerLevel, + injury: general.injury, + gold: general.gold, + rice: general.rice, + crew: general.crew, + crewTypeId: general.crewTypeId, + train: general.train, + atmos: general.atmos, + age: general.age, + turnTime: general.turnTime, + meta: general.meta, + })), + }); + const row = await db.worldState.create({ + data: { + scenarioCode: 'city-supply-persistence', + currentYear: 193, + currentMonth: 1, + tickSeconds: 600, + config: {}, + meta: {}, + }, + }); + const state: TurnWorldState = { + id: row.id, + currentYear: 193, + currentMonth: 1, + tickSeconds: 600, + lastTurnTime: new Date('2026-07-25T00:10:00.000Z'), + meta: {}, + }; + const actions = new Map(); + const snapshot: TurnWorldSnapshot = { + scenarioConfig: { + stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 50, npcMin: 10, chiefMin: 70 }, + iconPath: '', + map: {}, + const: {}, + environment: { mapName: map.id, unitSet: 'default' }, + }, + map, + generals, + cities, + nations: [nation], + troops: [], + diplomacy: [], + events: [ + { + id: 1, + targetCode: 'pre_month', + priority: 9_000, + condition: true, + action: [['UpdateCitySupply']], + meta: {}, + }, + ], + initialEvents: [], + }; + let world: InMemoryTurnWorld | null = null; + const calendarHandler = createMonthlyEventHandler({ getWorld: () => world, startYear: 190, actions }); + world = new InMemoryTurnWorld(state, snapshot, { + schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] }, + calendarHandler, + }); + actions.set('UpdateCitySupply', createUpdateCitySupplyHandler({ getWorld: () => world, map })); + const dbHooks = await createDatabaseTurnHooks(databaseUrl!, world); + + try { + await world.advanceMonth(new Date('2026-07-25T00:20:00.000Z')); + await dbHooks.hooks.flushChanges?.({ + lastTurnTime: world.getState().lastTurnTime.toISOString(), + processedGenerals: 0, + processedTurns: 1, + durationMs: 0, + partial: false, + }); + + expect(await db.city.findUniqueOrThrow({ where: { id: cityIds[0] } })).toMatchObject({ + nationId, + supplyState: 1, + population: 1_001, + }); + expect(await db.city.findUniqueOrThrow({ where: { id: cityIds[2] } })).toMatchObject({ + nationId: 0, + supplyState: 0, + frontState: 0, + population: 901, + agriculture: 451, + commerce: 449, + security: 89, + defence: 91, + wall: 45, + conflict: {}, + meta: { officer_set: 0, term: 0, state: 0 }, + }); + expect((await db.city.findUniqueOrThrow({ where: { id: cityIds[2] } })).trust).toBeCloseTo(29.7); + expect(await db.general.findUniqueOrThrow({ where: { id: generalIds[0] } })).toMatchObject({ + officerLevel: 1, + crew: 96, + atmos: 94, + train: 48, + meta: { officerCity: 0, officer_city: 0 }, + }); + expect(await db.general.findUniqueOrThrow({ where: { id: generalIds[1] } })).toMatchObject({ + officerLevel: 1, + crew: 101, + atmos: 99, + train: 51, + meta: { officerCity: 0, officer_city: 0 }, + }); + expect(await db.logEntry.findFirstOrThrow({ where: { text: { contains: lostCityName } } })).toMatchObject({ + year: 193, + month: 1, + text: `●193년 1월:【고립】${lostCityName}이 보급이 끊겨 미지배 도시가 되었습니다.`, + }); + } finally { + await dbHooks.close(); + await db.worldState.delete({ where: { id: row.id } }); + } + }); +}); diff --git a/packages/logic/src/logging/entries.ts b/packages/logic/src/logging/entries.ts index 06593ea..5747794 100644 --- a/packages/logic/src/logging/entries.ts +++ b/packages/logic/src/logging/entries.ts @@ -19,15 +19,17 @@ export const finalizeLogEntry = (entry: LogEntryDraft, context: LogContext): Log return null; } + const year = entry.year ?? context.year; + const month = entry.month ?? context.month; const format = entry.format ?? LogFormat.RAWTEXT; - const text = formatLogText(entry.text, format, context.year, context.month); + const text = formatLogText(entry.text, format, year, month); const record: LogEntryRecord = { scope: entry.scope, category: entry.category, text, - year: context.year, - month: context.month, + year, + month, }; if (entry.generalId !== undefined) { diff --git a/packages/logic/src/logging/types.ts b/packages/logic/src/logging/types.ts index d066c63..1d7a2cc 100644 --- a/packages/logic/src/logging/types.ts +++ b/packages/logic/src/logging/types.ts @@ -28,6 +28,9 @@ export interface LogEntryDraft { subType?: string; meta?: Record; format?: LogFormat; + /** 월 경계 전 action처럼 flush 시점과 다른 달에 귀속되는 로그의 명시적 날짜. */ + year?: number; + month?: number; } export interface LogEntryRecord { diff --git a/packages/logic/test/loggingEntries.test.ts b/packages/logic/test/loggingEntries.test.ts new file mode 100644 index 0000000..f706141 --- /dev/null +++ b/packages/logic/test/loggingEntries.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest'; + +import { finalizeLogEntry, LogCategory, LogFormat, LogScope } from '../src/index.js'; + +describe('finalizeLogEntry', () => { + it('uses an explicit draft year and month for pre-month logs', () => { + expect( + finalizeLogEntry( + { + scope: LogScope.SYSTEM, + category: LogCategory.HISTORY, + text: '이전 달 사건', + format: LogFormat.YEAR_MONTH, + year: 193, + month: 12, + }, + { year: 194, month: 1 } + ) + ).toMatchObject({ + year: 193, + month: 12, + text: '●193년 12월:이전 달 사건', + }); + }); +});