diff --git a/app/game-api/src/router/nation/shared.ts b/app/game-api/src/router/nation/shared.ts index 87c9517..6315b68 100644 --- a/app/game-api/src/router/nation/shared.ts +++ b/app/game-api/src/router/nation/shared.ts @@ -66,7 +66,7 @@ export type CityIncomeRow = { security: number; securityMax: number; trust: number; - trade: number; + trade: number | null; defence: number; defenceMax: number; wall: number; diff --git a/app/game-api/src/turns/commandTable.ts b/app/game-api/src/turns/commandTable.ts index fc639ae..b4172b0 100644 --- a/app/game-api/src/turns/commandTable.ts +++ b/app/game-api/src/turns/commandTable.ts @@ -301,7 +301,7 @@ const mapCityRow = (row: CityRow): City => { meta: { ...meta, trust: row.trust, - trade: row.trade, + ...(row.trade === null ? {} : { trade: row.trade }), region: row.region, }, }; diff --git a/app/game-engine/src/turn/databaseHooks.ts b/app/game-engine/src/turn/databaseHooks.ts index fa57c04..287db2b 100644 --- a/app/game-engine/src/turn/databaseHooks.ts +++ b/app/game-engine/src/turn/databaseHooks.ts @@ -261,9 +261,9 @@ const buildCityUpdate = ( if (trust !== null) { data.trust = trust; } - if (trade !== null) { - data.trade = trade; - } + // trade가 없는 도시는 레거시 DB에서 NULL이다. City meta에 trade가 + // 없다는 사실도 dirty city의 전체 snapshot 일부로 영속화한다. + data.trade = trade; if (region !== null) { data.region = region; } diff --git a/app/game-engine/src/turn/monthlyEventHandler.ts b/app/game-engine/src/turn/monthlyEventHandler.ts index 076550f..2dea6a1 100644 --- a/app/game-engine/src/turn/monthlyEventHandler.ts +++ b/app/game-engine/src/turn/monthlyEventHandler.ts @@ -1,3 +1,6 @@ +import { LiteHashDRBG, RandUtil } from '@sammo-ts/common'; +import { simpleSerialize } from '@sammo-ts/logic/war/utils.js'; + import type { InMemoryTurnWorld, TurnCalendarContext, TurnCalendarHandler } from './inMemoryWorld.js'; import type { TurnEvent } from './types.js'; @@ -18,6 +21,50 @@ export type MonthlyEventActionHandler = ( export type MonthlyEventActionRegistry = ReadonlyMap; const COMPARATORS = new Set(['==', '!=', '<', '>', '<=', '>=']); +const CITY_TRADE_PROBABILITY_BY_LEVEL: Readonly> = { + 1: 0, + 2: 0, + 3: 0, + 4: 0.2, + 5: 0.4, + 6: 0.6, + 7: 0.8, + 8: 1, +}; + +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); +}; + +export const createRandomizeCityTradeRateHandler = (options: { + getWorld: () => InMemoryTurnWorld | null; +}): MonthlyEventActionHandler => { + return (_args, environment) => { + const world = options.getWorld(); + if (!world) { + return; + } + const rng = new RandUtil( + new LiteHashDRBG( + simpleSerialize(resolveHiddenSeed(world), 'randomizeCityTradeRate', environment.year, environment.month) + ) + ); + + for (const city of world.listCities()) { + const probability = CITY_TRADE_PROBABILITY_BY_LEVEL[city.level]; + if (probability === undefined) { + throw new Error(`Unsupported city level for RandomizeCityTradeRate: ${city.level} (cityId=${city.id})`); + } + const trade = probability > 0 && rng.nextBool(probability) ? rng.nextRangeInt(95, 105) : null; + const { trade: _previousTrade, ...metaWithoutTrade } = city.meta; + world.updateCity(city.id, { + meta: trade === null ? metaWithoutTrade : { ...metaWithoutTrade, trade }, + }); + } + }; +}; const compare = (left: readonly (number | null)[], operator: string, right: readonly (number | null)[]): boolean => { const normalize = (values: readonly (number | null)[]): string => diff --git a/app/game-engine/src/turn/turnDaemon.ts b/app/game-engine/src/turn/turnDaemon.ts index c6021e9..fcd2a4a 100644 --- a/app/game-engine/src/turn/turnDaemon.ts +++ b/app/game-engine/src/turn/turnDaemon.ts @@ -34,7 +34,11 @@ import { createNeutralAuctionRegistrar } from '../auction/neutralRegistrar.js'; import { createTournamentRewardFinalizer } from '../tournament/finalizer.js'; import { createTournamentAutoStartHandler } from './tournamentAutoStart.js'; import { createYearbookHandler } from './yearbookHandler.js'; -import { createMonthlyEventHandler, type MonthlyEventActionHandler } from './monthlyEventHandler.js'; +import { + createMonthlyEventHandler, + createRandomizeCityTradeRateHandler, + type MonthlyEventActionHandler, +} from './monthlyEventHandler.js'; import { DatabaseTurnDaemonLease, TurnDaemonLeaseUnavailableError } from '../lifecycle/databaseTurnDaemonLease.js'; export interface TurnDaemonRuntimeOptions { @@ -142,6 +146,12 @@ const createTurnDaemonRuntimeWithLease = async ( event.action.some((action) => Array.isArray(action) && action[0] === name) ); const eventActions = new Map(); + eventActions.set( + 'RandomizeCityTradeRate', + createRandomizeCityTradeRateHandler({ + getWorld: () => worldRef, + }) + ); eventActions.set('ProcessIncome', (_args, environment) => { void incomeHandler.onMonthChanged?.({ previousYear: environment.month === 1 ? environment.year - 1 : environment.year, diff --git a/app/game-engine/src/turn/worldLoader.ts b/app/game-engine/src/turn/worldLoader.ts index c3c554a..1cee20f 100644 --- a/app/game-engine/src/turn/worldLoader.ts +++ b/app/game-engine/src/turn/worldLoader.ts @@ -246,7 +246,7 @@ const mapCityRow = (row: TurnEngineCityRow): City => { meta: { ...meta, trust: row.trust, - trade: row.trade, + ...(row.trade === null ? {} : { trade: row.trade }), region: row.region, }, }; diff --git a/app/game-engine/test/monthlyEventHandler.test.ts b/app/game-engine/test/monthlyEventHandler.test.ts index f479853..67a1cbf 100644 --- a/app/game-engine/test/monthlyEventHandler.test.ts +++ b/app/game-engine/test/monthlyEventHandler.test.ts @@ -1,8 +1,12 @@ import { describe, expect, it } from 'vitest'; -import type { MapDefinition } from '@sammo-ts/logic'; +import type { City, MapDefinition } from '@sammo-ts/logic'; import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js'; -import { createMonthlyEventHandler, type MonthlyEventActionHandler } from '../src/turn/monthlyEventHandler.js'; +import { + createMonthlyEventHandler, + createRandomizeCityTradeRateHandler, + type MonthlyEventActionHandler, +} from '../src/turn/monthlyEventHandler.js'; import type { TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js'; const map: MapDefinition = { @@ -14,7 +18,8 @@ const map: MapDefinition = { const buildWorld = ( events: TurnWorldSnapshot['events'], - actions: Map + actions: Map, + cities: City[] = [] ): InMemoryTurnWorld => { const state: TurnWorldState = { id: 1, @@ -22,7 +27,7 @@ const buildWorld = ( currentMonth: 12, tickSeconds: 600, lastTurnTime: new Date('0189-12-01T00:00:00.000Z'), - meta: {}, + meta: { hiddenSeed: 'monthly-event-test-seed' }, }; const snapshot: TurnWorldSnapshot = { scenarioConfig: { @@ -45,7 +50,7 @@ const buildWorld = ( events, initialEvents: [], generals: [], - cities: [], + cities, nations: [], troops: [], }; @@ -62,6 +67,30 @@ const buildWorld = ( return world; }; +const buildCity = (id: number, level: number): City => ({ + id, + name: `도시${id}`, + nationId: 0, + level, + state: 0, + population: 1_000, + populationMax: 2_000, + agriculture: 100, + agricultureMax: 200, + commerce: 100, + commerceMax: 200, + security: 100, + securityMax: 200, + supplyState: 1, + frontState: 0, + defence: 100, + defenceMax: 200, + wall: 100, + wallMax: 200, + conflict: {}, + meta: { trust: 50, trade: 100, marker: id }, +}); + describe('monthly event pipeline', () => { it('runs PRE_MONTH before the date change and MONTH after it in priority/id order', async () => { const trace: string[] = []; @@ -150,4 +179,69 @@ describe('monthly event pipeline', () => { 'Unsupported monthly event action: RaiseInvader (eventId=9)' ); }); + + it('randomizes city trade rates with the legacy seed domain and nullable no-trader state', async () => { + const actions = new Map(); + const world = buildWorld( + [ + { + id: 10, + targetCode: 'month', + priority: 0, + condition: true, + action: [['RandomizeCityTradeRate']], + meta: {}, + }, + ], + actions, + [buildCity(1, 1), buildCity(2, 4), buildCity(3, 5), buildCity(4, 6), buildCity(5, 7), buildCity(6, 8)] + ); + actions.set( + 'RandomizeCityTradeRate', + createRandomizeCityTradeRateHandler({ + getWorld: () => world, + }) + ); + + await world.advanceMonth(new Date('0190-01-01T00:00:00.000Z')); + + expect( + world.listCities().map((city) => ({ + id: city.id, + trade: city.meta.trade ?? null, + marker: city.meta.marker, + })) + ).toEqual([ + { id: 1, trade: null, marker: 1 }, + { id: 2, trade: null, marker: 2 }, + { id: 3, trade: 101, marker: 3 }, + { id: 4, trade: 100, marker: 4 }, + { id: 5, trade: 105, marker: 5 }, + { id: 6, trade: 102, marker: 6 }, + ]); + expect(world.peekDirtyState().cities.map((city) => city.id)).toEqual([1, 2, 3, 4, 5, 6]); + }); + + it('rejects an unsupported city level instead of changing RNG consumption silently', async () => { + const actions = new Map(); + const world = buildWorld( + [ + { + id: 11, + targetCode: 'month', + priority: 0, + condition: true, + action: [['RandomizeCityTradeRate']], + meta: {}, + }, + ], + actions, + [buildCity(99, 9)] + ); + actions.set('RandomizeCityTradeRate', createRandomizeCityTradeRateHandler({ getWorld: () => world })); + + await expect(world.advanceMonth(new Date('0190-01-01T00:00:00.000Z'))).rejects.toThrow( + 'Unsupported city level for RandomizeCityTradeRate: 9 (cityId=99)' + ); + }); }); diff --git a/app/game-engine/test/monthlyEventTradeRatePersistence.integration.test.ts b/app/game-engine/test/monthlyEventTradeRatePersistence.integration.test.ts new file mode 100644 index 0000000..01c7ca0 --- /dev/null +++ b/app/game-engine/test/monthlyEventTradeRatePersistence.integration.test.ts @@ -0,0 +1,150 @@ +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 type { TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js'; + +const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL; +const integration = describe.skipIf(!databaseUrl); +const cityId = 990_041; + +const map: MapDefinition = { + id: 'monthly-trade-persistence', + name: 'monthly-trade-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: 100, + agricultureMax: 200, + commerce: 100, + commerceMax: 200, + security: 100, + securityMax: 200, + supplyState: 1, + frontState: 0, + defence: 100, + defenceMax: 200, + wall: 100, + wallMax: 200, + conflict: {}, + meta: { trust: 50, trade: 100, region: 1 }, +}; + +integration('monthly city trade 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('writes both the legacy null state and a randomized numeric rate', 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: 50, + 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-trade-persistence', + currentYear: 190, + currentMonth: 1, + tickSeconds: 600, + config: {}, + meta: {}, + }, + }); + const state: TurnWorldState = { + id: row.id, + currentYear: 190, + currentMonth: 1, + tickSeconds: 600, + lastTurnTime: new Date('2026-07-25T00:10:00.000Z'), + meta: {}, + }; + 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 { trade: _trade, ...metaWithoutTrade } = city.meta; + world.updateCity(city.id, { meta: metaWithoutTrade }); + await dbHooks.hooks.flushChanges?.(checkpoint); + expect((await db.city.findUniqueOrThrow({ where: { id: cityId } })).trade).toBeNull(); + + world.updateCity(city.id, { meta: { ...metaWithoutTrade, trade: 103 } }); + await dbHooks.hooks.flushChanges?.(checkpoint); + expect((await db.city.findUniqueOrThrow({ where: { id: cityId } })).trade).toBe(103); + } finally { + await dbHooks.close(); + await db.worldState.delete({ where: { id: row.id } }); + } + }); +}); diff --git a/app/game-frontend/src/views/NationCitiesView.vue b/app/game-frontend/src/views/NationCitiesView.vue index 1cd4871..bf0d509 100644 --- a/app/game-frontend/src/views/NationCitiesView.vue +++ b/app/game-frontend/src/views/NationCitiesView.vue @@ -175,7 +175,7 @@ const sortedCities = computed(() => { case 9: return rhs.wall - lhs.wall; case 10: - return rhs.trade - lhs.trade; + return (rhs.trade ?? -1) - (lhs.trade ?? -1); case 11: { const regionCmp = lhs.region - rhs.region; if (regionCmp !== 0) { @@ -307,7 +307,7 @@ onMounted(() => { {{ regionMap[city.region] ?? '미지' }} · {{ cityLevelMap[city.level] ?? '-' }} 규모 -
시세 {{ city.trade }}%
+
시세 {{ city.trade === null ? '- ' : city.trade }}%
diff --git a/packages/infra/prisma/game.prisma b/packages/infra/prisma/game.prisma index da1910e..fa72319 100644 --- a/packages/infra/prisma/game.prisma +++ b/packages/infra/prisma/game.prisma @@ -132,7 +132,7 @@ model City { security Int @map("secu") securityMax Int @map("secu_max") trust Int @default(0) - trade Int @default(100) + trade Int? @default(100) defence Int @map("def") defenceMax Int @map("def_max") wall Int @map("wall") diff --git a/packages/infra/prisma/migrations/20260725004000_make_city_trade_nullable/migration.sql b/packages/infra/prisma/migrations/20260725004000_make_city_trade_nullable/migration.sql new file mode 100644 index 0000000..ce9944c --- /dev/null +++ b/packages/infra/prisma/migrations/20260725004000_make_city_trade_nullable/migration.sql @@ -0,0 +1,2 @@ +ALTER TABLE "city" + ALTER COLUMN "trade" DROP NOT NULL; diff --git a/packages/infra/src/turnEngineDb.ts b/packages/infra/src/turnEngineDb.ts index 4faf3e3..37f5bc3 100644 --- a/packages/infra/src/turnEngineDb.ts +++ b/packages/infra/src/turnEngineDb.ts @@ -75,7 +75,7 @@ export interface TurnEngineCityRow { conflict: JsonValue; meta: JsonValue; trust: number; - trade: number; + trade: number | null; region: number; } @@ -241,7 +241,7 @@ export interface TurnEngineCityUpdateInput { conflict?: InputJsonValue; meta: InputJsonValue; trust?: number; - trade?: number; + trade?: number | null; region?: number; }