diff --git a/app/game-engine/src/turn/monthlyDisasterAction.ts b/app/game-engine/src/turn/monthlyDisasterAction.ts new file mode 100644 index 00000000..b9bfd155 --- /dev/null +++ b/app/game-engine/src/turn/monthlyDisasterAction.ts @@ -0,0 +1,199 @@ +import { LiteHashDRBG, RandUtil } from '@sammo-ts/common'; +import { + GeneralActionPipeline, + LogCategory, + LogFormat, + LogScope, + createGeneralTriggerContext, + type GeneralActionModule, +} from '@sammo-ts/logic'; +import { simpleSerialize } from '@sammo-ts/logic/war/utils.js'; + +import type { InMemoryTurnWorld } from './inMemoryWorld.js'; +import type { MonthlyEventActionHandler } from './monthlyEventHandler.js'; + +type DisasterText = { + title: string; + stateCode: number; + body: string; +}; + +const DISASTER_TEXT_BY_MONTH: Readonly> = { + 1: [ + { title: '【재난】', stateCode: 4, body: '역병이 발생하여 도시가 황폐해지고 있습니다.' }, + { title: '【재난】', stateCode: 5, body: '지진으로 피해가 속출하고 있습니다.' }, + { title: '【재난】', stateCode: 3, body: '추위가 풀리지 않아 얼어죽는 백성들이 늘어나고 있습니다.' }, + { title: '【재난】', stateCode: 9, body: '황건적이 출현해 도시를 습격하고 있습니다.' }, + ], + 4: [ + { title: '【재난】', stateCode: 7, body: '홍수로 인해 피해가 급증하고 있습니다.' }, + { title: '【재난】', stateCode: 5, body: '지진으로 피해가 속출하고 있습니다.' }, + { title: '【재난】', stateCode: 6, body: '태풍으로 인해 피해가 속출하고 있습니다.' }, + ], + 7: [ + { title: '【재난】', stateCode: 8, body: '메뚜기 떼가 발생하여 도시가 황폐해지고 있습니다.' }, + { title: '【재난】', stateCode: 5, body: '지진으로 피해가 속출하고 있습니다.' }, + { title: '【재난】', stateCode: 8, body: '흉년이 들어 굶어죽는 백성들이 늘어나고 있습니다.' }, + ], + 10: [ + { title: '【재난】', stateCode: 3, body: '혹한으로 도시가 황폐해지고 있습니다.' }, + { title: '【재난】', stateCode: 5, body: '지진으로 피해가 속출하고 있습니다.' }, + { title: '【재난】', stateCode: 3, body: '눈이 많이 쌓여 도시가 황폐해지고 있습니다.' }, + { title: '【재난】', stateCode: 9, body: '황건적이 출현해 도시를 습격하고 있습니다.' }, + ], +}; + +const BOOMING_TEXT_BY_MONTH: Readonly>> = { + 4: [{ title: '【호황】', stateCode: 2, body: '호황으로 도시가 번창하고 있습니다.' }], + 7: [{ title: '【풍작】', stateCode: 1, body: '풍작으로 도시가 번창하고 있습니다.' }], +}; + +const BOOMING_RATE_BY_MONTH: Readonly> = { + 1: 0, + 4: 0.25, + 7: 0.25, + 10: 0, +}; + +const clamp = (value: number, min: number, max: number): number => Math.max(min, Math.min(max, value)); + +const resolveHiddenSeed = (world: InMemoryTurnWorld): string | number => { + const state = world.getState(); + const rawSeed = state.meta.hiddenSeed ?? state.meta.seed ?? state.id; + return typeof rawSeed === 'string' || typeof rawSeed === 'number' ? rawSeed : String(rawSeed); +}; + +const roundLegacyIntegerColumn = (value: number): number => Math.round(value); + +export const createRaiseDisasterHandler = (options: { + getWorld: () => InMemoryTurnWorld | null; + generalActionModules?: GeneralActionModule[]; +}): MonthlyEventActionHandler => { + const generalPipeline = new GeneralActionPipeline(options.generalActionModules ?? []); + + return (_args, environment) => { + const world = options.getWorld(); + if (!world) { + return; + } + + // 레거시 InnoDB의 PK scan 순서를 명시적으로 고정해 RNG 소비 순서를 + // PostgreSQL의 비결정적 findMany 반환 순서에 맡기지 않는다. + const cities = world.listCities().sort((left, right) => left.id - right.id); + // 레거시는 3년 유예 판정보다 먼저 이전 재난 표시를 초기화한다. + for (const city of cities) { + if (city.state <= 10) { + world.updateCity(city.id, { state: 0 }); + } + } + if (environment.startyear + 3 > environment.year) { + return; + } + + const boomingRate = BOOMING_RATE_BY_MONTH[environment.month]; + if (boomingRate === undefined) { + throw new Error(`Unsupported month for RaiseDisaster: ${environment.month}`); + } + const rng = new RandUtil( + new LiteHashDRBG( + simpleSerialize(resolveHiddenSeed(world), 'disater', environment.year, environment.month) + ) + ); + const isGood = rng.nextBool(boomingRate); + const targetCities = cities.filter((city) => { + if (city.securityMax <= 0) { + throw new Error(`RaiseDisaster requires positive securityMax (cityId=${city.id})`); + } + const securityRatio = city.security / city.securityMax; + const probability = isGood ? 0.02 + securityRatio * 0.05 : 0.06 - securityRatio * 0.05; + return rng.nextBool(probability); + }); + if (targetCities.length === 0) { + return; + } + + const textCandidates = isGood + ? BOOMING_TEXT_BY_MONTH[environment.month] + : DISASTER_TEXT_BY_MONTH[environment.month]; + if (!textCandidates || textCandidates.length === 0) { + throw new Error(`RaiseDisaster has no text candidates for month ${environment.month}`); + } + const picked = rng.choice(textCandidates); + const cityNames = targetCities.map((city) => city.name).join(' '); + world.pushLog({ + scope: LogScope.SYSTEM, + category: LogCategory.HISTORY, + text: `${picked.title}${cityNames}에 ${picked.body}`, + format: LogFormat.YEAR_MONTH, + }); + + const allGenerals = world.listGenerals().sort((left, right) => left.id - right.id); + for (const city of targetCities) { + const securityRatio = clamp(city.security / city.securityMax / 0.8, 0, 1); + const affectRatio = isGood ? 1.01 + securityRatio * 0.04 : 0.8 + securityRatio * 0.15; + const trust = typeof city.meta.trust === 'number' ? city.meta.trust : 0; + world.updateCity(city.id, { + state: picked.stateCode, + population: roundLegacyIntegerColumn( + isGood + ? Math.min(city.population * affectRatio, city.populationMax) + : city.population * affectRatio + ), + agriculture: roundLegacyIntegerColumn( + isGood + ? Math.min(city.agriculture * affectRatio, city.agricultureMax) + : city.agriculture * affectRatio + ), + commerce: roundLegacyIntegerColumn( + isGood ? Math.min(city.commerce * affectRatio, city.commerceMax) : city.commerce * affectRatio + ), + security: roundLegacyIntegerColumn( + isGood ? Math.min(city.security * affectRatio, city.securityMax) : city.security * affectRatio + ), + defence: roundLegacyIntegerColumn( + isGood ? Math.min(city.defence * affectRatio, city.defenceMax) : city.defence * affectRatio + ), + wall: roundLegacyIntegerColumn( + isGood ? Math.min(city.wall * affectRatio, city.wallMax) : city.wall * affectRatio + ), + meta: { + ...city.meta, + trust: isGood ? Math.min(trust * affectRatio, 100) : trust * affectRatio, + }, + }); + + if (isGood) { + continue; + } + for (const general of allGenerals.filter((candidate) => candidate.cityId === city.id)) { + const context = createGeneralTriggerContext({ + general, + nation: world.getNationById(general.nationId), + worldView: { + listGenerals: () => allGenerals, + listGeneralsByCity: (cityId) => + allGenerals.filter((candidate) => candidate.cityId === cityId), + }, + rng, + }); + const injuryProbability = generalPipeline.onCalcStat(context, 'injuryProb', 0.3); + if (!rng.nextBool(injuryProbability)) { + continue; + } + world.pushLog({ + scope: LogScope.GENERAL, + category: LogCategory.ACTION, + generalId: general.id, + text: '재난으로 인해 부상을 당했습니다.', + format: LogFormat.MONTH, + }); + world.updateGeneral(general.id, { + injury: clamp(general.injury + rng.nextRangeInt(1, 16), 0, 80), + crew: roundLegacyIntegerColumn(general.crew * 0.98), + atmos: roundLegacyIntegerColumn(general.atmos * 0.98), + train: roundLegacyIntegerColumn(general.train * 0.98), + }); + } + } + }; +}; diff --git a/app/game-engine/src/turn/turnDaemon.ts b/app/game-engine/src/turn/turnDaemon.ts index fcd2a4a6..40fd1fb7 100644 --- a/app/game-engine/src/turn/turnDaemon.ts +++ b/app/game-engine/src/turn/turnDaemon.ts @@ -1,4 +1,10 @@ -import { LogCategory, LogScope, type TurnCommandProfile, type TurnSchedule } from '@sammo-ts/logic'; +import { + LogCategory, + LogScope, + loadActionModuleBundle, + type TurnCommandProfile, + type TurnSchedule, +} from '@sammo-ts/logic'; import { buildGameEventChannel, type RealtimeEvent } from '@sammo-ts/common'; import { createGamePostgresConnector, createRedisConnector, resolveRedisConfigFromEnv } from '@sammo-ts/infra'; import { NATION_TRAIT_KEYS, NationTraitLoader, loadNationTraitModules } from '@sammo-ts/logic'; @@ -39,6 +45,7 @@ import { createRandomizeCityTradeRateHandler, type MonthlyEventActionHandler, } from './monthlyEventHandler.js'; +import { createRaiseDisasterHandler } from './monthlyDisasterAction.js'; import { DatabaseTurnDaemonLease, TurnDaemonLeaseUnavailableError } from '../lifecycle/databaseTurnDaemonLease.js'; export interface TurnDaemonRuntimeOptions { @@ -127,6 +134,7 @@ const createTurnDaemonRuntimeWithLease = async ( let redisConnector: ReturnType | null = null; const nationTraits = await loadNationTraitModules([...NATION_TRAIT_KEYS], new NationTraitLoader()); const nationTraitMap = new Map(nationTraits.map((module) => [module.key, module])); + const monthlyActionModules = await loadActionModuleBundle(snapshot.unitSet); const unification = options.calendarHandler ? null : createUnificationHandler({ @@ -152,6 +160,13 @@ const createTurnDaemonRuntimeWithLease = async ( getWorld: () => worldRef, }) ); + eventActions.set( + 'RaiseDisaster', + createRaiseDisasterHandler({ + getWorld: () => worldRef, + generalActionModules: monthlyActionModules.general, + }) + ); eventActions.set('ProcessIncome', (_args, environment) => { void incomeHandler.onMonthChanged?.({ previousYear: environment.month === 1 ? environment.year - 1 : environment.year, diff --git a/app/game-engine/test/monthlyDisasterPersistence.integration.test.ts b/app/game-engine/test/monthlyDisasterPersistence.integration.test.ts new file mode 100644 index 00000000..0c145090 --- /dev/null +++ b/app/game-engine/test/monthlyDisasterPersistence.integration.test.ts @@ -0,0 +1,167 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import type { City, MapDefinition } 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 { createRaiseDisasterHandler } from '../src/turn/monthlyDisasterAction.js'; +import type { TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js'; + +const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL; +const integration = describe.skipIf(!databaseUrl); +const cityId = 990_042; + +const map: MapDefinition = { + id: 'monthly-disaster-persistence', + name: 'monthly-disaster-persistence', + cities: [], + defaults: { trust: 50, trade: 100, supplyState: 1, frontState: 0 }, +}; + +const city: City = { + id: cityId, + name: '재난저장검증도시', + nationId: 0, + level: 1, + state: 0, + population: 1_000, + populationMax: 2_000, + agriculture: 500, + agricultureMax: 1_000, + commerce: 500, + commerceMax: 1_000, + security: 0, + securityMax: 100, + supplyState: 1, + frontState: 0, + defence: 100, + defenceMax: 1_000, + wall: 100, + wallMax: 1_000, + conflict: {}, + meta: { trust: 99.5, trade: 100, region: 1 }, +}; + +integration('monthly disaster 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.city.deleteMany({ where: { id: cityId } }); + }); + + afterAll(async () => { + await db.city.deleteMany({ where: { id: cityId } }); + await closeDb?.(); + }); + + it('preserves fractional trust while writing integer damage and the disaster state', async () => { + await db.city.create({ + data: { + 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: 99.5, + trade: 100, + defence: city.defence, + defenceMax: city.defenceMax, + wall: city.wall, + wallMax: city.wallMax, + region: 1, + conflict: {}, + meta: {}, + }, + }); + const row = await db.worldState.create({ + data: { + scenarioCode: 'monthly-disaster-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: { hiddenSeed: 'disaster-test-6' }, + }; + 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: [city], + nations: [], + troops: [], + diplomacy: [], + events: [], + initialEvents: [], + }; + const world = new InMemoryTurnWorld(state, snapshot, { + schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] }, + }); + const dbHooks = await createDatabaseTurnHooks(databaseUrl!, world); + const checkpoint = { + lastTurnTime: state.lastTurnTime.toISOString(), + processedGenerals: 0, + processedTurns: 1, + durationMs: 0, + partial: false, + }; + + try { + const raiseDisaster = createRaiseDisasterHandler({ getWorld: () => world }); + raiseDisaster( + [], + { + year: 193, + month: 1, + startyear: 190, + currentEventID: 1, + turnTime: state.lastTurnTime, + }, + { id: 1, targetCode: 'month', priority: 0, condition: true, action: [], meta: {} } + ); + await dbHooks.hooks.flushChanges?.(checkpoint); + + const persisted = await db.city.findUniqueOrThrow({ where: { id: cityId } }); + expect(persisted).toMatchObject({ + population: 800, + agriculture: 400, + commerce: 400, + defence: 80, + wall: 80, + meta: { state: 3 }, + }); + expect(persisted.trust).toBeCloseTo(79.6); + } finally { + await dbHooks.close(); + await db.worldState.delete({ where: { id: row.id } }); + } + }); +}); diff --git a/app/game-engine/test/monthlyEventHandler.test.ts b/app/game-engine/test/monthlyEventHandler.test.ts index 67a1cbf7..4d112b9e 100644 --- a/app/game-engine/test/monthlyEventHandler.test.ts +++ b/app/game-engine/test/monthlyEventHandler.test.ts @@ -1,13 +1,14 @@ import { describe, expect, it } from 'vitest'; -import type { City, MapDefinition } from '@sammo-ts/logic'; +import { loadActionModuleBundle, LogCategory, LogFormat, LogScope, type City, type MapDefinition } from '@sammo-ts/logic'; import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js'; +import { createRaiseDisasterHandler } from '../src/turn/monthlyDisasterAction.js'; import { createMonthlyEventHandler, createRandomizeCityTradeRateHandler, type MonthlyEventActionHandler, } from '../src/turn/monthlyEventHandler.js'; -import type { TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js'; +import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js'; const map: MapDefinition = { id: 'event-test', @@ -19,15 +20,25 @@ const map: MapDefinition = { const buildWorld = ( events: TurnWorldSnapshot['events'], actions: Map, - cities: City[] = [] + cities: City[] = [], + options: { + generals?: TurnGeneral[]; + hiddenSeed?: string; + startYear?: number; + currentYear?: number; + currentMonth?: number; + } = {} ): InMemoryTurnWorld => { + const currentYear = options.currentYear ?? 189; + const currentMonth = options.currentMonth ?? 12; + const startYear = options.startYear ?? 189; const state: TurnWorldState = { id: 1, - currentYear: 189, - currentMonth: 12, + currentYear, + currentMonth, tickSeconds: 600, lastTurnTime: new Date('0189-12-01T00:00:00.000Z'), - meta: { hiddenSeed: 'monthly-event-test-seed' }, + meta: { hiddenSeed: options.hiddenSeed ?? 'monthly-event-test-seed' }, }; const snapshot: TurnWorldSnapshot = { scenarioConfig: { @@ -39,7 +50,7 @@ const buildWorld = ( }, scenarioMeta: { title: 'event test', - startYear: 189, + startYear, life: null, fiction: null, history: [], @@ -49,7 +60,7 @@ const buildWorld = ( diplomacy: [], events, initialEvents: [], - generals: [], + generals: options.generals ?? [], cities, nations: [], troops: [], @@ -57,7 +68,7 @@ const buildWorld = ( let world: InMemoryTurnWorld | null = null; const handler = createMonthlyEventHandler({ getWorld: () => world, - startYear: 189, + startYear, actions, }); world = new InMemoryTurnWorld(state, snapshot, { @@ -91,6 +102,37 @@ const buildCity = (id: number, level: number): City => ({ meta: { trust: 50, trade: 100, marker: id }, }); +const buildGeneral = (id: number, patch: Partial = {}): TurnGeneral => ({ + id, + name: `장수${id}`, + nationId: 0, + cityId: 1, + troopId: 0, + stats: { leadership: 50, strength: 50, intelligence: 50 }, + experience: 0, + dedication: 0, + officerLevel: 0, + 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: 99, + crewTypeId: 1100, + train: 51, + atmos: 50, + age: 30, + npcState: 0, + triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} }, + meta: { killturn: 24 }, + turnTime: new Date('0193-01-01T00:00:00.000Z'), + ...patch, +}); + describe('monthly event pipeline', () => { it('runs PRE_MONTH before the date change and MONTH after it in priority/id order', async () => { const trace: string[] = []; @@ -244,4 +286,194 @@ describe('monthly event pipeline', () => { 'Unsupported city level for RandomizeCityTradeRate: 9 (cityId=99)' ); }); + + it('resets an old disaster marker before the opening three-year skip', async () => { + const actions = new Map(); + const world = buildWorld( + [ + { + id: 12, + targetCode: 'month', + priority: 0, + condition: true, + action: [['RaiseDisaster']], + meta: {}, + }, + ], + actions, + [{ ...buildCity(1, 1), state: 5 }, { ...buildCity(2, 1), state: 31 }], + { startYear: 190, currentYear: 190, currentMonth: 12 } + ); + actions.set('RaiseDisaster', createRaiseDisasterHandler({ getWorld: () => world })); + + await world.advanceMonth(new Date('0191-01-01T00:00:00.000Z')); + + expect(world.getCityById(1)?.state).toBe(0); + expect(world.getCityById(2)?.state).toBe(31); + expect(world.peekDirtyState().logs).toEqual([]); + }); + + it('matches the legacy January disaster RNG, city damage, injury order, and talisman protection', async () => { + const actions = new Map(); + const moduleBundle = await loadActionModuleBundle(); + const protectedGeneral = buildGeneral(1, { + role: { + personality: null, + specialDomestic: null, + specialWar: null, + items: { horse: null, weapon: null, book: null, item: 'che_부적_태현청생부' }, + }, + }); + const injuredGeneral = buildGeneral(2); + const cappedGeneral = buildGeneral(3, { injury: 70 }); + const disasterCity: City = { + ...buildCity(1, 1), + name: '재난도시', + state: 5, + population: 1_001, + populationMax: 2_000, + agriculture: 501, + agricultureMax: 1_000, + commerce: 499, + commerceMax: 1_000, + security: 0, + securityMax: 100, + defence: 99, + defenceMax: 1_000, + wall: 101, + wallMax: 1_000, + meta: { trust: 99.5, trade: 100, marker: 1 }, + }; + const world = buildWorld( + [ + { + id: 13, + targetCode: 'month', + priority: 0, + condition: true, + action: [['RaiseDisaster']], + meta: {}, + }, + ], + actions, + [disasterCity], + { + generals: [cappedGeneral, protectedGeneral, injuredGeneral], + hiddenSeed: 'disaster-test-6', + startYear: 190, + currentYear: 192, + currentMonth: 12, + } + ); + actions.set( + 'RaiseDisaster', + createRaiseDisasterHandler({ + getWorld: () => world, + generalActionModules: moduleBundle.general, + }) + ); + + await world.advanceMonth(new Date('0193-01-01T00:00:00.000Z')); + + const damagedCity = world.getCityById(1); + expect(damagedCity).toMatchObject({ + state: 3, + population: 801, + agriculture: 401, + commerce: 399, + security: 0, + defence: 79, + wall: 81, + meta: { trade: 100, marker: 1 }, + }); + expect(damagedCity?.meta.trust).toBeCloseTo(79.6); + expect(world.getGeneralById(1)).toMatchObject({ injury: 0, crew: 99, atmos: 50, train: 51 }); + expect(world.getGeneralById(2)).toMatchObject({ injury: 7, crew: 97, atmos: 49, train: 50 }); + expect(world.getGeneralById(3)).toMatchObject({ injury: 80, crew: 97, atmos: 49, train: 50 }); + expect(world.peekDirtyState().logs).toEqual([ + { + scope: LogScope.SYSTEM, + category: LogCategory.HISTORY, + text: '【재난】재난도시에 추위가 풀리지 않아 얼어죽는 백성들이 늘어나고 있습니다.', + format: LogFormat.YEAR_MONTH, + }, + { + scope: LogScope.GENERAL, + category: LogCategory.ACTION, + generalId: 2, + text: '재난으로 인해 부상을 당했습니다.', + format: LogFormat.MONTH, + }, + { + scope: LogScope.GENERAL, + category: LogCategory.ACTION, + generalId: 3, + text: '재난으로 인해 부상을 당했습니다.', + format: LogFormat.MONTH, + }, + ]); + }); + + it('matches the legacy April booming RNG and maximum clamps', async () => { + const actions = new Map(); + const boomingCity: City = { + ...buildCity(1, 8), + name: '호황도시', + population: 1_001, + populationMax: 1_100, + agriculture: 999, + agricultureMax: 1_000, + commerce: 500, + commerceMax: 1_000, + security: 1_000, + securityMax: 1_000, + defence: 101, + defenceMax: 1_000, + wall: 99, + wallMax: 1_000, + meta: { trust: 99.5, trade: 100 }, + }; + const world = buildWorld( + [ + { + id: 14, + targetCode: 'month', + priority: 0, + condition: true, + action: [['RaiseDisaster']], + meta: {}, + }, + ], + actions, + [boomingCity], + { + hiddenSeed: 'booming-test-59', + startYear: 190, + currentYear: 193, + currentMonth: 3, + } + ); + actions.set('RaiseDisaster', createRaiseDisasterHandler({ getWorld: () => world })); + + await world.advanceMonth(new Date('0193-04-01T00:00:00.000Z')); + + expect(world.getCityById(1)).toMatchObject({ + state: 2, + population: 1_051, + agriculture: 1_000, + commerce: 525, + security: 1_000, + defence: 106, + wall: 104, + meta: { trust: 100, trade: 100 }, + }); + expect(world.peekDirtyState().logs).toEqual([ + { + scope: LogScope.SYSTEM, + category: LogCategory.HISTORY, + text: '【호황】호황도시에 호황으로 도시가 번창하고 있습니다.', + format: LogFormat.YEAR_MONTH, + }, + ]); + }); }); diff --git a/packages/infra/prisma/game.prisma b/packages/infra/prisma/game.prisma index fa72319f..3493f5a1 100644 --- a/packages/infra/prisma/game.prisma +++ b/packages/infra/prisma/game.prisma @@ -131,7 +131,7 @@ model City { commerceMax Int @map("comm_max") security Int @map("secu") securityMax Int @map("secu_max") - trust Int @default(0) + trust Float @default(0) @db.Real trade Int? @default(100) defence Int @map("def") defenceMax Int @map("def_max") diff --git a/packages/infra/prisma/migrations/20260725005000_make_city_trust_real/migration.sql b/packages/infra/prisma/migrations/20260725005000_make_city_trust_real/migration.sql new file mode 100644 index 00000000..e3770e1e --- /dev/null +++ b/packages/infra/prisma/migrations/20260725005000_make_city_trust_real/migration.sql @@ -0,0 +1,3 @@ +ALTER TABLE "city" + ALTER COLUMN "trust" TYPE REAL + USING "trust"::REAL; diff --git a/packages/logic/src/triggers/types.ts b/packages/logic/src/triggers/types.ts index 2ba5f107..16c98e2a 100644 --- a/packages/logic/src/triggers/types.ts +++ b/packages/logic/src/triggers/types.ts @@ -36,6 +36,7 @@ export type GeneralStatName = | 'dedication' | 'sabotageDefence' | 'sabotageAttack' + | 'injuryProb' | 'addDex'; export type WarStatName =