시나리오 기본 시작 연도와 출병 제한 기준 통일
This commit is contained in:
@@ -18,7 +18,13 @@ import type {
|
|||||||
TriggerValue,
|
TriggerValue,
|
||||||
UnitSetDefinition,
|
UnitSetDefinition,
|
||||||
} from '@sammo-ts/logic';
|
} 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 type { GeneralActionModule } from '@sammo-ts/logic/actionModules/general.js';
|
||||||
import { CommandResolver as RecruitmentCommandResolver } from '@sammo-ts/logic/actions/turn/general/che_징병.js';
|
import { CommandResolver as RecruitmentCommandResolver } from '@sammo-ts/logic/actions/turn/general/che_징병.js';
|
||||||
import { projectItemSlots, readItemInventoryFromMeta } from '@sammo-ts/logic/items/index.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),
|
sabotageDefenceCoefByGeneralCount: resolveNumber(constValues, ['sabotageDefenceCoefByGeneralCount'], 0),
|
||||||
sabotageDamageMin: resolveNumber(constValues, ['sabotageDamageMin'], 0),
|
sabotageDamageMin: resolveNumber(constValues, ['sabotageDamageMin'], 0),
|
||||||
sabotageDamageMax: resolveNumber(constValues, ['sabotageDamageMax'], 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),
|
maxGeneral: resolveNumber(constValues, ['defaultMaxGeneral', 'maxGeneral'], 0),
|
||||||
defaultNpcGold: resolveNumber(constValues, ['defaultNpcGold', 'defaultGold'], DEFAULT_GENERAL_GOLD),
|
defaultNpcGold: resolveNumber(constValues, ['defaultNpcGold', 'defaultGold'], DEFAULT_GENERAL_GOLD),
|
||||||
defaultNpcRice: resolveNumber(constValues, ['defaultNpcRice', 'defaultRice'], DEFAULT_GENERAL_RICE),
|
defaultNpcRice: resolveNumber(constValues, ['defaultNpcRice', 'defaultRice'], DEFAULT_GENERAL_RICE),
|
||||||
@@ -370,8 +376,8 @@ const buildConstraintEnv = (worldState: WorldStateRow): Record<string, unknown>
|
|||||||
const constValues = asRecord(config.const);
|
const constValues = asRecord(config.const);
|
||||||
const meta = asRecord(worldState.meta);
|
const meta = asRecord(worldState.meta);
|
||||||
const scenarioMeta = asRecord(meta.scenarioMeta);
|
const scenarioMeta = asRecord(meta.scenarioMeta);
|
||||||
const startYear = typeof scenarioMeta.startYear === 'number' ? scenarioMeta.startYear : undefined;
|
const startYear = resolveScenarioStartYear(scenarioMeta.startYear);
|
||||||
const relYear = typeof startYear === 'number' ? worldState.currentYear - startYear : undefined;
|
const relYear = worldState.currentYear - startYear;
|
||||||
const joinModeRaw = config.join_mode ?? config.joinMode;
|
const joinModeRaw = config.join_mode ?? config.joinMode;
|
||||||
const joinMode = typeof joinModeRaw === 'string' ? joinModeRaw : 'full';
|
const joinMode = typeof joinModeRaw === 'string' ? joinModeRaw : 'full';
|
||||||
|
|
||||||
@@ -383,7 +389,7 @@ const buildConstraintEnv = (worldState: WorldStateRow): Record<string, unknown>
|
|||||||
startYear,
|
startYear,
|
||||||
relYear,
|
relYear,
|
||||||
join_mode: joinMode,
|
join_mode: joinMode,
|
||||||
openingPartYear: resolveNumber(constValues, ['openingPartYear'], 0),
|
openingPartYear: resolveNumber(constValues, ['openingPartYear'], LEGACY_DEFAULT_OPENING_PART_YEAR),
|
||||||
maxTechLevel: resolveNumber(constValues, ['maxTechLevel'], 12),
|
maxTechLevel: resolveNumber(constValues, ['maxTechLevel'], 12),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -559,10 +565,7 @@ export const buildRecruitmentCommandInfo = (options: {
|
|||||||
avoid: crewType.avoid,
|
avoid: crewType.avoid,
|
||||||
baseCost: displayCost.gold,
|
baseCost: displayCost.gold,
|
||||||
baseRice: displayCost.rice,
|
baseRice: displayCost.rice,
|
||||||
info: [
|
info: [...crewType.info, ...crewType.requirements.map(formatCrewTypeRequirement).filter(Boolean)],
|
||||||
...crewType.info,
|
|
||||||
...crewType.requirements.map(formatCrewTypeRequirement).filter(Boolean),
|
|
||||||
],
|
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -102,6 +102,30 @@ const buildNation = (): NationRow =>
|
|||||||
}) as unknown as NationRow;
|
}) as unknown as NationRow;
|
||||||
|
|
||||||
describe('buildTurnCommandTable', () => {
|
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<ReturnType<typeof buildTurnCommandTable>>, key: string) =>
|
const findCommand = (table: Awaited<ReturnType<typeof buildTurnCommandTable>>, key: string) =>
|
||||||
[...table.general, ...table.nation].flatMap(({ values }) => values).find((command) => command.key === key);
|
[...table.general, ...table.nation].flatMap(({ values }) => values).find((command) => command.key === key);
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import {
|
|||||||
import { GameClock, asNumber, asRecord, type GameClockMode, type GameClockPhase } from '@sammo-ts/common';
|
import { GameClock, asNumber, asRecord, type GameClockMode, type GameClockPhase } from '@sammo-ts/common';
|
||||||
import {
|
import {
|
||||||
buildScenarioBootstrap,
|
buildScenarioBootstrap,
|
||||||
|
resolveScenarioStartYear,
|
||||||
|
LEGACY_DEFAULT_OPENING_PART_YEAR,
|
||||||
resolveScenarioGeneralDeathMonth,
|
resolveScenarioGeneralDeathMonth,
|
||||||
type GeneralMeta,
|
type GeneralMeta,
|
||||||
type ScenarioBootstrapWarning,
|
type ScenarioBootstrapWarning,
|
||||||
@@ -29,7 +31,6 @@ import { applyInitialChangeCityEvents } from '../turn/monthlyChangeCityAction.js
|
|||||||
const DEFAULT_TICK_SECONDS = 120 * 60;
|
const DEFAULT_TICK_SECONDS = 120 * 60;
|
||||||
const DEFAULT_GENERAL_GOLD = 1000;
|
const DEFAULT_GENERAL_GOLD = 1000;
|
||||||
const DEFAULT_GENERAL_RICE = 1000;
|
const DEFAULT_GENERAL_RICE = 1000;
|
||||||
const DEFAULT_OPENING_PART_YEAR = 3;
|
|
||||||
const INTEGRATION_WORLD_SEED_ENV = 'INTEGRATION_WORLD_SEED';
|
const INTEGRATION_WORLD_SEED_ENV = 'INTEGRATION_WORLD_SEED';
|
||||||
|
|
||||||
const MINUTES_TO_MS = 60_000;
|
const MINUTES_TO_MS = 60_000;
|
||||||
@@ -148,7 +149,7 @@ const resolveStartState = (
|
|||||||
turnTermMinutes: number,
|
turnTermMinutes: number,
|
||||||
sync: boolean
|
sync: boolean
|
||||||
): { startTime: Date; currentYear: number; currentMonth: number } => {
|
): { startTime: Date; currentYear: number; currentMonth: number } => {
|
||||||
const startYear = scenarioStartYear ?? 0;
|
const startYear = resolveScenarioStartYear(scenarioStartYear);
|
||||||
if (!sync) {
|
if (!sync) {
|
||||||
return {
|
return {
|
||||||
startTime: cutTurn(now, turnTermMinutes),
|
startTime: cutTurn(now, turnTermMinutes),
|
||||||
@@ -294,7 +295,7 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom
|
|||||||
|
|
||||||
const scenarioConst = asRecord(seed.scenarioConfig.const);
|
const scenarioConst = asRecord(seed.scenarioConfig.const);
|
||||||
if (typeof scenarioConst.openingPartYear !== 'number' || Number.isNaN(scenarioConst.openingPartYear)) {
|
if (typeof scenarioConst.openingPartYear !== 'number' || Number.isNaN(scenarioConst.openingPartYear)) {
|
||||||
scenarioConst.openingPartYear = DEFAULT_OPENING_PART_YEAR;
|
scenarioConst.openingPartYear = LEGACY_DEFAULT_OPENING_PART_YEAR;
|
||||||
}
|
}
|
||||||
const scenarioConfig = {
|
const scenarioConfig = {
|
||||||
...seed.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,
|
killturn: install?.npcMode === 1 ? Math.trunc(4800 / turnTermMinutes / 3) : 4800 / turnTermMinutes,
|
||||||
// Ref seeds game_env.develcost before the first general turn. The
|
// Ref seeds game_env.develcost before the first general turn. The
|
||||||
// monthly pre-handler recalculates the same value at each boundary.
|
// 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),
|
starttime: formatDateTime(startState.startTime),
|
||||||
turntime: formatDateTime(gameClockMode === 'manual' ? now : initialClock.baseTime),
|
turntime: formatDateTime(gameClockMode === 'manual' ? now : initialClock.baseTime),
|
||||||
opentime: formatDateTime(initialClockWallAnchor),
|
opentime: formatDateTime(initialClockWallAnchor),
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { resolveScenarioStartYear } from '@sammo-ts/logic';
|
||||||
import type { ScenarioMeta, TurnCommandEnv } from '@sammo-ts/logic';
|
import type { ScenarioMeta, TurnCommandEnv } from '@sammo-ts/logic';
|
||||||
|
|
||||||
import type { TurnWorldState } from '../../types.js';
|
import type { TurnWorldState } from '../../types.js';
|
||||||
@@ -9,8 +10,8 @@ export const resolveConstraintEnv = (
|
|||||||
scenarioMeta: ScenarioMeta | undefined,
|
scenarioMeta: ScenarioMeta | undefined,
|
||||||
env: TurnCommandEnv
|
env: TurnCommandEnv
|
||||||
): ConstraintEnv => {
|
): ConstraintEnv => {
|
||||||
const startYear = typeof scenarioMeta?.startYear === 'number' ? scenarioMeta.startYear : undefined;
|
const startYear = resolveScenarioStartYear(scenarioMeta?.startYear);
|
||||||
const relYear = typeof startYear === 'number' ? world.currentYear - startYear : undefined;
|
const relYear = world.currentYear - startYear;
|
||||||
const worldMeta = world.meta as Record<string, unknown>;
|
const worldMeta = world.meta as Record<string, unknown>;
|
||||||
const rawKillturn = worldMeta.killturn;
|
const rawKillturn = worldMeta.killturn;
|
||||||
const killturn =
|
const killturn =
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
LEGACY_RANDOM_GENERAL_FIRST_NAMES,
|
LEGACY_RANDOM_GENERAL_FIRST_NAMES,
|
||||||
LEGACY_RANDOM_GENERAL_LAST_NAMES,
|
LEGACY_RANDOM_GENERAL_LAST_NAMES,
|
||||||
LEGACY_DEFAULT_MAX_LEVEL,
|
LEGACY_DEFAULT_MAX_LEVEL,
|
||||||
|
LEGACY_DEFAULT_OPENING_PART_YEAR,
|
||||||
loadGeneralTurnCommandSpecs,
|
loadGeneralTurnCommandSpecs,
|
||||||
loadNationTurnCommandSpecs,
|
loadNationTurnCommandSpecs,
|
||||||
loadActionModuleBundle,
|
loadActionModuleBundle,
|
||||||
@@ -33,7 +34,6 @@ const DEFAULT_SABOTAGE_PROB_COEF = 300;
|
|||||||
const DEFAULT_SABOTAGE_DEFENCE_COEF = 0.04;
|
const DEFAULT_SABOTAGE_DEFENCE_COEF = 0.04;
|
||||||
const DEFAULT_SABOTAGE_DAMAGE_MIN = 100;
|
const DEFAULT_SABOTAGE_DAMAGE_MIN = 100;
|
||||||
const DEFAULT_SABOTAGE_DAMAGE_MAX = 800;
|
const DEFAULT_SABOTAGE_DAMAGE_MAX = 800;
|
||||||
const DEFAULT_OPENING_PART_YEAR = 3;
|
|
||||||
const DEFAULT_MAX_GENERAL = 500;
|
const DEFAULT_MAX_GENERAL = 500;
|
||||||
const DEFAULT_INITIAL_NATION_GEN_LIMIT = 10;
|
const DEFAULT_INITIAL_NATION_GEN_LIMIT = 10;
|
||||||
const DEFAULT_MAX_TECH_LEVEL = 12;
|
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),
|
sabotageDamageMin: resolveNumber(constValues, ['sabotageDamageMin'], DEFAULT_SABOTAGE_DAMAGE_MIN),
|
||||||
sabotageDamageMax: resolveNumber(constValues, ['sabotageDamageMax'], DEFAULT_SABOTAGE_DAMAGE_MAX),
|
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),
|
maxGeneral: resolveNumber(constValues, ['defaultMaxGeneral', 'maxGeneral'], DEFAULT_MAX_GENERAL),
|
||||||
defaultNpcGold: resolveNumber(constValues, ['defaultNpcGold', 'defaultGold'], DEFAULT_GENERAL_GOLD),
|
defaultNpcGold: resolveNumber(constValues, ['defaultNpcGold', 'defaultGold'], DEFAULT_GENERAL_GOLD),
|
||||||
defaultNpcRice: resolveNumber(constValues, ['defaultNpcRice', 'defaultRice'], DEFAULT_GENERAL_RICE),
|
defaultNpcRice: resolveNumber(constValues, ['defaultNpcRice', 'defaultRice'], DEFAULT_GENERAL_RICE),
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import type {
|
|||||||
UnitSetDefinition,
|
UnitSetDefinition,
|
||||||
} from '@sammo-ts/logic';
|
} from '@sammo-ts/logic';
|
||||||
import {
|
import {
|
||||||
|
resolveScenarioStartYear,
|
||||||
DEFAULT_TURN_COMMAND_PROFILE,
|
DEFAULT_TURN_COMMAND_PROFILE,
|
||||||
INTERNAL_GENERAL_TURN_COMMAND_KEYS,
|
INTERNAL_GENERAL_TURN_COMMAND_KEYS,
|
||||||
GeneralTurnCommandLoader,
|
GeneralTurnCommandLoader,
|
||||||
@@ -277,8 +278,8 @@ const resolveConstraintEnv = (
|
|||||||
worldConfig: Record<string, unknown>
|
worldConfig: Record<string, unknown>
|
||||||
): Record<string, unknown> => {
|
): Record<string, unknown> => {
|
||||||
const worldMeta = asRecord(world.meta);
|
const worldMeta = asRecord(world.meta);
|
||||||
const startYear = typeof scenarioMeta?.startYear === 'number' ? scenarioMeta.startYear : undefined;
|
const startYear = resolveScenarioStartYear(scenarioMeta?.startYear);
|
||||||
const relYear = typeof startYear === 'number' ? world.currentYear - startYear : undefined;
|
const relYear = world.currentYear - startYear;
|
||||||
const joinModeRaw = worldConfig.join_mode ?? worldConfig.joinMode ?? worldMeta.join_mode ?? worldMeta.joinMode;
|
const joinModeRaw = worldConfig.join_mode ?? worldConfig.joinMode ?? worldMeta.join_mode ?? worldMeta.joinMode;
|
||||||
const joinMode = joinModeRaw === 'onlyRandom' ? 'onlyRandom' : 'full';
|
const joinMode = joinModeRaw === 'onlyRandom' ? 'onlyRandom' : 'full';
|
||||||
const killturnRaw = worldMeta.killturn;
|
const killturnRaw = worldMeta.killturn;
|
||||||
|
|||||||
@@ -5,7 +5,12 @@ import { createPlayAuditHandler, initializeAuditCollection } from '../playAudit/
|
|||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
import { createRuntimePauseGate } from './runtimePauseGate.js';
|
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 {
|
import {
|
||||||
buildGameEventChannel,
|
buildGameEventChannel,
|
||||||
buildGameReadModelDomainRevisionKey,
|
buildGameReadModelDomainRevisionKey,
|
||||||
@@ -447,7 +452,7 @@ const createMonthlyCalendarRuntime = async (options: {
|
|||||||
});
|
});
|
||||||
const monthlyBoundaryPreHandler = createMonthlyBoundaryPreHandler({
|
const monthlyBoundaryPreHandler = createMonthlyBoundaryPreHandler({
|
||||||
getWorld: options.getWorld,
|
getWorld: options.getWorld,
|
||||||
startYear: options.snapshot.scenarioMeta?.startYear ?? options.currentYear,
|
startYear: resolveScenarioStartYear(options.snapshot.scenarioMeta?.startYear),
|
||||||
commandEnv: options.commandEnv,
|
commandEnv: options.commandEnv,
|
||||||
});
|
});
|
||||||
const monthlyNationStatsHandler = createMonthlyNationStatsHandler({
|
const monthlyNationStatsHandler = createMonthlyNationStatsHandler({
|
||||||
@@ -493,7 +498,7 @@ const createMonthlyCalendarRuntime = async (options: {
|
|||||||
createMonthlyWarSettingHandler({ getWorld: options.getWorld }),
|
createMonthlyWarSettingHandler({ getWorld: options.getWorld }),
|
||||||
createMonthlyWanderHandler({
|
createMonthlyWanderHandler({
|
||||||
getWorld: options.getWorld,
|
getWorld: options.getWorld,
|
||||||
startYear: options.snapshot.scenarioMeta?.startYear ?? options.currentYear,
|
startYear: resolveScenarioStartYear(options.snapshot.scenarioMeta?.startYear),
|
||||||
commandEnv: options.commandEnv,
|
commandEnv: options.commandEnv,
|
||||||
}),
|
}),
|
||||||
createMonthlyNationCountHandler({ getWorld: options.getWorld }),
|
createMonthlyNationCountHandler({ getWorld: options.getWorld }),
|
||||||
@@ -749,7 +754,7 @@ const createTurnDaemonRuntimeWithLease = async (
|
|||||||
});
|
});
|
||||||
const monthlyEventHandler = createMonthlyEventHandler({
|
const monthlyEventHandler = createMonthlyEventHandler({
|
||||||
getWorld: () => worldRef,
|
getWorld: () => worldRef,
|
||||||
startYear: snapshot.scenarioMeta?.startYear ?? state.currentYear,
|
startYear: resolveScenarioStartYear(snapshot.scenarioMeta?.startYear),
|
||||||
actions: eventActions,
|
actions: eventActions,
|
||||||
});
|
});
|
||||||
const {
|
const {
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ import {
|
|||||||
type RandomAppointmentResolveContext,
|
type RandomAppointmentResolveContext,
|
||||||
} from '@sammo-ts/logic/actions/turn/general/che_랜덤임관.js';
|
} from '@sammo-ts/logic/actions/turn/general/che_랜덤임관.js';
|
||||||
import type { ActionContextWorldRef } from '@sammo-ts/logic/actions/turn/actionContext.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 { buildCommandEnv } from '../src/turn/reservedTurnCommands.js';
|
||||||
import { loadTurnWorldFromDatabase } from '../src/turn/worldLoader.js';
|
import { loadTurnWorldFromDatabase } from '../src/turn/worldLoader.js';
|
||||||
import { resolveDatabaseUrl } from '../src/scenario/databaseUrl.js';
|
import { resolveDatabaseUrl } from '../src/scenario/databaseUrl.js';
|
||||||
@@ -103,6 +106,129 @@ const canRun = await canConnectToDatabase(databaseUrl);
|
|||||||
const describeDb = describe.runIf(canRun);
|
const describeDb = describe.runIf(canRun);
|
||||||
|
|
||||||
describeDb('scenario database seed', () => {
|
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([
|
test.each([
|
||||||
{ fiction: 1, expectedHistorical: false },
|
{ fiction: 1, expectedHistorical: false },
|
||||||
{ fiction: 0, expectedHistorical: true },
|
{ 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],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -5,7 +5,12 @@ import { fileURLToPath } from 'node:url';
|
|||||||
|
|
||||||
import { composeScenarioResource } from '@sammo-ts/game-engine/scenario/scenarioComposition.js';
|
import { composeScenarioResource } from '@sammo-ts/game-engine/scenario/scenarioComposition.js';
|
||||||
import { loadScenarioDefinitionById } from '@sammo-ts/game-engine/scenario/scenarioLoader.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';
|
import { resolveWorkspaceRoot } from '../orchestrator/workspaceRoot.js';
|
||||||
|
|
||||||
export interface ScenarioNationPreview {
|
export interface ScenarioNationPreview {
|
||||||
@@ -228,7 +233,7 @@ const buildScenarioPreview = async (scenarioId: number): Promise<ScenarioPreview
|
|||||||
return {
|
return {
|
||||||
id: scenarioId,
|
id: scenarioId,
|
||||||
title: scenario.title,
|
title: scenario.title,
|
||||||
year: scenario.startYear ?? null,
|
year: resolveScenarioStartYear(scenario.startYear),
|
||||||
defaultStatTotal: scenario.config.stat.total,
|
defaultStatTotal: scenario.config.stat.total,
|
||||||
fiction: scenario.fiction,
|
fiction: scenario.fiction,
|
||||||
npcCount: scenario.generals.length,
|
npcCount: scenario.generals.length,
|
||||||
@@ -275,7 +280,7 @@ const buildScenarioPreviewFromGit = async (commitSha: string, scenarioId: number
|
|||||||
return {
|
return {
|
||||||
id: scenarioId,
|
id: scenarioId,
|
||||||
title: scenario.title,
|
title: scenario.title,
|
||||||
year: scenario.startYear ?? null,
|
year: resolveScenarioStartYear(scenario.startYear),
|
||||||
defaultStatTotal: scenario.config.stat.total,
|
defaultStatTotal: scenario.config.stat.total,
|
||||||
fiction: scenario.fiction,
|
fiction: scenario.fiction,
|
||||||
npcCount: scenario.generals.length,
|
npcCount: scenario.generals.length,
|
||||||
|
|||||||
@@ -3,6 +3,14 @@ import { describe, expect, it } from 'vitest';
|
|||||||
import { listScenarioPreviews, resolveGitCommitSha } from '../src/scenario/scenarioCatalog.js';
|
import { listScenarioPreviews, resolveGitCommitSha } from '../src/scenario/scenarioCatalog.js';
|
||||||
|
|
||||||
describe('scenarioCatalog git ref support', () => {
|
describe('scenarioCatalog git ref support', () => {
|
||||||
|
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 () => {
|
it('includes the CHE zero-season dawn scenario in the local catalog', async () => {
|
||||||
const previews = await listScenarioPreviews();
|
const previews = await listScenarioPreviews();
|
||||||
|
|
||||||
|
|||||||
@@ -44,6 +44,30 @@
|
|||||||
실제 설치는 `loadScenarioDefinitionById()`, Git commit 미리보기는
|
실제 설치는 `loadScenarioDefinitionById()`, Git commit 미리보기는
|
||||||
`composeScenarioResource()`를 거쳐 같은 합성 규칙을 사용합니다.
|
`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을 적용해야 합니다. 현재 연도만 수정하지 않습니다.
|
||||||
|
|
||||||
## 제공하는 확장
|
## 제공하는 확장
|
||||||
|
|
||||||
| 경로 | 내용 |
|
| 경로 | 내용 |
|
||||||
|
|||||||
@@ -1,3 +1,11 @@
|
|||||||
// Ref GameConstBase::$maxLevel. Scenario `stat.max` is a separate join-time
|
// Ref GameConstBase::$maxLevel. Scenario `stat.max` is a separate join-time
|
||||||
// allocation rule and must not be used as this runtime fallback.
|
// allocation rule and must not be used as this runtime fallback.
|
||||||
export const LEGACY_DEFAULT_MAX_LEVEL = 255;
|
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;
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ export interface ScenarioGeneral {
|
|||||||
|
|
||||||
export interface ScenarioDefinition {
|
export interface ScenarioDefinition {
|
||||||
title: string;
|
title: string;
|
||||||
|
/** 원본 등장 연도. null이면 시대에 따른 장수 등장/퇴장 필터를 적용하지 않는다. */
|
||||||
startYear: number | null;
|
startYear: number | null;
|
||||||
life: number | null;
|
life: number | null;
|
||||||
fiction: number | null;
|
fiction: number | null;
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import type {
|
|||||||
WorldSeedPayload,
|
WorldSeedPayload,
|
||||||
WorldSnapshot,
|
WorldSnapshot,
|
||||||
} from './types.js';
|
} from './types.js';
|
||||||
|
import { LEGACY_DEFAULT_OPENING_PART_YEAR, resolveScenarioStartYear } from '../scenario/constants.js';
|
||||||
import { simpleSerialize } from '../war/utils.js';
|
import { simpleSerialize } from '../war/utils.js';
|
||||||
|
|
||||||
export interface ScenarioBootstrapOptions {
|
export interface ScenarioBootstrapOptions {
|
||||||
@@ -177,7 +178,7 @@ const resolveScenarioTraits = (
|
|||||||
|
|
||||||
const createScenarioMeta = (scenario: ScenarioDefinition): ScenarioMeta => ({
|
const createScenarioMeta = (scenario: ScenarioDefinition): ScenarioMeta => ({
|
||||||
title: scenario.title,
|
title: scenario.title,
|
||||||
startYear: scenario.startYear,
|
startYear: resolveScenarioStartYear(scenario.startYear),
|
||||||
life: scenario.life,
|
life: scenario.life,
|
||||||
fiction: scenario.fiction,
|
fiction: scenario.fiction,
|
||||||
history: scenario.history,
|
history: scenario.history,
|
||||||
@@ -198,7 +199,7 @@ const LEGACY_DEFAULT_INITIAL_EVENTS: unknown[] = [
|
|||||||
[true, ['NoticeToHistoryLog', '<S>2년간 거병 및 건국이 가능합니다.</>', 6]],
|
[true, ['NoticeToHistoryLog', '<S>2년간 거병 및 건국이 가능합니다.</>', 6]],
|
||||||
];
|
];
|
||||||
|
|
||||||
const LEGACY_DEFAULT_EVENTS: unknown[] = [
|
const buildLegacyDefaultEvents = (openingPartYear: number): unknown[] => [
|
||||||
['pre_month', 9_000, true, ['UpdateCitySupply'], ['ProcessWarIncome']],
|
['pre_month', 9_000, true, ['UpdateCitySupply'], ['ProcessWarIncome']],
|
||||||
[
|
[
|
||||||
'month',
|
'month',
|
||||||
@@ -229,28 +230,28 @@ const LEGACY_DEFAULT_EVENTS: unknown[] = [
|
|||||||
[
|
[
|
||||||
'month',
|
'month',
|
||||||
2_000,
|
2_000,
|
||||||
['DateRelative', '==', 1, 1],
|
['DateRelative', '==', openingPartYear - 2, 1],
|
||||||
['NoticeToHistoryLog', '<S>2년 뒤 출병 제한이 풀립니다.</>', 6],
|
['NoticeToHistoryLog', '<S>2년 뒤 출병 제한이 풀립니다.</>', 6],
|
||||||
['DeleteEvent'],
|
['DeleteEvent'],
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'month',
|
'month',
|
||||||
2_000,
|
2_000,
|
||||||
['DateRelative', '==', 2, 1],
|
['DateRelative', '==', openingPartYear - 1, 1],
|
||||||
['NoticeToHistoryLog', '<S>1년 뒤 출병 제한이 풀립니다.</>', 6],
|
['NoticeToHistoryLog', '<S>1년 뒤 출병 제한이 풀립니다.</>', 6],
|
||||||
['DeleteEvent'],
|
['DeleteEvent'],
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'month',
|
'month',
|
||||||
2_000,
|
2_000,
|
||||||
['DateRelative', '==', 2, 7],
|
['DateRelative', '==', openingPartYear - 1, 7],
|
||||||
['NoticeToHistoryLog', '<S>6개월 뒤 출병 제한이 풀립니다. 병력을 준비해주세요.</>', 6],
|
['NoticeToHistoryLog', '<S>6개월 뒤 출병 제한이 풀립니다. 병력을 준비해주세요.</>', 6],
|
||||||
['DeleteEvent'],
|
['DeleteEvent'],
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'month',
|
'month',
|
||||||
2_000,
|
2_000,
|
||||||
['DateRelative', '==', 3, 1],
|
['DateRelative', '==', openingPartYear, 1],
|
||||||
['NoticeToHistoryLog', '<S>출병 제한이 풀렸습니다.</>', 6],
|
['NoticeToHistoryLog', '<S>출병 제한이 풀렸습니다.</>', 6],
|
||||||
['DeleteEvent'],
|
['DeleteEvent'],
|
||||||
],
|
],
|
||||||
@@ -533,7 +534,7 @@ const buildGeneralSeeds = (
|
|||||||
installRng.nextRangeInt(0, turnTermMinutes * 60 - 1) * 1_000_000 + installRng.nextRangeInt(0, 999_999);
|
installRng.nextRangeInt(0, turnTermMinutes * 60 - 1) * 1_000_000 + installRng.nextRangeInt(0, 999_999);
|
||||||
const deathMonth = installRng.nextRangeInt(1, 12);
|
const deathMonth = installRng.nextRangeInt(1, 12);
|
||||||
const officerLevel = resolveOfficerLevel(row.officerLevel, nationId);
|
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 initialMonth = options?.initialMonth ?? 1;
|
||||||
const age = resolveAge(initialYear, birthYear);
|
const age = resolveAge(initialYear, birthYear);
|
||||||
const initialized = initializedValues.get(row);
|
const initialized = initializedValues.get(row);
|
||||||
@@ -1008,7 +1009,13 @@ export const buildScenarioBootstrap = (input: ScenarioBootstrapInput): ScenarioB
|
|||||||
...actions,
|
...actions,
|
||||||
['DeleteEvent'],
|
['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 defaultInitialEvents = scenario.ignoreDefaultEvents ? [] : LEGACY_DEFAULT_INITIAL_EVENTS;
|
||||||
const events = [...defaultEvents, ...scenario.events, ...delayedGeneralEvents];
|
const events = [...defaultEvents, ...scenario.events, ...delayedGeneralEvents];
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { ScenarioConfig, ScenarioDiplomacy } from '@sammo-ts/logic/scenario
|
|||||||
|
|
||||||
export interface ScenarioMeta {
|
export interface ScenarioMeta {
|
||||||
title: string;
|
title: string;
|
||||||
|
/** 게임 달력/규칙의 기준 연도. 새 seed는 누락 시 180을 저장하며 null은 이전 저장값 호환용이다. */
|
||||||
startYear: number | null;
|
startYear: number | null;
|
||||||
life: number | null;
|
life: number | null;
|
||||||
fiction: number | null;
|
fiction: number | null;
|
||||||
|
|||||||
Reference in New Issue
Block a user