diff --git a/app/game-api/src/turns/commandTable.ts b/app/game-api/src/turns/commandTable.ts index 16324c85..d5217ee0 100644 --- a/app/game-api/src/turns/commandTable.ts +++ b/app/game-api/src/turns/commandTable.ts @@ -18,7 +18,13 @@ import type { TriggerValue, UnitSetDefinition, } from '@sammo-ts/logic'; -import { evaluateConstraints, LEGACY_DEFAULT_MAX_LEVEL, resolveNonAggressionMaxEndYear } from '@sammo-ts/logic'; +import { + resolveScenarioStartYear, + LEGACY_DEFAULT_OPENING_PART_YEAR, + evaluateConstraints, + LEGACY_DEFAULT_MAX_LEVEL, + resolveNonAggressionMaxEndYear, +} from '@sammo-ts/logic'; import type { GeneralActionModule } from '@sammo-ts/logic/actionModules/general.js'; import { CommandResolver as RecruitmentCommandResolver } from '@sammo-ts/logic/actions/turn/general/che_징병.js'; import { projectItemSlots, readItemInventoryFromMeta } from '@sammo-ts/logic/items/index.js'; @@ -344,7 +350,7 @@ const buildCommandEnv = (worldState: WorldStateRow): CommandEnv => { sabotageDefenceCoefByGeneralCount: resolveNumber(constValues, ['sabotageDefenceCoefByGeneralCount'], 0), sabotageDamageMin: resolveNumber(constValues, ['sabotageDamageMin'], 0), sabotageDamageMax: resolveNumber(constValues, ['sabotageDamageMax'], 0), - openingPartYear: resolveNumber(constValues, ['openingPartYear'], 0), + openingPartYear: resolveNumber(constValues, ['openingPartYear'], LEGACY_DEFAULT_OPENING_PART_YEAR), maxGeneral: resolveNumber(constValues, ['defaultMaxGeneral', 'maxGeneral'], 0), defaultNpcGold: resolveNumber(constValues, ['defaultNpcGold', 'defaultGold'], DEFAULT_GENERAL_GOLD), defaultNpcRice: resolveNumber(constValues, ['defaultNpcRice', 'defaultRice'], DEFAULT_GENERAL_RICE), @@ -370,8 +376,8 @@ const buildConstraintEnv = (worldState: WorldStateRow): Record const constValues = asRecord(config.const); const meta = asRecord(worldState.meta); const scenarioMeta = asRecord(meta.scenarioMeta); - const startYear = typeof scenarioMeta.startYear === 'number' ? scenarioMeta.startYear : undefined; - const relYear = typeof startYear === 'number' ? worldState.currentYear - startYear : undefined; + const startYear = resolveScenarioStartYear(scenarioMeta.startYear); + const relYear = worldState.currentYear - startYear; const joinModeRaw = config.join_mode ?? config.joinMode; const joinMode = typeof joinModeRaw === 'string' ? joinModeRaw : 'full'; @@ -383,7 +389,7 @@ const buildConstraintEnv = (worldState: WorldStateRow): Record startYear, relYear, join_mode: joinMode, - openingPartYear: resolveNumber(constValues, ['openingPartYear'], 0), + openingPartYear: resolveNumber(constValues, ['openingPartYear'], LEGACY_DEFAULT_OPENING_PART_YEAR), maxTechLevel: resolveNumber(constValues, ['maxTechLevel'], 12), }; }; @@ -559,10 +565,7 @@ export const buildRecruitmentCommandInfo = (options: { avoid: crewType.avoid, baseCost: displayCost.gold, baseRice: displayCost.rice, - info: [ - ...crewType.info, - ...crewType.requirements.map(formatCrewTypeRequirement).filter(Boolean), - ], + info: [...crewType.info, ...crewType.requirements.map(formatCrewTypeRequirement).filter(Boolean)], }; }), })); diff --git a/app/game-api/test/commandTable.test.ts b/app/game-api/test/commandTable.test.ts index abf0886f..bc8a21eb 100644 --- a/app/game-api/test/commandTable.test.ts +++ b/app/game-api/test/commandTable.test.ts @@ -102,6 +102,30 @@ const buildNation = (): NationRow => }) as unknown as NationRow; describe('buildTurnCommandTable', () => { + it.each([null, 180, 189, 2025, 0])( + 'uses the resolved calendar for sortie reservation when startYear=%s', + async (startYear) => { + const base = startYear ?? 180; + for (const offset of [0, 1, 2, 3]) { + const worldState = buildWorldState('full'); + worldState.currentYear = base + offset; + worldState.meta = { scenarioMeta: { startYear } }; + const general = buildGeneral(); + general.rice = 10000; + const table = await buildTurnCommandTable({ + worldState, + general, + city: buildCity(), + nation: buildNation(), + nationGenerals: [], + }); + const sortie = table.general.flatMap(({ values }) => values).find(({ key }) => key === 'che_출병'); + expect(sortie).toBeDefined(); + expect(sortie?.status === 'blocked').toBe(offset === 0); + } + } + ); + const findCommand = (table: Awaited>, key: string) => [...table.general, ...table.nation].flatMap(({ values }) => values).find((command) => command.key === key); diff --git a/app/game-engine/src/scenario/scenarioSeeder.ts b/app/game-engine/src/scenario/scenarioSeeder.ts index eb8d659a..8601a976 100644 --- a/app/game-engine/src/scenario/scenarioSeeder.ts +++ b/app/game-engine/src/scenario/scenarioSeeder.ts @@ -10,6 +10,8 @@ import { import { GameClock, asNumber, asRecord, type GameClockMode, type GameClockPhase } from '@sammo-ts/common'; import { buildScenarioBootstrap, + resolveScenarioStartYear, + LEGACY_DEFAULT_OPENING_PART_YEAR, resolveScenarioGeneralDeathMonth, type GeneralMeta, type ScenarioBootstrapWarning, @@ -29,7 +31,6 @@ import { applyInitialChangeCityEvents } from '../turn/monthlyChangeCityAction.js const DEFAULT_TICK_SECONDS = 120 * 60; const DEFAULT_GENERAL_GOLD = 1000; const DEFAULT_GENERAL_RICE = 1000; -const DEFAULT_OPENING_PART_YEAR = 3; const INTEGRATION_WORLD_SEED_ENV = 'INTEGRATION_WORLD_SEED'; const MINUTES_TO_MS = 60_000; @@ -148,7 +149,7 @@ const resolveStartState = ( turnTermMinutes: number, sync: boolean ): { startTime: Date; currentYear: number; currentMonth: number } => { - const startYear = scenarioStartYear ?? 0; + const startYear = resolveScenarioStartYear(scenarioStartYear); if (!sync) { return { startTime: cutTurn(now, turnTermMinutes), @@ -294,7 +295,7 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom const scenarioConst = asRecord(seed.scenarioConfig.const); if (typeof scenarioConst.openingPartYear !== 'number' || Number.isNaN(scenarioConst.openingPartYear)) { - scenarioConst.openingPartYear = DEFAULT_OPENING_PART_YEAR; + scenarioConst.openingPartYear = LEGACY_DEFAULT_OPENING_PART_YEAR; } const scenarioConfig = { ...seed.scenarioConfig, @@ -328,7 +329,7 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom killturn: install?.npcMode === 1 ? Math.trunc(4800 / turnTermMinutes / 3) : 4800 / turnTermMinutes, // Ref seeds game_env.develcost before the first general turn. The // monthly pre-handler recalculates the same value at each boundary. - develcost: (startState.currentYear - (scenario.startYear ?? startState.currentYear) + 10) * 2, + develcost: (startState.currentYear - resolveScenarioStartYear(scenario.startYear) + 10) * 2, starttime: formatDateTime(startState.startTime), turntime: formatDateTime(gameClockMode === 'manual' ? now : initialClock.baseTime), opentime: formatDateTime(initialClockWallAnchor), diff --git a/app/game-engine/src/turn/ai/generalAi/constraint.ts b/app/game-engine/src/turn/ai/generalAi/constraint.ts index 78f8ae6c..5ff68e22 100644 --- a/app/game-engine/src/turn/ai/generalAi/constraint.ts +++ b/app/game-engine/src/turn/ai/generalAi/constraint.ts @@ -1,3 +1,4 @@ +import { resolveScenarioStartYear } from '@sammo-ts/logic'; import type { ScenarioMeta, TurnCommandEnv } from '@sammo-ts/logic'; import type { TurnWorldState } from '../../types.js'; @@ -9,8 +10,8 @@ export const resolveConstraintEnv = ( scenarioMeta: ScenarioMeta | undefined, env: TurnCommandEnv ): ConstraintEnv => { - const startYear = typeof scenarioMeta?.startYear === 'number' ? scenarioMeta.startYear : undefined; - const relYear = typeof startYear === 'number' ? world.currentYear - startYear : undefined; + const startYear = resolveScenarioStartYear(scenarioMeta?.startYear); + const relYear = world.currentYear - startYear; const worldMeta = world.meta as Record; const rawKillturn = worldMeta.killturn; const killturn = diff --git a/app/game-engine/src/turn/reservedTurnCommands.ts b/app/game-engine/src/turn/reservedTurnCommands.ts index 4639bf8d..7f8385f1 100644 --- a/app/game-engine/src/turn/reservedTurnCommands.ts +++ b/app/game-engine/src/turn/reservedTurnCommands.ts @@ -12,6 +12,7 @@ import { LEGACY_RANDOM_GENERAL_FIRST_NAMES, LEGACY_RANDOM_GENERAL_LAST_NAMES, LEGACY_DEFAULT_MAX_LEVEL, + LEGACY_DEFAULT_OPENING_PART_YEAR, loadGeneralTurnCommandSpecs, loadNationTurnCommandSpecs, loadActionModuleBundle, @@ -33,7 +34,6 @@ const DEFAULT_SABOTAGE_PROB_COEF = 300; const DEFAULT_SABOTAGE_DEFENCE_COEF = 0.04; const DEFAULT_SABOTAGE_DAMAGE_MIN = 100; const DEFAULT_SABOTAGE_DAMAGE_MAX = 800; -const DEFAULT_OPENING_PART_YEAR = 3; const DEFAULT_MAX_GENERAL = 500; const DEFAULT_INITIAL_NATION_GEN_LIMIT = 10; const DEFAULT_MAX_TECH_LEVEL = 12; @@ -103,7 +103,7 @@ export const buildCommandEnv = (config: ScenarioConfig, unitSet?: UnitSetDefinit ), sabotageDamageMin: resolveNumber(constValues, ['sabotageDamageMin'], DEFAULT_SABOTAGE_DAMAGE_MIN), sabotageDamageMax: resolveNumber(constValues, ['sabotageDamageMax'], DEFAULT_SABOTAGE_DAMAGE_MAX), - openingPartYear: resolveNumber(constValues, ['openingPartYear'], DEFAULT_OPENING_PART_YEAR), + openingPartYear: resolveNumber(constValues, ['openingPartYear'], LEGACY_DEFAULT_OPENING_PART_YEAR), maxGeneral: resolveNumber(constValues, ['defaultMaxGeneral', 'maxGeneral'], DEFAULT_MAX_GENERAL), defaultNpcGold: resolveNumber(constValues, ['defaultNpcGold', 'defaultGold'], DEFAULT_GENERAL_GOLD), defaultNpcRice: resolveNumber(constValues, ['defaultNpcRice', 'defaultRice'], DEFAULT_GENERAL_RICE), diff --git a/app/game-engine/src/turn/reservedTurnHandler.ts b/app/game-engine/src/turn/reservedTurnHandler.ts index a6ad2f33..e7648e83 100644 --- a/app/game-engine/src/turn/reservedTurnHandler.ts +++ b/app/game-engine/src/turn/reservedTurnHandler.ts @@ -20,6 +20,7 @@ import type { UnitSetDefinition, } from '@sammo-ts/logic'; import { + resolveScenarioStartYear, DEFAULT_TURN_COMMAND_PROFILE, INTERNAL_GENERAL_TURN_COMMAND_KEYS, GeneralTurnCommandLoader, @@ -277,8 +278,8 @@ const resolveConstraintEnv = ( worldConfig: Record ): Record => { const worldMeta = asRecord(world.meta); - const startYear = typeof scenarioMeta?.startYear === 'number' ? scenarioMeta.startYear : undefined; - const relYear = typeof startYear === 'number' ? world.currentYear - startYear : undefined; + const startYear = resolveScenarioStartYear(scenarioMeta?.startYear); + const relYear = world.currentYear - startYear; const joinModeRaw = worldConfig.join_mode ?? worldConfig.joinMode ?? worldMeta.join_mode ?? worldMeta.joinMode; const joinMode = joinModeRaw === 'onlyRandom' ? 'onlyRandom' : 'full'; const killturnRaw = worldMeta.killturn; diff --git a/app/game-engine/src/turn/turnDaemon.ts b/app/game-engine/src/turn/turnDaemon.ts index e3d1933f..1f78e4dc 100644 --- a/app/game-engine/src/turn/turnDaemon.ts +++ b/app/game-engine/src/turn/turnDaemon.ts @@ -5,7 +5,12 @@ import { createPlayAuditHandler, initializeAuditCollection } from '../playAudit/ import { randomUUID } from 'node:crypto'; import { createRuntimePauseGate } from './runtimePauseGate.js'; -import { loadActionModuleBundle, type TurnCommandProfile, type TurnSchedule } from '@sammo-ts/logic'; +import { + resolveScenarioStartYear, + loadActionModuleBundle, + type TurnCommandProfile, + type TurnSchedule, +} from '@sammo-ts/logic'; import { buildGameEventChannel, buildGameReadModelDomainRevisionKey, @@ -447,7 +452,7 @@ const createMonthlyCalendarRuntime = async (options: { }); const monthlyBoundaryPreHandler = createMonthlyBoundaryPreHandler({ getWorld: options.getWorld, - startYear: options.snapshot.scenarioMeta?.startYear ?? options.currentYear, + startYear: resolveScenarioStartYear(options.snapshot.scenarioMeta?.startYear), commandEnv: options.commandEnv, }); const monthlyNationStatsHandler = createMonthlyNationStatsHandler({ @@ -493,7 +498,7 @@ const createMonthlyCalendarRuntime = async (options: { createMonthlyWarSettingHandler({ getWorld: options.getWorld }), createMonthlyWanderHandler({ getWorld: options.getWorld, - startYear: options.snapshot.scenarioMeta?.startYear ?? options.currentYear, + startYear: resolveScenarioStartYear(options.snapshot.scenarioMeta?.startYear), commandEnv: options.commandEnv, }), createMonthlyNationCountHandler({ getWorld: options.getWorld }), @@ -749,7 +754,7 @@ const createTurnDaemonRuntimeWithLease = async ( }); const monthlyEventHandler = createMonthlyEventHandler({ getWorld: () => worldRef, - startYear: snapshot.scenarioMeta?.startYear ?? state.currentYear, + startYear: resolveScenarioStartYear(snapshot.scenarioMeta?.startYear), actions: eventActions, }); const { diff --git a/app/game-engine/test/scenarioSeeder.test.ts b/app/game-engine/test/scenarioSeeder.test.ts index a39db8c9..7c7963fe 100644 --- a/app/game-engine/test/scenarioSeeder.test.ts +++ b/app/game-engine/test/scenarioSeeder.test.ts @@ -7,6 +7,9 @@ import { type RandomAppointmentResolveContext, } from '@sammo-ts/logic/actions/turn/general/che_랜덤임관.js'; import type { ActionContextWorldRef } from '@sammo-ts/logic/actions/turn/actionContext.js'; +import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js'; +import { InMemoryReservedTurnStore } from '../src/turn/reservedTurnStore.js'; +import { createReservedTurnHandler } from '../src/turn/reservedTurnHandler.js'; import { buildCommandEnv } from '../src/turn/reservedTurnCommands.js'; import { loadTurnWorldFromDatabase } from '../src/turn/worldLoader.js'; import { resolveDatabaseUrl } from '../src/scenario/databaseUrl.js'; @@ -103,6 +106,129 @@ const canRun = await canConnectToDatabase(databaseUrl); const describeDb = describe.runIf(canRun); describeDb('scenario database seed', () => { + test.each([2020, 1031])( + 'executes reserved neutral-city sortie only after scenario %i opening boundary', + async (scenarioId) => { + await seedScenarioToDatabase({ + scenarioId, + databaseUrl, + resetTables: true, + now: new Date('2030-01-01T00:00:00Z'), + installOptions: { sync: false, turnTermMinutes: 1 }, + }); + const connector = createGamePostgresConnector({ url: databaseUrl }); + try { + await connector.connect(); + for (const allowed of [false, true]) { + // 매번 DB에서 재로드하여 이전 전투 결과나 생성 당시 객체를 재사용하지 않는다. + const loaded = await loadTurnWorldFromDatabase({ databaseUrl }); + const snapshot = loaded.snapshot; + const startYear = snapshot.scenarioMeta!.startYear!; + const source = snapshot.cities.find( + (city) => + city.nationId > 0 && + snapshot.map.cities.find((entry) => entry.id === city.id)!.connections.length > 0 + )!; + const destId = snapshot.map.cities.find((entry) => entry.id === source.id)!.connections[0]!; + const actor = { + ...snapshot.generals[0]!, + nationId: source.nationId, + cityId: source.id, + npcState: 0, + officerLevel: 1, + crew: 10000, + rice: 100000, + train: 100, + atmos: 100, + crewTypeId: snapshot.unitSet!.defaultCrewTypeId!, + stats: { leadership: 100, strength: 100, intelligence: 100 }, + meta: { ...snapshot.generals[0]!.meta, killturn: 1000 }, + }; + snapshot.generals = [actor]; + snapshot.cities = snapshot.cities.map((city) => + city.id === destId ? { ...city, nationId: 0, defence: 0, wall: 0, population: 100 } : city + ); + loaded.state.currentYear = startYear + (allowed ? 3 : 2); + loaded.state.currentMonth = allowed ? 1 : 12; + const store = new InMemoryReservedTurnStore(connector.prisma, { + maxGeneralTurns: 30, + maxNationTurns: 12, + }); + await store.loadAll(); + store.setGeneralTurn(actor.id, 0, { action: 'che_출병', args: { destCityId: destId } }); + let world: InMemoryTurnWorld | null = null; + const handler = await createReservedTurnHandler({ + reservedTurns: store, + scenarioConfig: snapshot.scenarioConfig, + scenarioMeta: snapshot.scenarioMeta, + map: snapshot.map, + unitSet: snapshot.unitSet, + getWorld: () => world, + }); + world = new InMemoryTurnWorld(loaded.state, snapshot, { + schedule: { entries: [{ startMinute: 0, tickMinutes: 1 }] }, + generalTurnHandler: handler, + }); + world.executeGeneralTurn(actor); + const logs = world + .peekDirtyState() + .logs.map((entry) => entry.text) + .join('\n'); + if (allowed) { + expect(logs).not.toContain('초반 제한'); + expect(world.getCityById(destId)?.nationId).toBe(source.nationId); + } else { + expect(logs).toContain('초반 제한'); + expect(world.getCityById(destId)?.nationId).toBe(0); + } + } + } finally { + await connector.disconnect(); + } + } + ); + + test.each([2020, 1031, 915])( + 'persists and reloads scenario %i calendar independently of the raw appearance year', + async (scenarioId) => { + const scenario = await loadScenarioDefinitionById(scenarioId); + const baseYear = scenario.startYear ?? 180; + for (const sync of [false, true]) { + await seedScenarioToDatabase({ + scenarioId, + databaseUrl, + resetTables: true, + now: new Date('2030-01-01T00:03:00Z'), + installOptions: { sync, turnTermMinutes: 1, openAt: new Date('2030-01-01T00:03:00Z') }, + }); + const connector = createGamePostgresConnector({ url: databaseUrl }); + try { + await connector.connect(); + const persisted = await connector.prisma.worldState.findFirstOrThrow(); + const meta = asRecord(persisted.meta); + expect(asRecord(meta.scenarioMeta).startYear).toBe(baseYear); + expect(persisted.currentYear).toBe(sync ? baseYear - 1 : baseYear); + expect(persisted.currentMonth).toBe(sync ? 4 : 1); + expect(meta.initYear).toBe(persisted.currentYear); + expect(meta.initMonth).toBe(persisted.currentMonth); + expect(meta.develcost).toBe(sync ? 18 : 20); + const loaded = await loadTurnWorldFromDatabase({ databaseUrl }); + expect(loaded.snapshot.scenarioMeta?.startYear).toBe(baseYear); + expect(loaded.state.currentYear).toBe(persisted.currentYear); + if (scenario.startYear === null) { + expect(await connector.prisma.general.count()).toBe( + scenario.generals.length + scenario.generalsEx.length + scenario.generalsNeutral.length + ); + const general = await connector.prisma.general.findFirstOrThrow(); + expect(general.age).toBeGreaterThan(0); + } + } finally { + await connector.disconnect(); + } + } + } + ); + test.each([ { fiction: 1, expectedHistorical: false }, { fiction: 0, expectedHistorical: true }, diff --git a/app/game-engine/test/scenarioStartYear.test.ts b/app/game-engine/test/scenarioStartYear.test.ts new file mode 100644 index 00000000..3e325d52 --- /dev/null +++ b/app/game-engine/test/scenarioStartYear.test.ts @@ -0,0 +1,144 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { buildScenarioBootstrap, type ConstraintContext } from '@sammo-ts/logic'; +import { ActionDefinition as Sortie } from '@sammo-ts/logic/actions/turn/general/che_출병.js'; +import { loadScenarioDefinitionById, resolveScenarioDefaultsPath } from '../src/scenario/scenarioLoader.js'; +import { loadMapDefinitionByName } from '../src/scenario/mapLoader.js'; +import { buildCommandEnv } from '../src/turn/reservedTurnCommands.js'; +import { resolveConstraintEnv } from '../src/turn/ai/generalAi/constraint.js'; +import { createMonthlyEventHandler } from '../src/turn/monthlyEventHandler.js'; +import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js'; +import type { TurnEvent, TurnWorldState } from '../src/turn/types.js'; + +const files = await fs.readdir(path.dirname(resolveScenarioDefaultsPath())); +const ids = files + .flatMap((file) => { + const match = /^scenario_(\d+)\.json$/.exec(file); + return match ? [Number(match[1])] : []; + }) + .sort((a, b) => a - b); + +const isSortieNotice = (raw: unknown): raw is unknown[] => + Array.isArray(raw) && JSON.stringify(raw).includes('출병 제한'); + +const build = async (id: number, openingPartYear?: number) => { + const scenario = await loadScenarioDefinitionById(id); + if (openingPartYear !== undefined) scenario.config.const.openingPartYear = openingPartYear; + const map = await loadMapDefinitionByName(scenario.config.environment.mapName); + return { scenario, ...buildScenarioBootstrap({ scenario, map, options: { hiddenSeed: 'start-year-regression' } }) }; +}; + +describe('scenario calendar and opening contract', () => { + it.each(ids)('scenario %i preserves raw appearance rules and resolves its game calendar', async (id) => { + const { scenario, seed, snapshot } = await build(id); + const startYear = scenario.startYear ?? 180; + expect(seed.scenarioMeta.startYear).toBe(startYear); + expect(snapshot.scenarioMeta?.startYear).toBe(startYear); + if (scenario.startYear === null) { + expect(seed.generals).toHaveLength( + scenario.generals.length + scenario.generalsEx.length + scenario.generalsNeutral.length + ); + } + const command = new Sortie(); + const commandEnv = buildCommandEnv(scenario.config); + const opening = commandEnv.openingPartYear as number; + const dates = [ + { year: startYear - 1, month: 4, reserve: false, execute: false }, + { year: startYear + opening - 3, month: 12, reserve: false, execute: false }, + { year: startYear + opening - 1, month: 12, reserve: true, execute: false }, + { year: startYear + opening, month: 1, reserve: true, execute: true }, + ]; + for (const date of dates) { + const state: TurnWorldState = { + id: 1, + meta: {}, + currentYear: date.year, + currentMonth: date.month, + tickSeconds: 60, + lastTurnTime: new Date('2030-01-01T00:00:00Z'), + }; + const ctx: ConstraintContext = { + actorId: 1, + args: { destCityId: 2 }, + mode: 'full', + env: resolveConstraintEnv(state, snapshot.scenarioMeta, commandEnv), + }; + const view = { has: () => false, get: () => null }; + expect(command.buildMinConstraints(ctx, { destCityId: 2 })[0]!.test(ctx, view).kind).toBe( + date.reserve ? 'allow' : 'deny' + ); + expect(command.buildConstraints(ctx, { destCityId: 2 })[0]!.test(ctx, view).kind).toBe( + date.execute ? 'allow' : 'deny' + ); + } + }); + + it.each([2020, 1031, 915])( + 'scenario %i announces unlock at the same execution boundary after reload', + async (id) => { + const { scenario, seed, snapshot } = await build(id); + const startYear = seed.scenarioMeta.startYear!; + const opening = buildCommandEnv(scenario.config).openingPartYear as number; + const events: TurnEvent[] = seed.events.filter(isSortieNotice).map((raw, index) => ({ + id: index + 1, + targetCode: String(raw[0]).toLowerCase(), + priority: Number(raw[1]), + condition: raw[2], + action: raw.slice(3), + meta: {}, + })); + expect(events).toHaveLength(4); + const notices: string[] = []; + const state: TurnWorldState = { + id: 1, + meta: {}, + currentYear: startYear + opening - 1, + currentMonth: 11, + tickSeconds: 60, + lastTurnTime: new Date('2030-01-01T00:00:00Z'), + }; + let world: InMemoryTurnWorld | null = null; + const handler = createMonthlyEventHandler({ + getWorld: () => world, + startYear, + actions: new Map([ + [ + 'NoticeToHistoryLog', + (args) => { + notices.push(String(args[0])); + }, + ], + ]), + }); + world = new InMemoryTurnWorld( + state, + { + ...snapshot, + diplomacy: [], + initialEvents: [], + generals: [], + events: JSON.parse(JSON.stringify(events)) as TurnEvent[], + }, + { schedule: { entries: [{ startMinute: 0, tickMinutes: 1 }] }, calendarHandler: handler } + ); + await world.advanceMonth(new Date('2030-01-01T00:01:00Z')); + expect(notices).toEqual([]); + await world.advanceMonth(new Date('2030-01-01T00:02:00Z')); + expect(notices).toEqual(['출병 제한이 풀렸습니다.']); + expect( + world.listEvents().some((event) => JSON.stringify(event.action).includes('출병 제한이 풀렸습니다')) + ).toBe(false); + } + ); + + it('moves default notices with an explicitly configured opening period', async () => { + const { seed } = await build(2020, 5); + expect(seed.events.filter(isSortieNotice).map((event) => event[2])).toEqual([ + ['DateRelative', '==', 3, 1], + ['DateRelative', '==', 4, 1], + ['DateRelative', '==', 4, 7], + ['DateRelative', '==', 5, 1], + ]); + }); +}); diff --git a/app/gateway-api/src/scenario/scenarioCatalog.ts b/app/gateway-api/src/scenario/scenarioCatalog.ts index f321a3ae..14315ddd 100644 --- a/app/gateway-api/src/scenario/scenarioCatalog.ts +++ b/app/gateway-api/src/scenario/scenarioCatalog.ts @@ -5,7 +5,12 @@ import { fileURLToPath } from 'node:url'; import { composeScenarioResource } from '@sammo-ts/game-engine/scenario/scenarioComposition.js'; import { loadScenarioDefinitionById } from '@sammo-ts/game-engine/scenario/scenarioLoader.js'; -import { parseScenarioDefaults, parseScenarioDefinition, type ScenarioDefaults } from '@sammo-ts/logic'; +import { + resolveScenarioStartYear, + parseScenarioDefaults, + parseScenarioDefinition, + type ScenarioDefaults, +} from '@sammo-ts/logic'; import { resolveWorkspaceRoot } from '../orchestrator/workspaceRoot.js'; export interface ScenarioNationPreview { @@ -228,7 +233,7 @@ const buildScenarioPreview = async (scenarioId: number): Promise { + it.each([undefined, 'HEAD'])('publishes resolved start years for every scenario (%s)', async (gitRef) => { + const previews = await listScenarioPreviews({ gitRef }); + expect(previews.every((scenario) => Number.isFinite(scenario.year))).toBe(true); + expect(previews.find((scenario) => scenario.id === 2020)?.year).toBe(180); + expect(previews.find((scenario) => scenario.id === 1031)?.year).toBe(192); + expect(previews.find((scenario) => scenario.id === 915)?.year).toBe(180); + }); + it('includes the CHE zero-season dawn scenario in the local catalog', async () => { const previews = await listScenarioPreviews(); diff --git a/docs/architecture/scenario-composition.md b/docs/architecture/scenario-composition.md index 6335cd83..49717ad3 100644 --- a/docs/architecture/scenario-composition.md +++ b/docs/architecture/scenario-composition.md @@ -44,6 +44,30 @@ 실제 설치는 `loadScenarioDefinitionById()`, Git commit 미리보기는 `composeScenarioResource()`를 거쳐 같은 합성 규칙을 사용합니다. +## 시작 연도와 출병 제한 + +원본 `ScenarioDefinition.startYear`와 실행용 `ScenarioMeta.startYear`는 역할이 +다릅니다. 원본 값이 없거나 `null`이면 Ref처럼 시대에 따른 장수 등장·퇴장 필터를 +적용하지 않습니다. 영웅 집결처럼 모든 영웅이 함께 등장하는 시나리오는 이 값을 +생략한 채 유지합니다. + +게임 달력과 규칙은 `resolveScenarioStartYear()`로 기준을 확정합니다. 명시한 +숫자는 그대로 사용하고, 값이 없으면 Ref `GameConstBase::$defaultStartYear`와 같은 +**180년**을 사용합니다. Bootstrap과 DB seed는 이 값을 실행용 metadata에 저장하고, +Gateway의 로컬/Git 시나리오 미리보기도 동일한 연도를 표시합니다. + +동기화 오픈의 `initYear/initMonth`는 실제 첫 달력입니다. 규칙 기준 180년이라도 +동기화 결과가 179년 4월일 수 있으며, 출병 제한 기준을 이 초기 연도로 바꾸지 +않습니다. 기본 `openingPartYear=3`이면 예약 최소 조건은 181년부터, 실제 출병은 +183년 1월부터 허용됩니다. 이는 오픈 후 정확히 36턴을 기다리는 정책이 아닙니다. +API, NPC AI, 예약 실행과 월간 공지는 같은 기준 연도를 사용합니다. 기본 해제 +공지의 상대 연월은 설정된 `openingPartYear`를 따르며, 시나리오가 직접 정의한 +별도 이벤트와 `ignoreDefaultEvents` 계약은 유지합니다. + +이전 버전이 시작 연도 누락을 0년으로 설치한 진행 중 기수는 단순 배포로 달력이 +교정되지 않습니다. 운영 상태를 확인해 닫은 뒤 새 기수를 초기화하거나, 별도 검증한 +달력·상태·기록 migration을 적용해야 합니다. 현재 연도만 수정하지 않습니다. + ## 제공하는 확장 | 경로 | 내용 | diff --git a/packages/logic/src/scenario/constants.ts b/packages/logic/src/scenario/constants.ts index 35e34187..3d5d94e0 100644 --- a/packages/logic/src/scenario/constants.ts +++ b/packages/logic/src/scenario/constants.ts @@ -1,3 +1,11 @@ // Ref GameConstBase::$maxLevel. Scenario `stat.max` is a separate join-time // allocation rule and must not be used as this runtime fallback. export const LEGACY_DEFAULT_MAX_LEVEL = 255; + +// Ref ResetHelper는 시나리오의 nullable 등장 기준과 게임 달력을 분리한다. +// 원본 startYear=null은 영웅 일괄 등장에 사용하므로 원본을 덮어쓰지 않는다. +export const LEGACY_DEFAULT_START_YEAR = 180; +export const LEGACY_DEFAULT_OPENING_PART_YEAR = 3; + +export const resolveScenarioStartYear = (startYear: unknown): number => + typeof startYear === 'number' && Number.isFinite(startYear) ? startYear : LEGACY_DEFAULT_START_YEAR; diff --git a/packages/logic/src/scenario/types.ts b/packages/logic/src/scenario/types.ts index afabf413..aab58809 100644 --- a/packages/logic/src/scenario/types.ts +++ b/packages/logic/src/scenario/types.ts @@ -71,6 +71,7 @@ export interface ScenarioGeneral { export interface ScenarioDefinition { title: string; + /** 원본 등장 연도. null이면 시대에 따른 장수 등장/퇴장 필터를 적용하지 않는다. */ startYear: number | null; life: number | null; fiction: number | null; diff --git a/packages/logic/src/world/bootstrap.ts b/packages/logic/src/world/bootstrap.ts index 115850f8..54be807a 100644 --- a/packages/logic/src/world/bootstrap.ts +++ b/packages/logic/src/world/bootstrap.ts @@ -24,6 +24,7 @@ import type { WorldSeedPayload, WorldSnapshot, } from './types.js'; +import { LEGACY_DEFAULT_OPENING_PART_YEAR, resolveScenarioStartYear } from '../scenario/constants.js'; import { simpleSerialize } from '../war/utils.js'; export interface ScenarioBootstrapOptions { @@ -177,7 +178,7 @@ const resolveScenarioTraits = ( const createScenarioMeta = (scenario: ScenarioDefinition): ScenarioMeta => ({ title: scenario.title, - startYear: scenario.startYear, + startYear: resolveScenarioStartYear(scenario.startYear), life: scenario.life, fiction: scenario.fiction, history: scenario.history, @@ -198,7 +199,7 @@ const LEGACY_DEFAULT_INITIAL_EVENTS: unknown[] = [ [true, ['NoticeToHistoryLog', '2년간 거병 및 건국이 가능합니다.', 6]], ]; -const LEGACY_DEFAULT_EVENTS: unknown[] = [ +const buildLegacyDefaultEvents = (openingPartYear: number): unknown[] => [ ['pre_month', 9_000, true, ['UpdateCitySupply'], ['ProcessWarIncome']], [ 'month', @@ -229,28 +230,28 @@ const LEGACY_DEFAULT_EVENTS: unknown[] = [ [ 'month', 2_000, - ['DateRelative', '==', 1, 1], + ['DateRelative', '==', openingPartYear - 2, 1], ['NoticeToHistoryLog', '2년 뒤 출병 제한이 풀립니다.', 6], ['DeleteEvent'], ], [ 'month', 2_000, - ['DateRelative', '==', 2, 1], + ['DateRelative', '==', openingPartYear - 1, 1], ['NoticeToHistoryLog', '1년 뒤 출병 제한이 풀립니다.', 6], ['DeleteEvent'], ], [ 'month', 2_000, - ['DateRelative', '==', 2, 7], + ['DateRelative', '==', openingPartYear - 1, 7], ['NoticeToHistoryLog', '6개월 뒤 출병 제한이 풀립니다. 병력을 준비해주세요.', 6], ['DeleteEvent'], ], [ 'month', 2_000, - ['DateRelative', '==', 3, 1], + ['DateRelative', '==', openingPartYear, 1], ['NoticeToHistoryLog', '출병 제한이 풀렸습니다.', 6], ['DeleteEvent'], ], @@ -533,7 +534,7 @@ const buildGeneralSeeds = ( installRng.nextRangeInt(0, turnTermMinutes * 60 - 1) * 1_000_000 + installRng.nextRangeInt(0, 999_999); const deathMonth = installRng.nextRangeInt(1, 12); const officerLevel = resolveOfficerLevel(row.officerLevel, nationId); - const initialYear = options?.initialYear ?? scenario.startYear; + const initialYear = options?.initialYear ?? resolveScenarioStartYear(scenario.startYear); const initialMonth = options?.initialMonth ?? 1; const age = resolveAge(initialYear, birthYear); const initialized = initializedValues.get(row); @@ -1008,7 +1009,13 @@ export const buildScenarioBootstrap = (input: ScenarioBootstrapInput): ScenarioB ...actions, ['DeleteEvent'], ]); - const defaultEvents = scenario.ignoreDefaultEvents ? [] : LEGACY_DEFAULT_EVENTS; + // 출병 판정과 공지는 같은 설치 설정을 사용한다. 다른 상대연도 이벤트는 유지한다. + const configuredOpeningPartYear = scenario.config.const.openingPartYear; + const openingPartYear = + typeof configuredOpeningPartYear === 'number' && Number.isFinite(configuredOpeningPartYear) + ? configuredOpeningPartYear + : LEGACY_DEFAULT_OPENING_PART_YEAR; + const defaultEvents = scenario.ignoreDefaultEvents ? [] : buildLegacyDefaultEvents(openingPartYear); const defaultInitialEvents = scenario.ignoreDefaultEvents ? [] : LEGACY_DEFAULT_INITIAL_EVENTS; const events = [...defaultEvents, ...scenario.events, ...delayedGeneralEvents]; diff --git a/packages/logic/src/world/types.ts b/packages/logic/src/world/types.ts index 0426499e..51ee6a3a 100644 --- a/packages/logic/src/world/types.ts +++ b/packages/logic/src/world/types.ts @@ -3,6 +3,7 @@ import type { ScenarioConfig, ScenarioDiplomacy } from '@sammo-ts/logic/scenario export interface ScenarioMeta { title: string; + /** 게임 달력/규칙의 기준 연도. 새 seed는 누락 시 180을 저장하며 null은 이전 저장값 호환용이다. */ startYear: number | null; life: number | null; fiction: number | null;