feat: port scenario action effects

This commit is contained in:
2026-07-30 19:00:04 +00:00
parent 7f31459385
commit c68d992046
39 changed files with 1131 additions and 95 deletions
+5 -1
View File
@@ -1,7 +1,7 @@
import type { WorldStateRow } from '../context.js';
import type { BattleSimJobPayload, BattleSimRequestPayload } from './types.js';
import { loadUnitSetDefinitionByName } from './unitSetLoader.js';
import type { WarEngineConfig } from '@sammo-ts/logic';
import { normalizeScenarioEffect, type ScenarioEffectKey, type WarEngineConfig } from '@sammo-ts/logic';
import { asRecord } from '@sammo-ts/common';
import type { UnitSetDefinition } from '@sammo-ts/logic';
@@ -79,6 +79,7 @@ export interface BattleSimEnvironment {
unitSet: UnitSetDefinition;
config: WarEngineConfig;
startYear: number;
scenarioEffect: ScenarioEffectKey | null;
}
export const buildBattleSimEnvironment = async (
@@ -89,6 +90,7 @@ export const buildBattleSimEnvironment = async (
const unitSet = await loadUnitSetDefinitionByName(unitSetName);
const configRecord = asRecord(worldState.config);
const scenarioEnvironment = asRecord(configRecord.environment ?? configRecord.map);
const constValues = asRecord(configRecord.const ?? configRecord.consts);
const castleCrewTypeId = resolveNumber(constValues, ['castleCrewTypeId'], resolveCastleCrewTypeId(unitSet));
const castleArmType = resolveCastleArmType(unitSet, castleCrewTypeId);
@@ -117,6 +119,7 @@ export const buildBattleSimEnvironment = async (
unitSet,
config,
startYear: resolveStartYear(worldState),
scenarioEffect: normalizeScenarioEffect(scenarioEnvironment.scenarioEffect),
};
};
@@ -136,5 +139,6 @@ export const buildBattleSimJobPayload = async (
month: request.month,
startYear: environment.startYear,
},
scenarioEffect: environment.scenarioEffect,
};
};
+9 -4
View File
@@ -12,6 +12,7 @@ import {
createItemActionModules,
createItemModuleRegistry,
createRefOrderedActionStack,
createScenarioEffectActionModules,
ITEM_KEYS,
loadItemModules,
createInheritBuffModules,
@@ -62,8 +63,12 @@ const domesticWarModule = new TraitWarActionRouter('domestic', traitCatalog);
const warTraitModule = new TraitWarActionRouter('war', traitCatalog);
const personalityWarModule = new TraitWarActionRouter('personality', traitCatalog);
const buildWarActionModules = (unitSet: UnitSetDefinition): RefOrderedActionStack<WarActionModule> => {
const buildWarActionModules = (
unitSet: UnitSetDefinition,
scenarioEffect?: string | null
): RefOrderedActionStack<WarActionModule> => {
const crewTypeCatalog = compileCrewTypeCatalog(unitSet, crewTypeWarTriggerRegistry);
const scenario = createScenarioEffectActionModules(scenarioEffect);
return createRefOrderedActionStack<WarActionModule>({
nation: nationWarModule,
officer: officerWarModule,
@@ -72,7 +77,7 @@ const buildWarActionModules = (unitSet: UnitSetDefinition): RefOrderedActionStac
personality: personalityWarModule,
crewType: crewTypeCatalog.warActionModule,
inheritance: inheritBuffModules.war,
scenario: null,
scenario: scenario.war,
items: itemWarModules,
});
};
@@ -296,7 +301,7 @@ const resolveDefenderOrderPayload = (payload: BattleSimJobPayload): number[] =>
const defenderCity = mapCityPayload(payload.defenderCity);
const attacker = mapGeneralPayload(payload.attackerGeneral);
const defenders = payload.defenderGenerals.map(mapGeneralPayload);
const warActionModules = buildWarActionModules(payload.unitSet);
const warActionModules = buildWarActionModules(payload.unitSet, payload.scenarioEffect);
return resolveDefenderOrder({
unitSet: payload.unitSet,
@@ -338,7 +343,7 @@ export const processBattleSimJob = (
}
let repeatCnt = payload.repeatCnt;
const warActionModules = buildWarActionModules(payload.unitSet);
const warActionModules = buildWarActionModules(payload.unitSet, payload.scenarioEffect);
const baseSeed = payload.seed ?? '';
if (baseSeed) {
repeatCnt = 1;
+1
View File
@@ -100,6 +100,7 @@ export interface BattleSimJobPayload extends BattleSimRequestPayload {
unitSet: UnitSetDefinition;
config: WarEngineConfig;
time: WarTimeContext;
scenarioEffect?: string | null;
}
export interface BattleSimLogBuckets {
+9
View File
@@ -1,6 +1,7 @@
import { z } from 'zod';
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
import type { DatabaseClient as InfraDatabaseClient, RedisConnector, GamePrisma } from '@sammo-ts/infra';
import { normalizeScenarioEffect, SCENARIO_EFFECT_KEYS } from '@sammo-ts/logic';
import type { TurnDaemonTransport } from './daemon/transport.js';
import type { BattleSimTransport } from './battleSim/transport.js';
@@ -25,6 +26,14 @@ export const zWorldStateConfig = z.object({
extendedGeneral: z.boolean().optional(),
turnTermMinutes: z.number().optional(),
syncTurnTime: z.boolean().optional(),
environment: z
.object({
scenarioEffect: z
.union([z.enum(SCENARIO_EFFECT_KEYS), z.literal(''), z.literal('None'), z.null()])
.transform(normalizeScenarioEffect)
.optional(),
})
.optional(),
});
export type WorldStateConfig = z.infer<typeof zWorldStateConfig>;
@@ -9,6 +9,7 @@ const buildPayload = (action: BattleSimJobPayload['action']): BattleSimJobPayloa
year: 200,
month: 1,
seed: 'test-seed',
scenarioEffect: null,
attackerGeneral: {
no: 1,
name: 'Attacker',
@@ -250,6 +251,14 @@ describe('battle sim processor', () => {
expect(result.lastWarLog?.generalActionLog).toContain('퇴각했습니다.');
});
it('treats a queued job from before scenarioEffect existed as the no-effect baseline', () => {
const baselinePayload = buildPayload('battle');
const legacyPayload = buildPayload('battle');
delete legacyPayload.scenarioEffect;
expect(processBattleSimJob(legacyPayload)).toEqual(processBattleSimJob(baselinePayload));
});
it('returns the fixed defender ID order for reorder action', () => {
const payload = buildPayload('reorder');
const result = processBattleSimJob(payload);
@@ -281,4 +290,53 @@ describe('battle sim processor', () => {
expect(() => processBattleSimJob(payload)).toThrow('Unknown crew type action');
});
it('applies StrongAttacker to general combat in the server-enriched simulator job', () => {
const baseline = processBattleSimJob(buildPayload('battle'));
const payload = buildPayload('battle');
payload.scenarioEffect = 'event_StrongAttacker';
const strong = processBattleSimJob(payload);
expect(strong.killed).toBeGreaterThan(baseline.killed ?? 0);
});
it('keeps StrongAttacker city combat identical but applies MoreEffect to it', () => {
const baselinePayload = buildPayload('battle');
baselinePayload.defenderGenerals = [];
const baseline = processBattleSimJob(baselinePayload);
const strongPayload = buildPayload('battle');
strongPayload.defenderGenerals = [];
strongPayload.scenarioEffect = 'event_StrongAttacker';
expect(processBattleSimJob(strongPayload)).toEqual(baseline);
const morePayload = buildPayload('battle');
morePayload.defenderGenerals = [];
morePayload.scenarioEffect = 'event_MoreEffect';
const more = processBattleSimJob(morePayload);
expect(more.dead).toBeLessThan(baseline.dead ?? Number.POSITIVE_INFINITY);
});
it('fails fast for an unknown server-derived scenario effect', () => {
const payload = buildPayload('battle');
payload.scenarioEffect = 'event_Missing';
expect(() => processBattleSimJob(payload)).toThrow('Unknown scenario effect: event_Missing');
});
it('runs the advance trigger when a progressed attacker meets the next fresh defender', () => {
const payload = buildPayload('battle');
payload.scenarioEffect = 'event_StrongAttacker';
payload.defenderGenerals[0]!.crew = 100;
payload.defenderGenerals.push({
...payload.defenderGenerals[0]!,
no: 3,
name: 'Next Defender',
crew: 1000,
});
const result = processBattleSimJob(payload);
expect(result.lastWarLog?.generalBattleDetailLog).toContain(
'적군의 전멸에 <font color=cyan>진격</font>이 이어집니다!'
);
});
});
+44 -1
View File
@@ -265,7 +265,7 @@ describe('battle router orchestration', () => {
currentYear: 200,
currentMonth: 1,
tickSeconds: 600,
config: {},
config: { environment: { scenarioEffect: 'event_MoreEffect' } },
meta: {},
updatedAt: new Date('2026-01-01T00:00:00Z'),
};
@@ -275,6 +275,7 @@ describe('battle router orchestration', () => {
expect(response.status).toBe('queued');
expect(battleSim.simulateCalls).toBe(1);
expect(battleSim.lastRequesterUserId).toBe('user-1');
expect(battleSim.lastPayload?.scenarioEffect).toBe('event_MoreEffect');
const queued = await caller.battle.getSimulation({ jobId: response.jobId });
expect(queued.status).toBe('queued');
@@ -286,6 +287,48 @@ describe('battle router orchestration', () => {
expect(completed.payload?.result).toBe(true);
});
it('uses the stored scenario effect even when a client sends a same-named field', async () => {
const battleSim = new QueuedBattleSimTransport();
const state: WorldStateRow = {
id: 1,
scenarioCode: 'default',
currentYear: 200,
currentMonth: 1,
tickSeconds: 600,
config: { environment: { scenarioEffect: 'event_MoreEffect' } },
meta: {},
updatedAt: new Date('2026-01-01T00:00:00Z'),
};
const caller = appRouter.createCaller(buildContext({ state, battleSim }));
const maliciousRequest = {
...buildBattleRequest(),
scenarioEffect: 'event_StrongAttacker',
} as ReturnType<typeof buildBattleRequest>;
await expect(caller.battle.simulate(maliciousRequest)).resolves.toMatchObject({ status: 'queued' });
expect(battleSim.lastPayload?.scenarioEffect).toBe('event_MoreEffect');
});
it('rejects an unknown stored scenario effect before queuing the simulation', async () => {
const battleSim = new QueuedBattleSimTransport();
const state: WorldStateRow = {
id: 1,
scenarioCode: 'default',
currentYear: 200,
currentMonth: 1,
tickSeconds: 600,
config: { environment: { scenarioEffect: 'event_Missing' } },
meta: {},
updatedAt: new Date('2026-01-01T00:00:00Z'),
};
const caller = appRouter.createCaller(buildContext({ state, battleSim }));
await expect(caller.battle.simulate(buildBattleRequest())).rejects.toThrow(
'Unknown scenario effect: event_Missing'
);
expect(battleSim.simulateCalls).toBe(0);
});
it('requires login, allows a user without a general, and does not open an input-event transaction', async () => {
const battleSim = new QueuedBattleSimTransport();
const state: WorldStateRow = {
@@ -51,6 +51,7 @@ liveDescribe('battle simulator worker with live Redis', () => {
unitSet: environment.unitSet,
config: environment.config,
time: { year: request.year, month: request.month, startYear },
scenarioEffect: environment.scenarioEffect,
};
const clientConnector = createRedisConnector(resolveRedisConfigFromEnv());
+48 -2
View File
@@ -350,7 +350,14 @@ describe('appRouter', () => {
currentYear: 1,
currentMonth: 2,
tickSeconds: 600,
config: { maxUserCnt: 500, hiddenSeed: 'config-secret' },
config: {
maxUserCnt: 500,
hiddenSeed: 'config-secret',
environment: {
scenarioEffect: 'event_StrongAttacker',
hiddenSeed: 'environment-secret',
},
},
meta: { otherTextInfo: 'sample', hiddenSeed: 'meta-secret' },
updatedAt: new Date('2026-01-01T00:00:00Z'),
};
@@ -360,11 +367,50 @@ describe('appRouter', () => {
expect(response?.scenarioCode).toBe('default');
expect(response?.currentYear).toBe(1);
expect(response?.config).toEqual({ maxUserCnt: 500 });
expect(response?.config).toEqual({
maxUserCnt: 500,
environment: { scenarioEffect: 'event_StrongAttacker' },
});
expect(response?.meta).toEqual({ otherTextInfo: 'sample' });
expect(response?.updatedAt).toBe('2026-01-01T00:00:00.000Z');
});
it.each(['', 'None', null])('normalizes the persisted no-effect sentinel %j in world snapshots', async (value) => {
const state: WorldStateRow = {
id: 1,
scenarioCode: 'default',
currentYear: 1,
currentMonth: 2,
tickSeconds: 600,
config: { environment: { scenarioEffect: value } },
meta: {},
updatedAt: new Date('2026-01-01T00:00:00Z'),
};
const caller = appRouter.createCaller(buildContext({ state }));
await expect(caller.world.getState()).resolves.toMatchObject({
config: { environment: { scenarioEffect: null } },
});
});
it('rejects unknown persisted scenario effects in world snapshots', async () => {
const state: WorldStateRow = {
id: 1,
scenarioCode: 'default',
currentYear: 1,
currentMonth: 2,
tickSeconds: 600,
config: { environment: { scenarioEffect: 'event_Missing' } },
meta: {},
updatedAt: new Date('2026-01-01T00:00:00Z'),
};
const caller = appRouter.createCaller(buildContext({ state }));
await expect(caller.world.getState()).rejects.toThrow();
});
it('requires profile administration permission for turn daemon control', async () => {
const auth: GameSessionTokenPayload = {
version: 1,
@@ -81,6 +81,7 @@ export const buildCommandEnv = (config: ScenarioConfig, unitSet?: UnitSetDefinit
return {
...(unitSet ? { unitSet } : {}),
scenarioEffect: config.environment.scenarioEffect ?? null,
develCost: resolveNumber(constValues, ['develCost', 'develcost', 'develrate'], 0),
minAvailableRecruitPop: resolveNumber(constValues, ['minAvailableRecruitPop'], 30000),
trainDelta: resolveNumber(constValues, ['trainDelta'], DEFAULT_TRAIN_DELTA),
@@ -169,7 +170,7 @@ export const buildReservedTurnDefinitions = async (options: {
general: Map<string, GeneralActionDefinition>;
nation: Map<string, GeneralActionDefinition>;
}> => {
const moduleBundle = await loadActionModuleBundle(options.env.unitSet);
const moduleBundle = await loadActionModuleBundle(options.env.unitSet, options.env.scenarioEffect);
const itemModules = moduleBundle.itemModules;
options.env.itemCatalog = Object.fromEntries(
itemModules.map((item) => [
+4 -1
View File
@@ -219,7 +219,10 @@ const createTurnDaemonRuntimeWithLease = async (
let redisConnector: ReturnType<typeof createRedisConnector> | null = null;
const nationTraits = await loadNationTraitModules([...NATION_TRAIT_KEYS], new NationTraitLoader());
const nationTraitMap = new Map(nationTraits.map((module) => [module.key, module]));
const monthlyActionModules = await loadActionModuleBundle(snapshot.unitSet);
const monthlyActionModules = await loadActionModuleBundle(
snapshot.unitSet,
snapshot.scenarioConfig.environment.scenarioEffect
);
const monthlyCommandEnv = buildCommandEnv(snapshot.scenarioConfig, snapshot.unitSet);
const unification = options.calendarHandler
? null
@@ -15,6 +15,7 @@ import {
buildVoteUniqueSeed,
countOccupiedUniqueItems,
createItemModuleRegistry,
isDefenceTrainPenaltyWaivedByScenarioEffect,
isValidTroopNameWidth,
loadItemModules,
normalizeTroopName,
@@ -948,10 +949,7 @@ async function handleSetMySetting(
nextMeta.defence_train = nextDefenceTrain;
if (nextDefenceTrain === 999) {
const scenarioEffect = world.getScenarioConfig().environment.scenarioEffect;
const ignoresPenalty =
scenarioEffect === 'event_UnlimitedDefenceThresholdChange' ||
scenarioEffect === 'event_StrongAttacker' ||
scenarioEffect === 'event_MoreEffect';
const ignoresPenalty = isDefenceTrainPenaltyWaivedByScenarioEffect(scenarioEffect);
const constValues = asRecord(world.getScenarioConfig().const);
const maxTrain = readMetaNumber(constValues, 'maxTrainByWar', 100);
const maxAtmos = readMetaNumber(constValues, 'maxAtmosByWar', 100);
+22 -2
View File
@@ -21,6 +21,7 @@ import type {
Troop,
TriggerValue,
} from '@sammo-ts/logic';
import { normalizeScenarioEffect } from '@sammo-ts/logic';
import { projectItemSlots, readItemInventoryFromMeta } from '@sammo-ts/logic/items/index.js';
import { z } from 'zod';
import { asRecord, isRecord } from '@sammo-ts/common';
@@ -90,7 +91,20 @@ const zScenarioStatBlock = z.object({
const zScenarioEnvironment = z.object({
mapName: z.string(),
unitSet: z.string(),
scenarioEffect: z.union([z.string(), z.null()]).optional(),
scenarioEffect: z
.union([z.string(), z.null()])
.optional()
.refine(
(value) => {
try {
normalizeScenarioEffect(value);
return true;
} catch {
return false;
}
},
{ message: 'Unknown scenario effect' }
),
});
const zScenarioConfig = z.object({
@@ -155,7 +169,13 @@ const mapScenarioConfig = (raw: JsonValue): ScenarioConfig => {
if (!parsed.success) {
throw new Error(`world_state.config is invalid: ${parsed.error.message}`);
}
return parsed.data;
return {
...parsed.data,
environment: {
...parsed.data.environment,
scenarioEffect: normalizeScenarioEffect(parsed.data.environment.scenarioEffect),
},
};
};
const mapGeneralRow = (
+30 -3
View File
@@ -108,6 +108,33 @@ const unitSet: UnitSetDefinition = {
};
describe('reserved turn crew type wiring', () => {
it('derives scenario modules from the stored scenario config and keeps them before items', async () => {
const env = buildCommandEnv(
{
...scenarioConfig,
environment: {
...scenarioConfig.environment,
scenarioEffect: 'event_MoreEffect',
},
},
unitSet
);
await buildReservedTurnDefinitions({
env,
commandProfile: { general: ['휴식'], nation: ['휴식'] },
defaultActionKey: '휴식',
});
expect(env.scenarioEffect).toBe('event_MoreEffect');
const scenarioGeneral = env.generalActionModules?.at(-2);
const scenarioWar = env.warActionModules?.at(-2);
const attacker = { isAttacker: () => true } as unknown as WarUnitGeneral;
const defender = { isAttacker: () => false } as unknown as WarUnitGeneral;
expect(scenarioGeneral?.onCalcDomestic?.({ general }, '상업', 'score', 10)).toBe(20);
expect(scenarioWar?.getWarPowerMultiplier?.({ general }, attacker, defender)).toEqual([1.4, 0.7143]);
});
it('installs the crew action router before inherit and item handlers', async () => {
const env = buildCommandEnv(scenarioConfig, unitSet);
@@ -221,9 +248,9 @@ describe('reserved turn crew type wiring', () => {
100,
100
);
expect(
warPipeline.getWarPowerMultiplier(attacker.getActionContext(), attacker, defender)
).toEqual([1.284, 0.93]);
expect(warPipeline.getWarPowerMultiplier(attacker.getActionContext(), attacker, defender)).toEqual([
1.284, 0.93,
]);
expect(warPipeline.onCalcStat(attacker.getActionContext(), 'bonusTrain', 100)).toBe(105);
});
});
@@ -198,7 +198,7 @@ describe('core monthly event actions at the real month boundary', () => {
]);
});
it('uses the ProcessIncome resource argument instead of inferring it from the month', async () => {
it('uses the ProcessIncome resource argument and keeps MoreEffect income dormant like ref', async () => {
const world = buildWorld(
[
{
@@ -226,7 +226,11 @@ describe('core monthly event actions at the real month boundary', () => {
iconPath: '',
map: {},
const: { baseGold: 0, baseRice: 0 },
environment: { mapName: map.id, unitSet: 'default' },
environment: {
mapName: map.id,
unitSet: 'default',
scenarioEffect: 'event_MoreEffect',
},
},
nationTraits: new Map(),
});
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest';
import type { TurnSchedule } from '@sammo-ts/logic';
import type { ScenarioEffectKey, TurnSchedule } from '@sammo-ts/logic';
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
@@ -51,7 +51,7 @@ const buildGeneral = (overrides: Partial<TurnGeneral> = {}): TurnGeneral => ({
const buildWorld = (
general = buildGeneral(),
options: { autorunLimit?: boolean; scenarioEffect?: string | null } = {}
options: { autorunLimit?: boolean; scenarioEffect?: ScenarioEffectKey | null } = {}
) => {
const state: TurnWorldState = {
id: 1,
@@ -143,15 +143,22 @@ describe('my information world commands', () => {
});
});
it('preserves the event scenarios that waive the no-defence penalty', async () => {
const fixture = buildWorld(buildGeneral(), { scenarioEffect: 'event_StrongAttacker' });
await fixture.handler.handle({
type: 'setMySetting',
generalId: 7,
settings: { defence_train: 999 },
});
expect(fixture.world.getGeneralById(7)).toMatchObject({ train: 90, atmos: 90 });
});
it.each([
'event_UnlimitedDefenceThresholdChange',
'event_StrongAttacker',
'event_MoreEffect',
] satisfies ScenarioEffectKey[])(
'preserves the %s scenario that waives the no-defence penalty',
async (scenarioEffect) => {
const fixture = buildWorld(buildGeneral(), { scenarioEffect });
await fixture.handler.handle({
type: 'setMySetting',
generalId: 7,
settings: { defence_train: 999 },
});
expect(fixture.world.getGeneralById(7)).toMatchObject({ train: 90, atmos: 90 });
}
);
it('applies vacation killturn and rejects it in automatic-turn mode', async () => {
const allowed = buildWorld();
+20 -9
View File
@@ -50,15 +50,7 @@ type ScenarioSeederPrismaClient = {
};
};
const requiredTables = [
'world_state',
'nation',
'city',
'general',
'diplomacy',
'troop',
'event',
];
const requiredTables = ['world_state', 'nation', 'city', 'general', 'diplomacy', 'troop', 'event'];
const hasRequiredTables = async (prisma: ScenarioSeederPrismaClient, schemaName: string): Promise<boolean> => {
for (const table of requiredTables) {
@@ -243,6 +235,25 @@ describeDb('scenario database seed', () => {
await connector.disconnect();
}
});
test('persists a tracked scenario effect in the world configuration', async () => {
await seedScenarioToDatabase({
scenarioId: 906,
databaseUrl,
});
const connector = createGamePostgresConnector({ url: databaseUrl });
await connector.connect();
try {
const prisma = connector.prisma as unknown as ScenarioSeederPrismaClient;
const worldState = await prisma.worldState.findFirst();
const config = (worldState?.config ?? {}) as Record<string, unknown>;
const environment = (config.environment ?? {}) as Record<string, unknown>;
expect(environment.scenarioEffect).toBe('event_StrongAttacker');
} finally {
await connector.disconnect();
}
});
});
describe('tracked scenario composition', () => {
+68 -6
View File
@@ -22,6 +22,7 @@ const persistParityArtifact = async (page: Page, name: string, geometry: unknown
type FixtureState = {
permission: 'head' | 'member';
myset: number;
scenarioEffect?: string | null;
settingMutations: Array<Record<string, unknown>>;
accessPages: string[];
};
@@ -154,7 +155,11 @@ const install = async (page: Page, state: FixtureState) => {
currentYear: 185,
currentMonth: 1,
tickSeconds: 600,
config: { npcMode: 0, const: { availableInstantAction: {} } },
config: {
npcMode: 0,
const: { availableInstantAction: {} },
environment: { scenarioEffect: state.scenarioEffect ?? null },
},
meta: {
turntime: '2026-01-01T00:00:00.000Z',
opentime: '2025-12-01T00:00:00.000Z',
@@ -266,11 +271,20 @@ test('접속량정보 keeps the legacy public 1016px chart geometry', async ({ p
test('내 정보&설정 keeps the legacy 1000px/500px geometry and saves in place', async ({ page }) => {
const state: FixtureState = { permission: 'head', myset: 3, settingMutations: [], accessPages: [] };
await install(page, state);
await page.setViewportSize({ width: 1200, height: 900 });
await page.setViewportSize({ width: 1000, height: 900 });
await page.goto('my-page');
await expect(page.locator('.title-row')).toContainText('내 정 보');
await expect(page.locator('#set_my_setting')).toBeVisible();
await expect.poll(() => state.accessPages).toContain('my-page');
const noDefenceOption = page.locator('option[value="999"]');
await expect(noDefenceOption).toHaveText('× [훈련 -3,사기 -6]');
await expect(page.locator('#defence_train option')).toHaveText([
'☆(훈사90)',
'◎(훈사80)',
'○(훈사60)',
'△(훈사40)',
'× [훈련 -3,사기 -6]',
]);
const desktop = await page.locator('#container').evaluate((element) => {
const rect = element.getBoundingClientRect();
@@ -311,14 +325,62 @@ test('내 정보&설정 keeps the legacy 1000px/500px geometry and saves in plac
expect(desktop.sectionBackgroundImage).toContain('back_green.jpg');
await persistParityArtifact(page, 'core-my-page-desktop', desktop);
await page
.locator('select')
.filter({ has: page.locator('option[value="999"]') })
.selectOption('999');
const defenceSelect = page.locator('select').filter({ has: page.locator('option[value="999"]') });
await defenceSelect.selectOption('999');
const noEffectState = await defenceSelect.evaluate((element) => {
const rect = element.getBoundingClientRect();
const style = getComputedStyle(element);
return {
scenarioEffect: null,
optionText: element.querySelector<HTMLOptionElement>('option[value="999"]')?.textContent,
selectedText: element.querySelector<HTMLOptionElement>('option:checked')?.textContent,
rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
fontSize: style.fontSize,
lineHeight: style.lineHeight,
color: style.color,
backgroundColor: style.backgroundColor,
};
});
expect(noEffectState.rect.width).toBe(134);
expect(noEffectState.rect.height).toBe(20);
await persistParityArtifact(page, 'core-my-page-no-effect-999', noEffectState);
await page.locator('#set_my_setting').click();
await expect.poll(() => state.settingMutations.length).toBe(1);
expect(state.settingMutations[0]).not.toHaveProperty('generalId');
for (const [effectIndex, scenarioEffect] of [
'event_UnlimitedDefenceThresholdChange',
'event_StrongAttacker',
'event_MoreEffect',
].entries()) {
state.scenarioEffect = scenarioEffect;
state.myset = 1;
await page.reload();
await expect(noDefenceOption).toHaveText('×');
await defenceSelect.selectOption('999');
const effectState = await defenceSelect.evaluate((element) => {
const rect = element.getBoundingClientRect();
const style = getComputedStyle(element);
return {
optionText: element.querySelector<HTMLOptionElement>('option[value="999"]')?.textContent,
selectedText: element.querySelector<HTMLOptionElement>('option:checked')?.textContent,
rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
fontSize: style.fontSize,
lineHeight: style.lineHeight,
color: style.color,
backgroundColor: style.backgroundColor,
};
});
expect(effectState.optionText).toBe('×');
expect(effectState.selectedText).toBe('×');
expect(effectState.rect.width).toBe(86);
expect(effectState.rect.height).toBe(20);
await persistParityArtifact(page, `core-my-page-${scenarioEffect}`, effectState);
await page.locator('#set_my_setting').click();
await expect.poll(() => state.settingMutations.length).toBe(effectIndex + 2);
expect(state.settingMutations.at(-1)).not.toHaveProperty('generalId');
}
await page.setViewportSize({ width: 500, height: 900 });
await page.reload();
const mobile = await page.locator('#container').evaluate((element) => {
+24 -6
View File
@@ -2,6 +2,7 @@
import { computed, onMounted, reactive, ref, watch } from 'vue';
import { trpc } from '../utils/trpc';
import { formatLog } from '../utils/formatLog';
import { isDefenceTrainPenaltyWaivedByScenarioEffect } from '@sammo-ts/logic';
const SCREEN_MODE_KEY = 'sam.screenMode';
const CUSTOM_CSS_KEY = 'sam_customCSS';
@@ -95,6 +96,13 @@ const statusLine = computed(() =>
const canSave = computed(() => (data.value?.settings.myset ?? 1) > 0);
const penalties = computed(() => Object.entries(data.value?.penalties ?? {}));
const noDefencePenaltyWaived = computed(() => {
const environment = asRecord(world.value?.config.environment);
return isDefenceTrainPenaltyWaivedByScenarioEffect(
typeof environment.scenarioEffect === 'string' ? environment.scenarioEffect : null
);
});
const noDefenceLabel = computed(() => (noDefencePenaltyWaived.value ? '×' : '× [훈련 -3,사기 -6]'));
const items = computed<Array<{ key: ItemSlotKey; name: string; code: string | null }>>(() => [
{ key: 'horse', name: '말', code: data.value?.general.items.horse ?? null },
{ key: 'weapon', name: '무기', code: data.value?.general.items.weapon ?? null },
@@ -319,12 +327,16 @@ onMounted(() => {
<label class="setting-line">
수비
<select v-model.number="form.defence_train">
<option :value="90">수비 (훈사90)</option>
<option :value="80">수비 (훈사80)</option>
<option :value="60">수비 (훈사60)</option>
<option :value="40">수비 (훈사40)</option>
<option :value="999">수비 안함 [훈련 -3, 사기 -6]</option>
<select
id="defence_train"
v-model.number="form.defence_train"
:class="{ 'penalty-waived': noDefencePenaltyWaived }"
>
<option :value="90">(훈사90)</option>
<option :value="80">(훈사80)</option>
<option :value="60">(훈사60)</option>
<option :value="40">(훈사40)</option>
<option :value="999">{{ noDefenceLabel }}</option>
</select>
</label>
@@ -584,6 +596,12 @@ dt {
display: block;
margin-top: 5px;
}
#defence_train {
width: 134px;
}
#defence_train.penalty-waived {
width: 86px;
}
.hint {
margin: 0 0 13px;
color: orange;
+30 -3
View File
@@ -34,9 +34,36 @@ priority trigger를 의미 이벤트로 바꾸거나, 의미 이벤트를 `Trigg
`RefOrderedActionStack`의 readonly unique-symbol brand는 임의 배열을 제품용
표준 stack으로 오인하지 않게 하는 shadow type입니다. 모든 slot을 명시하는
factory에서만 이 brand를 만들 수 있으며, 예약턴 runtime env에도 spread하지
않고 그대로 전달합니다. 현재 시나리오 효과 runtime module은 이식되지 않아
해당 slot은 명시적으로 `null`입니다. 따라서 이 brand는 순서와 slot 소유권을
증명하며, 시나리오 효과 구현 완료를 뜻하지 않습니다.
않고 그대로 전달합니다. `scenarioEffect`가 없으면 scenario slot은
`null`이며, 지원하는 값이면 `createScenarioEffectActionModules()`
general·war module을 생성합니다. 표준 순서 테스트는
`inheritance → scenario → items`를 포함한 아홉 slot을 직접 검증합니다.
## 시나리오 효과
`SCENARIO_EFFECT_KEYS`가 저장·실행 가능한 효과의 단일 registry입니다.
scenario parser, resource schema, PostgreSQL world loader와 battle simulator
환경은 이 registry로 값을 정규화하며 알 수 없는 값은 실행 전에 거부합니다.
| 효과 | General hook | War hook |
| --------------------------------------- | ------------------------------------------------- | ------------------------------------------------------------------- |
| `event_UnlimitedDefenceThresholdChange` | 무수비 설정의 훈련·사기 penalty를 0으로 만듭니다. | 없음 |
| `event_StrongAttacker` | 같은 penalty를 0으로 만듭니다. | 장수전 공격측 `1.4`, 상대 `0.7143`; 성벽전 제외; 전멸 뒤 진격 phase |
| `event_MoreEffect` | penalty 제거, 8개 내정 score를 2배로 만듭니다. | 성벽전을 포함한 공격측 배율과 전멸 뒤 진격 phase |
진격 trigger는 진행한 unit이 phase 0인 새 상대를 만날 때 bonus phase를
정확히 1 추가합니다. trigger 자체는 RNG를 소비하지 않으며, 추가 phase가
이후 전투 RNG를 정상적으로 더 소비합니다.
`event_MoreEffect::onCalcNationalIncome()`은 ref class에 존재하지만 실제
월간 수입 entry point는 General action list가 아니라 nation type hook만
호출합니다. Core도 protocol hook은 보존하되 월간 수입 경로에는 연결하지
않습니다.
전투 시뮬레이터의 효과는 공개 request가 아니라 저장된 world config에서
서버가 파생합니다. 내부 queue payload의 필드는 optional이므로 배포 전에
생성된 payload는 효과 없음으로 처리하며, 신·구 API/worker 혼재 시에는
효과 누락을 피하기 위해 queue를 비우거나 API와 worker를 함께 재시작합니다.
## 닫힌 의미 이벤트
+7 -5
View File
@@ -27,6 +27,8 @@ import {
WAR_TRAIT_KEYS,
} from './traits/index.js';
import type { NationTraitModule } from './traits/nation/index.js';
import { createScenarioEffectActionModules } from './scenarioEffect.js';
import type { ScenarioEffectKey } from '@sammo-ts/logic/scenario/scenarioEffect.js';
export interface ActionModuleBundle<TriggerState extends GeneralTriggerState = GeneralTriggerState> {
general: RefOrderedActionStack<GeneralActionModule<TriggerState>>;
@@ -82,7 +84,8 @@ export const createRefOrderedActionStack = <Module>(slots: RefActionSlots<Module
// General::getActionList와 같은 소유권 순서로 실제 턴과 시뮬레이터의 모듈을 조립한다.
export const loadActionModuleBundle = async <TriggerState extends GeneralTriggerState = GeneralTriggerState>(
unitSet?: UnitSetDefinition
unitSet?: UnitSetDefinition,
scenarioEffect?: ScenarioEffectKey | null
): Promise<ActionModuleBundle<TriggerState>> => {
const [domestic, war, personality, nation, itemModules] = await Promise.all([
loadDomesticTraitModules([...DOMESTIC_TRAIT_KEYS]),
@@ -95,6 +98,7 @@ export const loadActionModuleBundle = async <TriggerState extends GeneralTrigger
const officer = createOfficerLevelActionModules<TriggerState>();
const items = createItemActionModules(createItemModuleRegistry(itemModules));
const inherit = createInheritBuffModules();
const scenario = createScenarioEffectActionModules<TriggerState>(scenarioEffect);
const crewTypeCatalog = unitSet?.crewTypes?.length
? compileCrewTypeCatalog(unitSet, createCrewTypeWarTriggerRegistry())
: null;
@@ -110,8 +114,7 @@ export const loadActionModuleBundle = async <TriggerState extends GeneralTrigger
? (crewTypeCatalog.generalActionModule as GeneralActionModule<TriggerState>)
: null,
inheritance: inherit.general as GeneralActionModule<TriggerState>,
// scenarioEffect는 현재 core runtime module이 없어 명시적으로 빈 slot입니다.
scenario: null,
scenario: scenario.general,
items: items.general,
}),
war: createRefOrderedActionStack<WarActionModule<TriggerState>>({
@@ -122,8 +125,7 @@ export const loadActionModuleBundle = async <TriggerState extends GeneralTrigger
personality: new TraitWarActionRouter('personality', traitCatalog),
crewType: crewTypeCatalog ? (crewTypeCatalog.warActionModule as WarActionModule<TriggerState>) : null,
inheritance: inherit.war as WarActionModule<TriggerState>,
// ref의 scenarioEffect 위치를 보존하되 미이식 module은 별도 gap으로 남깁니다.
scenario: null,
scenario: scenario.war,
items: items.war,
}),
itemModules,
@@ -3,4 +3,5 @@ export * from './general.js';
export * from './types.js';
export * from './officerLevel.js';
export * from './bundle.js';
export * from './scenarioEffect.js';
export * from './traits/index.js';
@@ -0,0 +1,84 @@
import type { GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js';
import type { GeneralActionModule } from '@sammo-ts/logic/actionModules/general.js';
import type { WarActionModule } from '@sammo-ts/logic/war/actions.js';
import { WarTriggerCaller } from '@sammo-ts/logic/war/triggers.js';
import { che_전멸시페이즈증가 } from '@sammo-ts/logic/war/triggers/che_전멸시페이즈증가.js';
import { WarUnitCity } from '@sammo-ts/logic/war/units.js';
import { normalizeScenarioEffect } from '@sammo-ts/logic/scenario/scenarioEffect.js';
export type { ScenarioEffectKey } from '@sammo-ts/logic/scenario/scenarioEffect.js';
export interface ScenarioEffectActionModules<TriggerState extends GeneralTriggerState = GeneralTriggerState> {
general: GeneralActionModule<TriggerState> | null;
war: WarActionModule<TriggerState> | null;
}
const MORE_EFFECT_DOMESTIC_ACTIONS = new Set(['상업', '농업', '치안', '기술', '성벽', '수비', '인구', '민심']);
const createDefenceThresholdGeneralModule = <
TriggerState extends GeneralTriggerState,
>(): GeneralActionModule<TriggerState> => ({
onCalcDomestic: (_context, turnType, _varType, value) => (turnType === 'changeDefenceTrain' ? 0 : value),
});
const createAdvanceTriggerWarModule = <TriggerState extends GeneralTriggerState>(
includeCityWarPower: boolean
): WarActionModule<TriggerState> => ({
getWarPowerMultiplier: (_context, unit, oppose) => {
if (!includeCityWarPower && (unit instanceof WarUnitCity || oppose instanceof WarUnitCity)) {
return [1, 1];
}
return unit.isAttacker() ? [1.4, 0.7143] : [1, 1];
},
getBattlePhaseTriggerList: (context) => {
const unit = context.unit;
return unit ? new WarTriggerCaller(new che_전멸시페이즈증가(unit)) : null;
},
});
const createMoreEffectGeneralModule = <
TriggerState extends GeneralTriggerState,
>(): GeneralActionModule<TriggerState> => ({
onCalcDomestic: (_context, turnType, varType, value) => {
if (turnType === 'changeDefenceTrain') {
return 0;
}
return varType === 'score' && MORE_EFFECT_DOMESTIC_ACTIONS.has(turnType) ? value * 2 : value;
},
// ref에도 정의되어 있지만 실제 월간 수입 경로는 General이 아니라
// nation type module만 호출합니다. protocol 보존용이며 월간 경로에는
// 이 general hook을 연결하지 않습니다.
onCalcNationalIncome: (_context, type, amount) => {
if (type === 'gold' || type === 'rice' || (type === 'pop' && amount > 0)) {
return amount * 2;
}
return amount;
},
});
export const createScenarioEffectActionModules = <TriggerState extends GeneralTriggerState = GeneralTriggerState>(
scenarioEffect?: string | null
): ScenarioEffectActionModules<TriggerState> => {
const normalizedEffect = normalizeScenarioEffect(scenarioEffect);
if (!normalizedEffect) {
return { general: null, war: null };
}
switch (normalizedEffect) {
case 'event_UnlimitedDefenceThresholdChange':
return {
general: createDefenceThresholdGeneralModule<TriggerState>(),
war: null,
};
case 'event_StrongAttacker':
return {
general: createDefenceThresholdGeneralModule<TriggerState>(),
war: createAdvanceTriggerWarModule<TriggerState>(false),
};
case 'event_MoreEffect':
return {
general: createMoreEffectGeneralModule<TriggerState>(),
war: createAdvanceTriggerWarModule<TriggerState>(true),
};
}
};
+2 -1
View File
@@ -13,7 +13,8 @@ export type TriggerDomesticActionType =
| '기술'
| '모병'
| '단련'
| '조달';
| '조달'
| 'changeDefenceTrain';
export type TriggerDomesticVarType = 'cost' | 'score' | 'success' | 'fail' | 'train' | 'atmos' | 'rice' | 'probability';
@@ -3,6 +3,7 @@ import type { WarActionModule } from '@sammo-ts/logic/war/actions.js';
import type { UnitSetDefinition } from '@sammo-ts/logic/world/types.js';
import type { NationTraitModule } from '@sammo-ts/logic/actionModules/traits/nation/index.js';
import type { RefOrderedActionStack } from '@sammo-ts/logic/actionModules/bundle.js';
import type { ScenarioEffectKey } from '@sammo-ts/logic/scenario/scenarioEffect.js';
export interface TurnCommandItemCatalogEntry {
slot: 'horse' | 'weapon' | 'book' | 'item';
@@ -17,6 +18,7 @@ export interface TurnCommandItemCatalogEntry {
export interface TurnCommandEnv {
unitSet?: UnitSetDefinition;
scenarioEffect?: ScenarioEffectKey | null;
develCost: number;
minAvailableRecruitPop?: number;
trainDelta: number;
@@ -1,4 +1,5 @@
import { z } from 'zod';
import { SCENARIO_EFFECT_KEYS } from '../scenario/scenarioEffect.js';
export const ScenarioStatBlockSchema = z
.object({
@@ -19,6 +20,12 @@ export const ScenarioDefaultsInputSchema = z.object({
export const ScenarioExtendsInputSchema = z.union([z.string().min(1), z.array(z.string().min(1)).min(1)]);
const ScenarioConstInputSchema = z
.object({
scenarioEffect: z.union([z.enum(SCENARIO_EFFECT_KEYS), z.literal(''), z.literal('None'), z.null()]).optional(),
})
.catchall(z.unknown());
const ScenarioBodyInputSchema = z
.object({
extends: ScenarioExtendsInputSchema.optional(),
@@ -29,7 +36,7 @@ const ScenarioBodyInputSchema = z
iconPath: z.string().optional(),
stat: ScenarioStatBlockSchema.optional(),
map: z.record(z.string(), z.unknown()).optional(),
const: z.record(z.string(), z.unknown()).optional(),
const: ScenarioConstInputSchema.optional(),
nation: z.array(z.unknown()).optional(),
diplomacy: z.array(z.unknown()).optional(),
general: z.array(z.unknown()).optional(),
+1
View File
@@ -1,2 +1,3 @@
export * from './types.js';
export * from './parseScenario.js';
export * from './scenarioEffect.js';
+3 -5
View File
@@ -13,6 +13,7 @@ import type {
ScenarioNation,
ScenarioStatBlock,
} from './types.js';
import { normalizeScenarioEffect } from './scenarioEffect.js';
type UnknownRecord = Record<string, unknown>;
@@ -45,12 +46,9 @@ const parseScenarioEnvironment = (mapConfig: UnknownRecord, constConfig: Unknown
const merged = { ...mapConfig, ...constConfig };
const mapName = asString(merged.mapName, 'che');
const unitSet = asString(merged.unitSet, 'che');
const scenarioEffect =
typeof merged.scenarioEffect === 'string' || merged.scenarioEffect === null ? merged.scenarioEffect : undefined;
const result: ScenarioEnvironment = { mapName, unitSet };
if (scenarioEffect !== undefined) {
result.scenarioEffect = scenarioEffect;
if (Object.hasOwn(merged, 'scenarioEffect')) {
result.scenarioEffect = normalizeScenarioEffect(merged.scenarioEffect);
}
return result;
};
@@ -0,0 +1,31 @@
export const SCENARIO_EFFECT_KEYS = [
'event_UnlimitedDefenceThresholdChange',
'event_StrongAttacker',
'event_MoreEffect',
] as const;
export type ScenarioEffectKey = (typeof SCENARIO_EFFECT_KEYS)[number];
const DEFENCE_TRAIN_PENALTY_WAIVER_EFFECTS = new Set<ScenarioEffectKey>([
'event_UnlimitedDefenceThresholdChange',
'event_StrongAttacker',
'event_MoreEffect',
]);
export const isScenarioEffectKey = (value: string): value is ScenarioEffectKey =>
SCENARIO_EFFECT_KEYS.includes(value as ScenarioEffectKey);
export const normalizeScenarioEffect = (value: unknown): ScenarioEffectKey | null => {
if (value === undefined || value === null || value === '' || value === 'None') {
return null;
}
if (typeof value === 'string' && isScenarioEffectKey(value)) {
return value;
}
throw new Error(`Unknown scenario effect: ${String(value)}`);
};
export const isDefenceTrainPenaltyWaivedByScenarioEffect = (value: string | null | undefined): boolean => {
const effect = normalizeScenarioEffect(value);
return effect !== null && DEFENCE_TRAIN_PENALTY_WAIVER_EFFECTS.has(effect);
};
+2 -1
View File
@@ -16,7 +16,7 @@ export interface ScenarioDefaults {
export interface ScenarioEnvironment {
mapName: string;
unitSet: string;
scenarioEffect?: string | null;
scenarioEffect?: ScenarioEffectKey | null;
}
export interface ScenarioConfig {
@@ -86,3 +86,4 @@ export interface ScenarioDefinition {
initialEvents: unknown[];
ignoreDefaultEvents: boolean;
}
import type { ScenarioEffectKey } from './scenarioEffect.js';
@@ -0,0 +1,32 @@
import { LogFormat } from '@sammo-ts/logic/logging/types.js';
import { TriggerPriority } from '@sammo-ts/logic/triggers/core.js';
import { BaseWarUnitTrigger } from '@sammo-ts/logic/war/triggers.js';
import type { WarUnit } from '@sammo-ts/logic/war/units.js';
/**
* ref che_전멸시페이즈증가.
*
* 이전 수비자를 격파한 공격자가 phase를 소비한 상태로 새 수비자
* (phase 0)와 맞붙을 때 다음 phase 하나를 보너스로 얻습니다.
*/
export class che_전멸시페이즈증가 extends BaseWarUnitTrigger {
constructor(unit: WarUnit) {
super(unit, TriggerPriority.Post + 800);
}
protected actionWar(
self: WarUnit,
oppose: WarUnit,
_selfEnv: Record<string, unknown>,
_opposeEnv: Record<string, unknown>
): boolean {
if (self.getPhase() === 0 || oppose.getPhase() !== 0) {
return true;
}
self.addBonusPhase(1);
self.getLogger().pushGeneralBattleDetailLog('적군의 전멸에 <C>진격</>이 이어집니다!', LogFormat.PLAIN);
oppose.getLogger().pushGeneralBattleDetailLog('아군의 전멸에 상대의 <R>진격</>이 이어집니다!', LogFormat.PLAIN);
return true;
}
}
@@ -37,7 +37,7 @@ const makeGeneral = (): General => ({
describe('typed general action events', () => {
it('folds synchronous event handlers in the supplied ref ownership order', () => {
const trace: string[] = [];
const names = ['nation', 'officer', 'domestic', 'war', 'personality', 'crew', 'inherit', 'item'];
const names = ['nation', 'officer', 'domestic', 'war', 'personality', 'crew', 'inherit', 'scenario', 'item'];
const modules: GeneralActionModule[] = names.map((name) => ({
eventHandlers: {
'strategy.succeeded': (_context, event) => {
@@ -56,8 +56,8 @@ describe('typed general action events', () => {
personality: modules[4]!,
crewType: modules[5]!,
inheritance: modules[6]!,
scenario: null,
items: [modules[7]!],
scenario: modules[7]!,
items: [modules[8]!],
});
const pipeline = new GeneralActionPipeline(stack);
+122
View File
@@ -0,0 +1,122 @@
import { ConstantRNG, RandUtil } from '@sammo-ts/common';
import { describe, expect, it } from 'vitest';
import { createScenarioEffectActionModules } from '../src/actionModules/scenarioEffect.js';
import type { General } from '../src/domain/entities.js';
import { ActionLogger } from '../src/logging/actionLogger.js';
import { createWarTriggerEnv } from '../src/war/triggers.js';
import type { WarUnit } from '../src/war/units.js';
import { WarUnitCity } from '../src/war/units.js';
const generalContext = { general: {} as General };
const buildGeneralUnit = (isAttacker = true): WarUnit =>
({
isAttacker: () => isAttacker,
}) as unknown as WarUnit;
const buildCityUnit = (isAttacker = false): WarUnit => {
const unit = Object.create(WarUnitCity.prototype) as WarUnit & {
isAttacker: () => boolean;
};
unit.isAttacker = () => isAttacker;
return unit;
};
describe('scenario effect action modules', () => {
it.each(['event_UnlimitedDefenceThresholdChange', 'event_StrongAttacker', 'event_MoreEffect'])(
'%s removes only the defence-setting train/atmos penalty',
(key) => {
const module = createScenarioEffectActionModules(key).general;
expect(module?.onCalcDomestic?.(generalContext, 'changeDefenceTrain', 'train', -3)).toBe(0);
expect(module?.onCalcDomestic?.(generalContext, 'changeDefenceTrain', 'atmos', -6)).toBe(0);
}
);
it('doubles only the eight MoreEffect domestic score actions', () => {
const module = createScenarioEffectActionModules('event_MoreEffect').general!;
for (const action of ['상업', '농업', '치안', '기술', '성벽', '수비', '인구', '민심'] as const) {
expect(module.onCalcDomestic?.(generalContext, action, 'score', 12.5)).toBe(25);
expect(module.onCalcDomestic?.(generalContext, action, 'cost', 12.5)).toBe(12.5);
}
expect(module.onCalcDomestic?.(generalContext, '징병', 'score', 12.5)).toBe(12.5);
});
it('retains the dormant MoreEffect income hook contract without wiring it to monthly income', () => {
const module = createScenarioEffectActionModules('event_MoreEffect').general!;
expect(module.onCalcNationalIncome?.(generalContext, 'gold', 10)).toBe(20);
expect(module.onCalcNationalIncome?.(generalContext, 'rice', 10)).toBe(20);
expect(module.onCalcNationalIncome?.(generalContext, 'pop', 10)).toBe(20);
expect(module.onCalcNationalIncome?.(generalContext, 'pop', -10)).toBe(-10);
});
it('preserves StrongAttacker city exclusions and the exact 0.7143 literal', () => {
const strong = createScenarioEffectActionModules('event_StrongAttacker').war!;
const more = createScenarioEffectActionModules('event_MoreEffect').war!;
const attacker = buildGeneralUnit(true);
const defender = buildGeneralUnit(false);
const city = buildCityUnit(false);
expect(strong.getWarPowerMultiplier?.(generalContext, attacker, defender)).toEqual([1.4, 0.7143]);
expect(strong.getWarPowerMultiplier?.(generalContext, defender, attacker)).toEqual([1, 1]);
expect(strong.getWarPowerMultiplier?.(generalContext, attacker, city)).toEqual([1, 1]);
expect(strong.getWarPowerMultiplier?.(generalContext, city, attacker)).toEqual([1, 1]);
expect(more.getWarPowerMultiplier?.(generalContext, attacker, city)).toEqual([1.4, 0.7143]);
});
it('adds one phase and the exact two logs only for a progressed unit facing a fresh opponent', () => {
const selfLogger = new ActionLogger({ generalId: 1 });
const opposeLogger = new ActionLogger({ generalId: 2 });
let bonusPhase = 0;
const self = {
getUnitId: () => 'general:1',
isAttacker: () => true,
getPhase: () => 1,
addBonusPhase: (count: number) => {
bonusPhase += count;
},
getLogger: () => selfLogger,
} as unknown as WarUnit;
const oppose = {
getUnitId: () => 'general:2',
isAttacker: () => false,
getPhase: () => 0,
addBonusPhase: () => undefined,
getLogger: () => opposeLogger,
} as unknown as WarUnit;
const module = createScenarioEffectActionModules('event_StrongAttacker').war!;
const caller = module.getBattlePhaseTriggerList?.({ general: {} as General, unit: self });
let rngCalls = 0;
const source = new Proxy(new ConstantRNG(0), {
get(target, property, receiver) {
const value = Reflect.get(target, property, receiver);
if (typeof value !== 'function' || !String(property).startsWith('next')) {
return value;
}
return (...args: unknown[]) => {
rngCalls += 1;
return Reflect.apply(value, target, args);
};
},
});
caller?.fire({ rng: new RandUtil(source), attacker: self, defender: oppose }, createWarTriggerEnv());
expect(bonusPhase).toBe(1);
expect(rngCalls).toBe(0);
expect(selfLogger.flush()).toContainEqual(
expect.objectContaining({ text: '적군의 전멸에 <C>진격</>이 이어집니다!' })
);
expect(opposeLogger.flush()).toContainEqual(
expect.objectContaining({ text: '아군의 전멸에 상대의 <R>진격</>이 이어집니다!' })
);
});
it('returns no module for None/null and fails fast for an unknown effect', () => {
expect(createScenarioEffectActionModules(null)).toEqual({ general: null, war: null });
expect(createScenarioEffectActionModules('None')).toEqual({ general: null, war: null });
expect(() => createScenarioEffectActionModules('event_Missing')).toThrow(
'Unknown scenario effect: event_Missing'
);
});
});
@@ -76,4 +76,33 @@ describe('scenario parser', () => {
picture: '장수/아회남.jpg',
});
});
it('keeps the complete seven-scenario effect inventory typed and executable', async () => {
const defaults = parseScenarioDefaults(await readJson(path.join(scenarioRoot, 'default.json')));
const expected = new Map([
[906, 'event_StrongAttacker'],
[911, 'event_UnlimitedDefenceThresholdChange'],
[913, 'event_MoreEffect'],
[2703, 'event_StrongAttacker'],
[2704, 'event_StrongAttacker'],
[2903, 'event_StrongAttacker'],
[2904, 'event_StrongAttacker'],
]);
for (const [scenarioId, scenarioEffect] of expected) {
const raw = await readJson(path.join(scenarioRoot, `scenario_${scenarioId}.json`));
expect(parseScenarioDefinition(raw, defaults).config.environment.scenarioEffect).toBe(scenarioEffect);
}
});
it('fails before seeding when a scenario references an unknown effect', async () => {
const defaults = parseScenarioDefaults(await readJson(path.join(scenarioRoot, 'default.json')));
const raw = (await readJson(path.join(scenarioRoot, 'scenario_0.json'))) as Record<string, unknown>;
raw.const = {
...((raw.const as Record<string, unknown> | undefined) ?? {}),
scenarioEffect: 'event_Missing',
};
expect(() => parseScenarioDefinition(raw, defaults)).toThrow();
});
});
@@ -5,6 +5,7 @@ import type { City, General, Nation } from '../../src/domain/entities.js';
import type { WorldSnapshot } from '../../src/world/types.js';
import { commandSpec as developAgricultureSpec } from '../../src/actions/turn/general/che_농지개간.js';
import type { TurnCommandEnv } from '../../src/actions/turn/commandEnv.js';
import { loadActionModuleBundle } from '../../src/actionModules/bundle.js';
describe('Domestic Affairs Scenario', () => {
it('should increase agriculture when executing "Farming" command', async () => {
@@ -97,6 +98,8 @@ describe('Domestic Affairs Scenario', () => {
events: [],
initialEvents: [],
};
const moduleBaselineSnapshot = structuredClone(snapshot);
const moreEffectSnapshot = structuredClone(snapshot);
const world = new InMemoryWorld(snapshot);
const runner = new TestGameRunner(world, 200, 1);
@@ -143,6 +146,39 @@ describe('Domestic Affairs Scenario', () => {
const updatedCity = world.getCity(1)!;
// 레거시 che_농지개간: 능력치·경험등급·0.8~1.2 난수·성공 배율을 모두 반영한다.
expect(updatedCity.agriculture).toBe(664);
const baselineModuleWorld = new InMemoryWorld(moduleBaselineSnapshot);
const baselineModuleRunner = new TestGameRunner(baselineModuleWorld, 200, 1);
const baselineBundle = await loadActionModuleBundle();
await baselineModuleRunner.runTurn([
{
generalId: 1,
commandKey: 'che_농지개간',
resolver: developAgricultureSpec.createDefinition({
...systemEnv,
generalActionModules: baselineBundle.general,
}),
args: {},
},
]);
const moreEffectWorld = new InMemoryWorld(moreEffectSnapshot);
const moreEffectRunner = new TestGameRunner(moreEffectWorld, 200, 1);
const moduleBundle = await loadActionModuleBundle(undefined, 'event_MoreEffect');
const moreEffectDefinition = developAgricultureSpec.createDefinition({
...systemEnv,
generalActionModules: moduleBundle.general,
});
await moreEffectRunner.runTurn([
{
generalId: 1,
commandKey: 'che_농지개간',
resolver: moreEffectDefinition,
args: {},
},
]);
expect(baselineModuleWorld.getCity(1)?.agriculture).toBe(672);
expect(moreEffectWorld.getCity(1)?.agriculture).toBe(844);
});
it('should not increase agriculture when city is already maxed', async () => {
+48 -4
View File
@@ -110,8 +110,30 @@
},
"const": {
"type": "object",
"propertyNames": {
"type": "string"
"properties": {
"scenarioEffect": {
"anyOf": [
{
"type": "string",
"enum": [
"event_UnlimitedDefenceThresholdChange",
"event_StrongAttacker",
"event_MoreEffect"
]
},
{
"type": "string",
"const": ""
},
{
"type": "string",
"const": "None"
},
{
"type": "null"
}
]
}
},
"additionalProperties": {}
},
@@ -236,8 +258,30 @@
},
"const": {
"type": "object",
"propertyNames": {
"type": "string"
"properties": {
"scenarioEffect": {
"anyOf": [
{
"type": "string",
"enum": [
"event_UnlimitedDefenceThresholdChange",
"event_StrongAttacker",
"event_MoreEffect"
]
},
{
"type": "string",
"const": ""
},
{
"type": "string",
"const": "None"
},
{
"type": "null"
}
]
}
},
"additionalProperties": {}
},
@@ -2,6 +2,7 @@ import { LiteHashDRBG, RandUtil, type RNG } from '@sammo-ts/common';
import {
GENERAL_TURN_COMMAND_KEYS,
NATION_TURN_COMMAND_KEYS,
normalizeScenarioEffect,
type MapDefinition,
type Nation,
type TurnCommandProfile,
@@ -51,6 +52,7 @@ export interface TurnCommandFixtureRequest {
year?: number;
month?: number;
hiddenSeed?: string;
scenarioEffect?: string | null;
staticEventHandlers?: Record<string, string[]>;
};
isolateWorld?: boolean;
@@ -428,7 +430,13 @@ export const buildCoreTurnCommandWorldInput = (
? { staticEventHandlers: request.setup.world.staticEventHandlers }
: {}),
},
environment: { mapName: map.id, unitSet: unitSet.id },
environment: {
mapName: map.id,
unitSet: unitSet.id,
...(request.setup?.world?.scenarioEffect !== undefined
? { scenarioEffect: normalizeScenarioEffect(request.setup.world.scenarioEffect) }
: {}),
},
},
scenarioMeta: {
title: '턴 명령 차등',
@@ -25,6 +25,20 @@ const withProjectedTraceMeta = (trace: CanonicalTurnCommandTrace): CanonicalTurn
after: withProjectedGeneralMeta(trace.after),
});
const referenceRunnerEnvironment = (workspaceRoot: string, stackDirectory: string): NodeJS.ProcessEnv => {
const compareSourceRoot = process.env.REF_COMPARE_SOURCE_ROOT;
return {
...process.env,
TURN_DIFFERENTIAL_STACK_DIR: stackDirectory,
...(compareSourceRoot
? {
TURN_DIFFERENTIAL_APP_DIR: path.resolve(compareSourceRoot),
TURN_DIFFERENTIAL_RUNTIME_DIR: path.join(workspaceRoot, 'ref/sam'),
}
: {}),
};
};
export const findTurnDifferentialWorkspaceRoot = (start: string): string | null => {
let current = path.resolve(start);
while (true) {
@@ -82,8 +96,7 @@ export const runReferenceTurnCommandTrace = (workspaceRoot: string, fixturePath:
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
env: {
...process.env,
TURN_DIFFERENTIAL_STACK_DIR: stackDirectory,
...referenceRunnerEnvironment(workspaceRoot, stackDirectory),
},
});
return withProjectedTraceMeta(JSON.parse(stdout) as CanonicalTurnCommandTrace);
@@ -101,8 +114,7 @@ export const runReferenceTurnCommandTraceRequest = (
encoding: 'utf8',
stdio: ['pipe', 'pipe', 'pipe'],
env: {
...process.env,
TURN_DIFFERENTIAL_STACK_DIR: stackDirectory,
...referenceRunnerEnvironment(workspaceRoot, stackDirectory),
},
});
return withProjectedTraceMeta(JSON.parse(stdout) as CanonicalTurnCommandTrace);
@@ -1,5 +1,6 @@
import { execFileSync } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { LiteHashDRBG, RandUtil, type RNG } from '@sammo-ts/common';
@@ -28,6 +29,21 @@ interface ReferenceTrace {
conquered: boolean;
events: WarBattleTraceEvent[];
rng: RandomCall[];
logs: {
attacker: ReferenceLogBuckets;
defenders: Record<string, ReferenceLogBuckets>;
city: ReferenceLogBuckets;
};
}
interface ReferenceLogBuckets {
generalHistoryLog: string[];
generalActionLog: string[];
generalBattleResultLog: string[];
generalBattleDetailLog: string[];
nationalHistoryLog: string[];
globalHistoryLog: string[];
globalActionLog: string[];
}
interface RandomCall {
@@ -110,6 +126,60 @@ const findWorkspaceRoot = (start: string): string | null => {
const readJson = <T>(filePath: string): T => JSON.parse(fs.readFileSync(filePath, 'utf8')) as T;
const runReferenceTrace = (workspaceRoot: string, fixtureJson: string): ReferenceTrace => {
const compareSourceRoot = process.env.REF_COMPARE_SOURCE_ROOT;
if (compareSourceRoot) {
const resolvedCompareRoot = path.resolve(compareSourceRoot);
const runtimeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'sammo-ref-battle-'));
fs.cpSync(resolvedCompareRoot, runtimeRoot, {
recursive: true,
filter: (source) => {
const relative = path.relative(resolvedCompareRoot, source);
return !(
relative === '.git' ||
relative.startsWith(`.git${path.sep}`) ||
relative === 'vendor' ||
relative.startsWith(`vendor${path.sep}`) ||
relative === 'd_log' ||
relative.startsWith(`d_log${path.sep}`) ||
relative === path.join('hwe', 'd_setting') ||
relative.startsWith(`${path.join('hwe', 'd_setting')}${path.sep}`)
);
},
});
fs.mkdirSync(path.join(runtimeRoot, 'd_log'));
try {
const stdout = execFileSync(
'docker',
[
'run',
'--rm',
'-i',
'-v',
`${runtimeRoot}:/var/www/html`,
'-v',
`${path.join(workspaceRoot, 'ref/sam/vendor')}:/var/www/html/vendor:ro`,
'-v',
`${path.join(workspaceRoot, 'ref/sam/hwe/d_setting')}:/var/www/html/hwe/d_setting:ro`,
'sam-rebuild-ref-php:8.3',
'php',
'-d',
'display_errors=0',
'-d',
'log_errors=0',
'/var/www/html/hwe/compare/battle_trace.php',
'-',
],
{
input: fixtureJson,
encoding: 'utf8',
stdio: ['pipe', 'pipe', 'pipe'],
}
);
return JSON.parse(stdout) as ReferenceTrace;
} finally {
fs.rmSync(runtimeRoot, { recursive: true, force: true });
}
}
const stdout = execFileSync(
'docker',
['compose', 'exec', '-T', 'php', 'php', '/var/www/html/hwe/compare/battle_trace.php', '-'],
@@ -165,6 +235,9 @@ const expectNearlyEqual = (actual: unknown, expected: unknown, label: string): v
).toBeLessThanOrEqual(tolerance);
};
const normalizeRandomArguments = (value: Record<string, unknown>): Record<string, unknown> =>
Array.isArray(value) && value.length === 0 ? {} : value;
const assertTraceParity = (
coreEvents: WarBattleTraceEvent[],
reference: ReferenceTrace,
@@ -176,8 +249,20 @@ const assertTraceParity = (
coreEventNames,
`event sequence\ncore=${JSON.stringify(coreEventNames)}\nref=${JSON.stringify(referenceEventNames)}`
).toEqual(referenceEventNames);
expect(coreRng?.calls.map(({ operation, result }) => ({ operation, result }))).toEqual(
reference.rng.map(({ operation, result }) => ({ operation, result }))
expect(
coreRng?.calls.map(({ seq, operation, arguments: args, result }) => ({
seq,
operation,
arguments: normalizeRandomArguments(args),
result,
}))
).toEqual(
reference.rng.map(({ seq, operation, arguments: args, result }) => ({
seq,
operation,
arguments: normalizeRandomArguments(args),
result,
}))
);
for (let index = 0; index < reference.events.length; index += 1) {
@@ -185,10 +270,16 @@ const assertTraceParity = (
const ref = reference.events[index]!;
expectNearlyEqual(core.attacker.hp, ref.attacker.hp, `event ${index} attacker.hp`);
expectNearlyEqual(core.attacker.warPower, ref.attacker.warPower, `event ${index} attacker.warPower`);
expect(core.attacker.phase, `event ${index} attacker.phase`).toBe(ref.attacker.phase);
expect(core.attacker.realPhase, `event ${index} attacker.realPhase`).toBe(ref.attacker.realPhase);
expect(core.attacker.maxPhase, `event ${index} attacker.maxPhase`).toBe(ref.attacker.maxPhase);
if (core.defender && ref.defender) {
expect(core.defender.kind, `event ${index} defender.kind`).toBe(ref.defender.kind);
expectNearlyEqual(core.defender.hp, ref.defender.hp, `event ${index} defender.hp`);
expectNearlyEqual(core.defender.warPower, ref.defender.warPower, `event ${index} defender.warPower`);
expect(core.defender.phase, `event ${index} defender.phase`).toBe(ref.defender.phase);
expect(core.defender.realPhase, `event ${index} defender.realPhase`).toBe(ref.defender.realPhase);
expect(core.defender.maxPhase, `event ${index} defender.maxPhase`).toBe(ref.defender.maxPhase);
} else {
expect(core.defender, `event ${index} defender presence`).toBe(ref.defender);
}
@@ -202,9 +293,137 @@ const assertTraceParity = (
const configuredWorkspaceRoot = process.env.TURN_DIFFERENTIAL_WORKSPACE_ROOT;
const workspaceRoot = configuredWorkspaceRoot ?? findWorkspaceRoot(process.cwd());
if (process.env.TURN_DIFFERENTIAL_REFERENCE === '1' && !workspaceRoot) {
throw new Error(
'TURN_DIFFERENTIAL_REFERENCE=1 requires TURN_DIFFERENTIAL_WORKSPACE_ROOT when running outside the workspace tree.'
);
}
const describeWithReference = workspaceRoot ? describe : describe.skip;
describeWithReference('ref ↔ core2026 battle differential', () => {
it('matches all scenario effects across general, direct-city, and fresh-defender combat', () => {
const unitSet = readJson<UnitSetDefinition>(
path.resolve(process.cwd(), '../../resources/unitset/unitset_che.json')
);
const config: WarEngineConfig = {
armPerPhase: 500,
maxTrainByCommand: 100,
maxAtmosByCommand: 100,
maxTrainByWar: 110,
maxAtmosByWar: 150,
castleCrewTypeId: 1000,
armTypes: { footman: 1, archer: 2, cavalry: 3, wizard: 4, siege: 5, misc: 6, castle: 0 },
};
const cases: Array<{
name: string;
effect: string;
directCity?: boolean;
multipleDefenders?: boolean;
}> = [
{ name: 'unlimited-general', effect: 'event_UnlimitedDefenceThresholdChange' },
{ name: 'strong-general', effect: 'event_StrongAttacker' },
{ name: 'more-general', effect: 'event_MoreEffect' },
{ name: 'strong-city', effect: 'event_StrongAttacker', directCity: true },
{ name: 'more-city', effect: 'event_MoreEffect', directCity: true },
{ name: 'strong-fresh-defender', effect: 'event_StrongAttacker', multipleDefenders: true },
];
for (const entry of cases) {
const base = readJson<BattleSimRequestPayload & { startYear: number; scenarioEffect?: string }>(
path.resolve(process.cwd(), 'fixtures/battle/basic-infantry.json')
);
base.seed = `battle-differential-scenario-${entry.name}`;
base.scenarioEffect = entry.effect;
base.attackerGeneral.crew = entry.multipleDefenders ? 5000 : 3000;
if (entry.directCity) {
base.defenderGenerals = [];
}
if (entry.multipleDefenders) {
base.defenderGenerals[0]!.crew = 1;
}
if (entry.multipleDefenders) {
base.defenderGenerals.push({
...base.defenderGenerals[0]!,
no: 3,
name: '새 수비자',
crew: 1200,
leadership: 1,
strength: 1,
intel: 1,
});
}
const coreEvents: WarBattleTraceEvent[] = [];
let coreRng: TracingRng | null = null;
const coreResult = processBattleSimJob(
{
...base,
unitSet,
config,
time: { year: base.year, month: base.month, startYear: base.startYear },
scenarioEffect: entry.effect,
},
{
trace: (event) => coreEvents.push(event),
rngFactory: (seed) => {
coreRng = new TracingRng(LiteHashDRBG.build(seed));
return new RandUtil(coreRng);
},
}
);
try {
const reference = runReferenceTrace(workspaceRoot!, JSON.stringify(base));
assertTraceParity(coreEvents, reference, coreRng);
const opponentSwitches = coreEvents.filter((event) => event.event === 'opponent_switched');
if (entry.directCity) {
expect(
coreEvents.some(
(event) => event.event === 'opponent_initialized' && event.defender?.kind === 'city'
),
`${entry.name}: must execute direct city combat`
).toBe(true);
}
if (entry.multipleDefenders) {
expect(
opponentSwitches.some((event) => event.defender?.kind === 'general' && event.defender.id === 3),
`${entry.name}: must transition to the fresh defender`
).toBe(true);
const switchedIndex = coreEvents.findIndex(
(event) =>
event.event === 'opponent_switched' &&
event.defender?.kind === 'general' &&
event.defender.id === 3
);
const maxPhaseBeforeAdvance = coreEvents[switchedIndex]!.attacker.maxPhase;
expect(
coreEvents
.slice(switchedIndex + 1)
.some(
(event) =>
event.event === 'phase_triggered' &&
event.attacker.maxPhase === maxPhaseBeforeAdvance + 1
),
`${entry.name}: advance trigger must extend attacker phases after the switch`
).toBe(true);
expect(reference.logs.attacker.generalBattleDetailLog).toContain(
'<C>●</>적군의 전멸에 <C>진격</>이 이어집니다!'
);
expect(reference.logs.defenders['3']?.generalBattleDetailLog).toContain(
'<C>●</>아군의 전멸에 상대의 <R>진격</>이 이어집니다!'
);
expect(coreResult.lastWarLog?.generalBattleDetailLog).toContain(
'적군의 전멸에 <font color=cyan>진격</font>이 이어집니다!'
);
}
} catch (error) {
throw new Error(`${entry.name}: ${error instanceof Error ? error.message : String(error)}`, {
cause: error,
});
}
}
});
it('matches every legacy trait name and description', async () => {
const reference = runReferenceTraitCatalog(workspaceRoot!);
const [nation, domestic, war, personality] = await Promise.all([
@@ -285,9 +504,12 @@ describeWithReference('ref ↔ core2026 battle differential', () => {
try {
assertTraceParity(coreEvents, runReferenceTrace(workspaceRoot!, JSON.stringify(base)), coreRng);
} catch (error) {
throw new Error(`${entry.kind}/${entry.key}: ${error instanceof Error ? error.message : String(error)}`, {
cause: error,
});
throw new Error(
`${entry.kind}/${entry.key}: ${error instanceof Error ? error.message : String(error)}`,
{
cause: error,
}
);
}
}
});
@@ -805,12 +1027,16 @@ describeWithReference('ref ↔ core2026 battle differential', () => {
process.env['ITEM_PARITY_DEBUG'] === '1'
? `\ncore=${JSON.stringify(
coreEvents
.map((event, index) => ({ index, event }))
.filter(({ event }) => event.event === 'phase_power' || event.event === 'phase_damage')
.map((event, index) => ({ index, event }))
.filter(
({ event }) => event.event === 'phase_power' || event.event === 'phase_damage'
)
)}\nref=${JSON.stringify(
reference.events
.map((event, index) => ({ index, event }))
.filter(({ event }) => event.event === 'phase_power' || event.event === 'phase_damage')
.map((event, index) => ({ index, event }))
.filter(
({ event }) => event.event === 'phase_power' || event.event === 'phase_damage'
)
)}`
: '';
failures.push(`${itemKey}: ${error instanceof Error ? error.message : String(error)}${debug}`);
@@ -910,11 +1136,15 @@ describeWithReference('ref ↔ core2026 battle differential', () => {
? `\ncore=${JSON.stringify(
coreEvents
.map((event, index) => ({ index, event }))
.filter(({ event }) => event.event === 'phase_power' || event.event === 'phase_damage')
.filter(
({ event }) => event.event === 'phase_power' || event.event === 'phase_damage'
)
)}\nref=${JSON.stringify(
reference.events
.map((event, index) => ({ index, event }))
.filter(({ event }) => event.event === 'phase_power' || event.event === 'phase_damage')
.filter(
({ event }) => event.event === 'phase_power' || event.event === 'phase_damage'
)
)}`
: '';
failures.push(`${itemKey}: ${error instanceof Error ? error.message : String(error)}${debug}`);
@@ -13,6 +13,7 @@ import {
} from '@sammo-ts/game-engine/turn/reservedTurnStore.js';
import { loadMapDefinitionByName } from '@sammo-ts/game-engine/scenario/mapLoader.js';
import { loadUnitSetDefinitionByName } from '@sammo-ts/game-engine/scenario/unitSetLoader.js';
import { loadTurnWorldFromDatabase } from '@sammo-ts/game-engine/turn/worldLoader.js';
import { createGamePostgresConnector, type GamePrismaClient, type InputJsonValue } from '@sammo-ts/infra';
import {
@@ -49,7 +50,7 @@ const assertDedicatedDatabase = (rawUrl: string): void => {
}
};
const readFixture = (fixtureName: string): TurnCommandFixtureRequest => {
const readFixture = (fixtureName: string, scenarioEffect?: string): TurnCommandFixtureRequest => {
const fixturePath = path.join(
workspaceRoot!,
`docker_compose_files/reference/fixtures/turn-differential/${fixtureName}`
@@ -64,6 +65,7 @@ const readFixture = (fixtureName: string): TurnCommandFixtureRequest => {
world: {
...fixture.setup?.world,
hiddenSeed: 'turn-command-differential-seed',
...(scenarioEffect ? { scenarioEffect } : {}),
},
generals: fixture.setup?.generals?.map((general) => ({
...general,
@@ -117,6 +119,42 @@ integration('live sortie PostgreSQL persistence retry', () => {
await disconnect?.();
});
it('rejects an unknown persisted scenario effect before turn execution', async () => {
await db.worldState.create({
data: {
id: 1,
scenarioCode: 'invalid-scenario-effect',
currentYear: 180,
currentMonth: 1,
tickSeconds: 600,
config: asJson({
stat: {
total: 300,
min: 10,
max: 100,
npcTotal: 150,
npcMax: 50,
npcMin: 10,
chiefMin: 65,
},
iconPath: '',
map: {},
const: {},
environment: {
mapName: 'che',
unitSet: 'che',
scenarioEffect: 'event_Missing',
},
}),
meta: {},
},
});
await expect(loadTurnWorldFromDatabase({ databaseUrl: databaseUrl! })).rejects.toThrow(
'world_state.config is invalid'
);
});
it.each([
['conquest and nation collapse', 'live-sortie-conquest.json'],
['collapsed nation conflict cleanup', 'live-sortie-collapse-conflict.json'],
@@ -127,15 +165,25 @@ integration('live sortie PostgreSQL persistence retry', () => {
['emergency capital', 'live-sortie-emergency-capital.json'],
['conflict arbitration', 'live-sortie-conflict-arbitration.json'],
['tied conflict', 'live-sortie-conflict-tie.json'],
['StrongAttacker scenario effect', 'live-sortie-multiple-defenders.json', 'event_StrongAttacker'],
])(
'%s rolls back a lost lease and flushes exactly once on retry',
async (_label, fixtureName) => {
const request = readFixture(fixtureName);
async (_label, fixtureName, scenarioEffect?: string) => {
const request = readFixture(fixtureName, scenarioEffect);
const reference = runReferenceTurnCommandTraceRequest(
workspaceRoot!,
request as unknown as Record<string, unknown>
);
const expected = await runCoreTurnCommandTrace(request, reference.before);
if (scenarioEffect) {
expect(expected.rng).toEqual(reference.rng);
expect(
reference.after.logs.some((entry) => typeof entry.text === 'string' && entry.text.includes('진격'))
).toBe(true);
expect(
expected.after.logs.some((entry) => typeof entry.text === 'string' && entry.text.includes('진격'))
).toBe(true);
}
const coreArgs = resolveCoreTurnCommandArgs(request);
const unitSet = await loadUnitSetDefinitionByName('che');
const map = await loadMapDefinitionByName('che');
@@ -431,6 +479,8 @@ integration('live sortie PostgreSQL persistence retry', () => {
revision: committedRevision.revision,
leaseOwner: committedRevision.leaseOwner,
});
const reloaded = await loadTurnWorldFromDatabase({ databaseUrl: databaseUrl! });
expect(reloaded.snapshot.scenarioConfig.environment.scenarioEffect).toBe(scenarioEffect ?? null);
} finally {
await hooks.close();
}