시나리오 기본 시작 연도와 출병 제한 기준 통일

This commit is contained in:
2026-09-17 00:53:24 +00:00
parent 581ecd6a7b
commit d89bdc439f
16 changed files with 393 additions and 34 deletions
@@ -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),
@@ -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<string, unknown>;
const rawKillturn = worldMeta.killturn;
const killturn =
@@ -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),
@@ -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<string, unknown>
): Record<string, unknown> => {
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;
+9 -4
View File
@@ -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 {
+126
View File
@@ -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 },
@@ -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(['<S>출병 제한이 풀렸습니다.</>']);
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],
]);
});
});