feat: port scenario action effects
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
import type { WorldStateRow } from '../context.js';
|
import type { WorldStateRow } from '../context.js';
|
||||||
import type { BattleSimJobPayload, BattleSimRequestPayload } from './types.js';
|
import type { BattleSimJobPayload, BattleSimRequestPayload } from './types.js';
|
||||||
import { loadUnitSetDefinitionByName } from './unitSetLoader.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 { asRecord } from '@sammo-ts/common';
|
||||||
import type { UnitSetDefinition } from '@sammo-ts/logic';
|
import type { UnitSetDefinition } from '@sammo-ts/logic';
|
||||||
|
|
||||||
@@ -79,6 +79,7 @@ export interface BattleSimEnvironment {
|
|||||||
unitSet: UnitSetDefinition;
|
unitSet: UnitSetDefinition;
|
||||||
config: WarEngineConfig;
|
config: WarEngineConfig;
|
||||||
startYear: number;
|
startYear: number;
|
||||||
|
scenarioEffect: ScenarioEffectKey | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const buildBattleSimEnvironment = async (
|
export const buildBattleSimEnvironment = async (
|
||||||
@@ -89,6 +90,7 @@ export const buildBattleSimEnvironment = async (
|
|||||||
const unitSet = await loadUnitSetDefinitionByName(unitSetName);
|
const unitSet = await loadUnitSetDefinitionByName(unitSetName);
|
||||||
|
|
||||||
const configRecord = asRecord(worldState.config);
|
const configRecord = asRecord(worldState.config);
|
||||||
|
const scenarioEnvironment = asRecord(configRecord.environment ?? configRecord.map);
|
||||||
const constValues = asRecord(configRecord.const ?? configRecord.consts);
|
const constValues = asRecord(configRecord.const ?? configRecord.consts);
|
||||||
const castleCrewTypeId = resolveNumber(constValues, ['castleCrewTypeId'], resolveCastleCrewTypeId(unitSet));
|
const castleCrewTypeId = resolveNumber(constValues, ['castleCrewTypeId'], resolveCastleCrewTypeId(unitSet));
|
||||||
const castleArmType = resolveCastleArmType(unitSet, castleCrewTypeId);
|
const castleArmType = resolveCastleArmType(unitSet, castleCrewTypeId);
|
||||||
@@ -117,6 +119,7 @@ export const buildBattleSimEnvironment = async (
|
|||||||
unitSet,
|
unitSet,
|
||||||
config,
|
config,
|
||||||
startYear: resolveStartYear(worldState),
|
startYear: resolveStartYear(worldState),
|
||||||
|
scenarioEffect: normalizeScenarioEffect(scenarioEnvironment.scenarioEffect),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -136,5 +139,6 @@ export const buildBattleSimJobPayload = async (
|
|||||||
month: request.month,
|
month: request.month,
|
||||||
startYear: environment.startYear,
|
startYear: environment.startYear,
|
||||||
},
|
},
|
||||||
|
scenarioEffect: environment.scenarioEffect,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
createItemActionModules,
|
createItemActionModules,
|
||||||
createItemModuleRegistry,
|
createItemModuleRegistry,
|
||||||
createRefOrderedActionStack,
|
createRefOrderedActionStack,
|
||||||
|
createScenarioEffectActionModules,
|
||||||
ITEM_KEYS,
|
ITEM_KEYS,
|
||||||
loadItemModules,
|
loadItemModules,
|
||||||
createInheritBuffModules,
|
createInheritBuffModules,
|
||||||
@@ -62,8 +63,12 @@ const domesticWarModule = new TraitWarActionRouter('domestic', traitCatalog);
|
|||||||
const warTraitModule = new TraitWarActionRouter('war', traitCatalog);
|
const warTraitModule = new TraitWarActionRouter('war', traitCatalog);
|
||||||
const personalityWarModule = new TraitWarActionRouter('personality', 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 crewTypeCatalog = compileCrewTypeCatalog(unitSet, crewTypeWarTriggerRegistry);
|
||||||
|
const scenario = createScenarioEffectActionModules(scenarioEffect);
|
||||||
return createRefOrderedActionStack<WarActionModule>({
|
return createRefOrderedActionStack<WarActionModule>({
|
||||||
nation: nationWarModule,
|
nation: nationWarModule,
|
||||||
officer: officerWarModule,
|
officer: officerWarModule,
|
||||||
@@ -72,7 +77,7 @@ const buildWarActionModules = (unitSet: UnitSetDefinition): RefOrderedActionStac
|
|||||||
personality: personalityWarModule,
|
personality: personalityWarModule,
|
||||||
crewType: crewTypeCatalog.warActionModule,
|
crewType: crewTypeCatalog.warActionModule,
|
||||||
inheritance: inheritBuffModules.war,
|
inheritance: inheritBuffModules.war,
|
||||||
scenario: null,
|
scenario: scenario.war,
|
||||||
items: itemWarModules,
|
items: itemWarModules,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -296,7 +301,7 @@ const resolveDefenderOrderPayload = (payload: BattleSimJobPayload): number[] =>
|
|||||||
const defenderCity = mapCityPayload(payload.defenderCity);
|
const defenderCity = mapCityPayload(payload.defenderCity);
|
||||||
const attacker = mapGeneralPayload(payload.attackerGeneral);
|
const attacker = mapGeneralPayload(payload.attackerGeneral);
|
||||||
const defenders = payload.defenderGenerals.map(mapGeneralPayload);
|
const defenders = payload.defenderGenerals.map(mapGeneralPayload);
|
||||||
const warActionModules = buildWarActionModules(payload.unitSet);
|
const warActionModules = buildWarActionModules(payload.unitSet, payload.scenarioEffect);
|
||||||
|
|
||||||
return resolveDefenderOrder({
|
return resolveDefenderOrder({
|
||||||
unitSet: payload.unitSet,
|
unitSet: payload.unitSet,
|
||||||
@@ -338,7 +343,7 @@ export const processBattleSimJob = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
let repeatCnt = payload.repeatCnt;
|
let repeatCnt = payload.repeatCnt;
|
||||||
const warActionModules = buildWarActionModules(payload.unitSet);
|
const warActionModules = buildWarActionModules(payload.unitSet, payload.scenarioEffect);
|
||||||
const baseSeed = payload.seed ?? '';
|
const baseSeed = payload.seed ?? '';
|
||||||
if (baseSeed) {
|
if (baseSeed) {
|
||||||
repeatCnt = 1;
|
repeatCnt = 1;
|
||||||
|
|||||||
@@ -100,6 +100,7 @@ export interface BattleSimJobPayload extends BattleSimRequestPayload {
|
|||||||
unitSet: UnitSetDefinition;
|
unitSet: UnitSetDefinition;
|
||||||
config: WarEngineConfig;
|
config: WarEngineConfig;
|
||||||
time: WarTimeContext;
|
time: WarTimeContext;
|
||||||
|
scenarioEffect?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BattleSimLogBuckets {
|
export interface BattleSimLogBuckets {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
|
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
|
||||||
import type { DatabaseClient as InfraDatabaseClient, RedisConnector, GamePrisma } from '@sammo-ts/infra';
|
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 { TurnDaemonTransport } from './daemon/transport.js';
|
||||||
import type { BattleSimTransport } from './battleSim/transport.js';
|
import type { BattleSimTransport } from './battleSim/transport.js';
|
||||||
@@ -25,6 +26,14 @@ export const zWorldStateConfig = z.object({
|
|||||||
extendedGeneral: z.boolean().optional(),
|
extendedGeneral: z.boolean().optional(),
|
||||||
turnTermMinutes: z.number().optional(),
|
turnTermMinutes: z.number().optional(),
|
||||||
syncTurnTime: z.boolean().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>;
|
export type WorldStateConfig = z.infer<typeof zWorldStateConfig>;
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ const buildPayload = (action: BattleSimJobPayload['action']): BattleSimJobPayloa
|
|||||||
year: 200,
|
year: 200,
|
||||||
month: 1,
|
month: 1,
|
||||||
seed: 'test-seed',
|
seed: 'test-seed',
|
||||||
|
scenarioEffect: null,
|
||||||
attackerGeneral: {
|
attackerGeneral: {
|
||||||
no: 1,
|
no: 1,
|
||||||
name: 'Attacker',
|
name: 'Attacker',
|
||||||
@@ -250,6 +251,14 @@ describe('battle sim processor', () => {
|
|||||||
expect(result.lastWarLog?.generalActionLog).toContain('퇴각했습니다.');
|
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', () => {
|
it('returns the fixed defender ID order for reorder action', () => {
|
||||||
const payload = buildPayload('reorder');
|
const payload = buildPayload('reorder');
|
||||||
const result = processBattleSimJob(payload);
|
const result = processBattleSimJob(payload);
|
||||||
@@ -281,4 +290,53 @@ describe('battle sim processor', () => {
|
|||||||
|
|
||||||
expect(() => processBattleSimJob(payload)).toThrow('Unknown crew type action');
|
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>이 이어집니다!'
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -265,7 +265,7 @@ describe('battle router orchestration', () => {
|
|||||||
currentYear: 200,
|
currentYear: 200,
|
||||||
currentMonth: 1,
|
currentMonth: 1,
|
||||||
tickSeconds: 600,
|
tickSeconds: 600,
|
||||||
config: {},
|
config: { environment: { scenarioEffect: 'event_MoreEffect' } },
|
||||||
meta: {},
|
meta: {},
|
||||||
updatedAt: new Date('2026-01-01T00:00:00Z'),
|
updatedAt: new Date('2026-01-01T00:00:00Z'),
|
||||||
};
|
};
|
||||||
@@ -275,6 +275,7 @@ describe('battle router orchestration', () => {
|
|||||||
expect(response.status).toBe('queued');
|
expect(response.status).toBe('queued');
|
||||||
expect(battleSim.simulateCalls).toBe(1);
|
expect(battleSim.simulateCalls).toBe(1);
|
||||||
expect(battleSim.lastRequesterUserId).toBe('user-1');
|
expect(battleSim.lastRequesterUserId).toBe('user-1');
|
||||||
|
expect(battleSim.lastPayload?.scenarioEffect).toBe('event_MoreEffect');
|
||||||
|
|
||||||
const queued = await caller.battle.getSimulation({ jobId: response.jobId });
|
const queued = await caller.battle.getSimulation({ jobId: response.jobId });
|
||||||
expect(queued.status).toBe('queued');
|
expect(queued.status).toBe('queued');
|
||||||
@@ -286,6 +287,48 @@ describe('battle router orchestration', () => {
|
|||||||
expect(completed.payload?.result).toBe(true);
|
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 () => {
|
it('requires login, allows a user without a general, and does not open an input-event transaction', async () => {
|
||||||
const battleSim = new QueuedBattleSimTransport();
|
const battleSim = new QueuedBattleSimTransport();
|
||||||
const state: WorldStateRow = {
|
const state: WorldStateRow = {
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ liveDescribe('battle simulator worker with live Redis', () => {
|
|||||||
unitSet: environment.unitSet,
|
unitSet: environment.unitSet,
|
||||||
config: environment.config,
|
config: environment.config,
|
||||||
time: { year: request.year, month: request.month, startYear },
|
time: { year: request.year, month: request.month, startYear },
|
||||||
|
scenarioEffect: environment.scenarioEffect,
|
||||||
};
|
};
|
||||||
|
|
||||||
const clientConnector = createRedisConnector(resolveRedisConfigFromEnv());
|
const clientConnector = createRedisConnector(resolveRedisConfigFromEnv());
|
||||||
|
|||||||
@@ -350,7 +350,14 @@ describe('appRouter', () => {
|
|||||||
currentYear: 1,
|
currentYear: 1,
|
||||||
currentMonth: 2,
|
currentMonth: 2,
|
||||||
tickSeconds: 600,
|
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' },
|
meta: { otherTextInfo: 'sample', hiddenSeed: 'meta-secret' },
|
||||||
updatedAt: new Date('2026-01-01T00:00:00Z'),
|
updatedAt: new Date('2026-01-01T00:00:00Z'),
|
||||||
};
|
};
|
||||||
@@ -360,11 +367,50 @@ describe('appRouter', () => {
|
|||||||
|
|
||||||
expect(response?.scenarioCode).toBe('default');
|
expect(response?.scenarioCode).toBe('default');
|
||||||
expect(response?.currentYear).toBe(1);
|
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?.meta).toEqual({ otherTextInfo: 'sample' });
|
||||||
expect(response?.updatedAt).toBe('2026-01-01T00:00:00.000Z');
|
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 () => {
|
it('requires profile administration permission for turn daemon control', async () => {
|
||||||
const auth: GameSessionTokenPayload = {
|
const auth: GameSessionTokenPayload = {
|
||||||
version: 1,
|
version: 1,
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ export const buildCommandEnv = (config: ScenarioConfig, unitSet?: UnitSetDefinit
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
...(unitSet ? { unitSet } : {}),
|
...(unitSet ? { unitSet } : {}),
|
||||||
|
scenarioEffect: config.environment.scenarioEffect ?? null,
|
||||||
develCost: resolveNumber(constValues, ['develCost', 'develcost', 'develrate'], 0),
|
develCost: resolveNumber(constValues, ['develCost', 'develcost', 'develrate'], 0),
|
||||||
minAvailableRecruitPop: resolveNumber(constValues, ['minAvailableRecruitPop'], 30000),
|
minAvailableRecruitPop: resolveNumber(constValues, ['minAvailableRecruitPop'], 30000),
|
||||||
trainDelta: resolveNumber(constValues, ['trainDelta'], DEFAULT_TRAIN_DELTA),
|
trainDelta: resolveNumber(constValues, ['trainDelta'], DEFAULT_TRAIN_DELTA),
|
||||||
@@ -169,7 +170,7 @@ export const buildReservedTurnDefinitions = async (options: {
|
|||||||
general: Map<string, GeneralActionDefinition>;
|
general: Map<string, GeneralActionDefinition>;
|
||||||
nation: 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;
|
const itemModules = moduleBundle.itemModules;
|
||||||
options.env.itemCatalog = Object.fromEntries(
|
options.env.itemCatalog = Object.fromEntries(
|
||||||
itemModules.map((item) => [
|
itemModules.map((item) => [
|
||||||
|
|||||||
@@ -219,7 +219,10 @@ const createTurnDaemonRuntimeWithLease = async (
|
|||||||
let redisConnector: ReturnType<typeof createRedisConnector> | null = null;
|
let redisConnector: ReturnType<typeof createRedisConnector> | null = null;
|
||||||
const nationTraits = await loadNationTraitModules([...NATION_TRAIT_KEYS], new NationTraitLoader());
|
const nationTraits = await loadNationTraitModules([...NATION_TRAIT_KEYS], new NationTraitLoader());
|
||||||
const nationTraitMap = new Map(nationTraits.map((module) => [module.key, module]));
|
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 monthlyCommandEnv = buildCommandEnv(snapshot.scenarioConfig, snapshot.unitSet);
|
||||||
const unification = options.calendarHandler
|
const unification = options.calendarHandler
|
||||||
? null
|
? null
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
buildVoteUniqueSeed,
|
buildVoteUniqueSeed,
|
||||||
countOccupiedUniqueItems,
|
countOccupiedUniqueItems,
|
||||||
createItemModuleRegistry,
|
createItemModuleRegistry,
|
||||||
|
isDefenceTrainPenaltyWaivedByScenarioEffect,
|
||||||
isValidTroopNameWidth,
|
isValidTroopNameWidth,
|
||||||
loadItemModules,
|
loadItemModules,
|
||||||
normalizeTroopName,
|
normalizeTroopName,
|
||||||
@@ -948,10 +949,7 @@ async function handleSetMySetting(
|
|||||||
nextMeta.defence_train = nextDefenceTrain;
|
nextMeta.defence_train = nextDefenceTrain;
|
||||||
if (nextDefenceTrain === 999) {
|
if (nextDefenceTrain === 999) {
|
||||||
const scenarioEffect = world.getScenarioConfig().environment.scenarioEffect;
|
const scenarioEffect = world.getScenarioConfig().environment.scenarioEffect;
|
||||||
const ignoresPenalty =
|
const ignoresPenalty = isDefenceTrainPenaltyWaivedByScenarioEffect(scenarioEffect);
|
||||||
scenarioEffect === 'event_UnlimitedDefenceThresholdChange' ||
|
|
||||||
scenarioEffect === 'event_StrongAttacker' ||
|
|
||||||
scenarioEffect === 'event_MoreEffect';
|
|
||||||
const constValues = asRecord(world.getScenarioConfig().const);
|
const constValues = asRecord(world.getScenarioConfig().const);
|
||||||
const maxTrain = readMetaNumber(constValues, 'maxTrainByWar', 100);
|
const maxTrain = readMetaNumber(constValues, 'maxTrainByWar', 100);
|
||||||
const maxAtmos = readMetaNumber(constValues, 'maxAtmosByWar', 100);
|
const maxAtmos = readMetaNumber(constValues, 'maxAtmosByWar', 100);
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import type {
|
|||||||
Troop,
|
Troop,
|
||||||
TriggerValue,
|
TriggerValue,
|
||||||
} from '@sammo-ts/logic';
|
} from '@sammo-ts/logic';
|
||||||
|
import { normalizeScenarioEffect } from '@sammo-ts/logic';
|
||||||
import { projectItemSlots, readItemInventoryFromMeta } from '@sammo-ts/logic/items/index.js';
|
import { projectItemSlots, readItemInventoryFromMeta } from '@sammo-ts/logic/items/index.js';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { asRecord, isRecord } from '@sammo-ts/common';
|
import { asRecord, isRecord } from '@sammo-ts/common';
|
||||||
@@ -90,7 +91,20 @@ const zScenarioStatBlock = z.object({
|
|||||||
const zScenarioEnvironment = z.object({
|
const zScenarioEnvironment = z.object({
|
||||||
mapName: z.string(),
|
mapName: z.string(),
|
||||||
unitSet: 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({
|
const zScenarioConfig = z.object({
|
||||||
@@ -155,7 +169,13 @@ const mapScenarioConfig = (raw: JsonValue): ScenarioConfig => {
|
|||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
throw new Error(`world_state.config is invalid: ${parsed.error.message}`);
|
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 = (
|
const mapGeneralRow = (
|
||||||
|
|||||||
@@ -108,6 +108,33 @@ const unitSet: UnitSetDefinition = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
describe('reserved turn crew type wiring', () => {
|
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 () => {
|
it('installs the crew action router before inherit and item handlers', async () => {
|
||||||
const env = buildCommandEnv(scenarioConfig, unitSet);
|
const env = buildCommandEnv(scenarioConfig, unitSet);
|
||||||
|
|
||||||
@@ -221,9 +248,9 @@ describe('reserved turn crew type wiring', () => {
|
|||||||
100,
|
100,
|
||||||
100
|
100
|
||||||
);
|
);
|
||||||
expect(
|
expect(warPipeline.getWarPowerMultiplier(attacker.getActionContext(), attacker, defender)).toEqual([
|
||||||
warPipeline.getWarPowerMultiplier(attacker.getActionContext(), attacker, defender)
|
1.284, 0.93,
|
||||||
).toEqual([1.284, 0.93]);
|
]);
|
||||||
expect(warPipeline.onCalcStat(attacker.getActionContext(), 'bonusTrain', 100)).toBe(105);
|
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(
|
const world = buildWorld(
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
@@ -226,7 +226,11 @@ describe('core monthly event actions at the real month boundary', () => {
|
|||||||
iconPath: '',
|
iconPath: '',
|
||||||
map: {},
|
map: {},
|
||||||
const: { baseGold: 0, baseRice: 0 },
|
const: { baseGold: 0, baseRice: 0 },
|
||||||
environment: { mapName: map.id, unitSet: 'default' },
|
environment: {
|
||||||
|
mapName: map.id,
|
||||||
|
unitSet: 'default',
|
||||||
|
scenarioEffect: 'event_MoreEffect',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
nationTraits: new Map(),
|
nationTraits: new Map(),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
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 { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
|
||||||
import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
|
import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
|
||||||
@@ -51,7 +51,7 @@ const buildGeneral = (overrides: Partial<TurnGeneral> = {}): TurnGeneral => ({
|
|||||||
|
|
||||||
const buildWorld = (
|
const buildWorld = (
|
||||||
general = buildGeneral(),
|
general = buildGeneral(),
|
||||||
options: { autorunLimit?: boolean; scenarioEffect?: string | null } = {}
|
options: { autorunLimit?: boolean; scenarioEffect?: ScenarioEffectKey | null } = {}
|
||||||
) => {
|
) => {
|
||||||
const state: TurnWorldState = {
|
const state: TurnWorldState = {
|
||||||
id: 1,
|
id: 1,
|
||||||
@@ -143,15 +143,22 @@ describe('my information world commands', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('preserves the event scenarios that waive the no-defence penalty', async () => {
|
it.each([
|
||||||
const fixture = buildWorld(buildGeneral(), { scenarioEffect: 'event_StrongAttacker' });
|
'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({
|
await fixture.handler.handle({
|
||||||
type: 'setMySetting',
|
type: 'setMySetting',
|
||||||
generalId: 7,
|
generalId: 7,
|
||||||
settings: { defence_train: 999 },
|
settings: { defence_train: 999 },
|
||||||
});
|
});
|
||||||
expect(fixture.world.getGeneralById(7)).toMatchObject({ train: 90, atmos: 90 });
|
expect(fixture.world.getGeneralById(7)).toMatchObject({ train: 90, atmos: 90 });
|
||||||
});
|
}
|
||||||
|
);
|
||||||
|
|
||||||
it('applies vacation killturn and rejects it in automatic-turn mode', async () => {
|
it('applies vacation killturn and rejects it in automatic-turn mode', async () => {
|
||||||
const allowed = buildWorld();
|
const allowed = buildWorld();
|
||||||
|
|||||||
@@ -50,15 +50,7 @@ type ScenarioSeederPrismaClient = {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const requiredTables = [
|
const requiredTables = ['world_state', 'nation', 'city', 'general', 'diplomacy', 'troop', 'event'];
|
||||||
'world_state',
|
|
||||||
'nation',
|
|
||||||
'city',
|
|
||||||
'general',
|
|
||||||
'diplomacy',
|
|
||||||
'troop',
|
|
||||||
'event',
|
|
||||||
];
|
|
||||||
|
|
||||||
const hasRequiredTables = async (prisma: ScenarioSeederPrismaClient, schemaName: string): Promise<boolean> => {
|
const hasRequiredTables = async (prisma: ScenarioSeederPrismaClient, schemaName: string): Promise<boolean> => {
|
||||||
for (const table of requiredTables) {
|
for (const table of requiredTables) {
|
||||||
@@ -243,6 +235,25 @@ describeDb('scenario database seed', () => {
|
|||||||
await connector.disconnect();
|
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', () => {
|
describe('tracked scenario composition', () => {
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ const persistParityArtifact = async (page: Page, name: string, geometry: unknown
|
|||||||
type FixtureState = {
|
type FixtureState = {
|
||||||
permission: 'head' | 'member';
|
permission: 'head' | 'member';
|
||||||
myset: number;
|
myset: number;
|
||||||
|
scenarioEffect?: string | null;
|
||||||
settingMutations: Array<Record<string, unknown>>;
|
settingMutations: Array<Record<string, unknown>>;
|
||||||
accessPages: string[];
|
accessPages: string[];
|
||||||
};
|
};
|
||||||
@@ -154,7 +155,11 @@ const install = async (page: Page, state: FixtureState) => {
|
|||||||
currentYear: 185,
|
currentYear: 185,
|
||||||
currentMonth: 1,
|
currentMonth: 1,
|
||||||
tickSeconds: 600,
|
tickSeconds: 600,
|
||||||
config: { npcMode: 0, const: { availableInstantAction: {} } },
|
config: {
|
||||||
|
npcMode: 0,
|
||||||
|
const: { availableInstantAction: {} },
|
||||||
|
environment: { scenarioEffect: state.scenarioEffect ?? null },
|
||||||
|
},
|
||||||
meta: {
|
meta: {
|
||||||
turntime: '2026-01-01T00:00:00.000Z',
|
turntime: '2026-01-01T00:00:00.000Z',
|
||||||
opentime: '2025-12-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 }) => {
|
test('내 정보&설정 keeps the legacy 1000px/500px geometry and saves in place', async ({ page }) => {
|
||||||
const state: FixtureState = { permission: 'head', myset: 3, settingMutations: [], accessPages: [] };
|
const state: FixtureState = { permission: 'head', myset: 3, settingMutations: [], accessPages: [] };
|
||||||
await install(page, state);
|
await install(page, state);
|
||||||
await page.setViewportSize({ width: 1200, height: 900 });
|
await page.setViewportSize({ width: 1000, height: 900 });
|
||||||
await page.goto('my-page');
|
await page.goto('my-page');
|
||||||
await expect(page.locator('.title-row')).toContainText('내 정 보');
|
await expect(page.locator('.title-row')).toContainText('내 정 보');
|
||||||
await expect(page.locator('#set_my_setting')).toBeVisible();
|
await expect(page.locator('#set_my_setting')).toBeVisible();
|
||||||
await expect.poll(() => state.accessPages).toContain('my-page');
|
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 desktop = await page.locator('#container').evaluate((element) => {
|
||||||
const rect = element.getBoundingClientRect();
|
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');
|
expect(desktop.sectionBackgroundImage).toContain('back_green.jpg');
|
||||||
await persistParityArtifact(page, 'core-my-page-desktop', desktop);
|
await persistParityArtifact(page, 'core-my-page-desktop', desktop);
|
||||||
|
|
||||||
await page
|
const defenceSelect = page.locator('select').filter({ has: page.locator('option[value="999"]') });
|
||||||
.locator('select')
|
await defenceSelect.selectOption('999');
|
||||||
.filter({ has: page.locator('option[value="999"]') })
|
const noEffectState = await defenceSelect.evaluate((element) => {
|
||||||
.selectOption('999');
|
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 page.locator('#set_my_setting').click();
|
||||||
await expect.poll(() => state.settingMutations.length).toBe(1);
|
await expect.poll(() => state.settingMutations.length).toBe(1);
|
||||||
expect(state.settingMutations[0]).not.toHaveProperty('generalId');
|
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.setViewportSize({ width: 500, height: 900 });
|
||||||
await page.reload();
|
await page.reload();
|
||||||
const mobile = await page.locator('#container').evaluate((element) => {
|
const mobile = await page.locator('#container').evaluate((element) => {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { computed, onMounted, reactive, ref, watch } from 'vue';
|
import { computed, onMounted, reactive, ref, watch } from 'vue';
|
||||||
import { trpc } from '../utils/trpc';
|
import { trpc } from '../utils/trpc';
|
||||||
import { formatLog } from '../utils/formatLog';
|
import { formatLog } from '../utils/formatLog';
|
||||||
|
import { isDefenceTrainPenaltyWaivedByScenarioEffect } from '@sammo-ts/logic';
|
||||||
|
|
||||||
const SCREEN_MODE_KEY = 'sam.screenMode';
|
const SCREEN_MODE_KEY = 'sam.screenMode';
|
||||||
const CUSTOM_CSS_KEY = 'sam_customCSS';
|
const CUSTOM_CSS_KEY = 'sam_customCSS';
|
||||||
@@ -95,6 +96,13 @@ const statusLine = computed(() =>
|
|||||||
|
|
||||||
const canSave = computed(() => (data.value?.settings.myset ?? 1) > 0);
|
const canSave = computed(() => (data.value?.settings.myset ?? 1) > 0);
|
||||||
const penalties = computed(() => Object.entries(data.value?.penalties ?? {}));
|
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 }>>(() => [
|
const items = computed<Array<{ key: ItemSlotKey; name: string; code: string | null }>>(() => [
|
||||||
{ key: 'horse', name: '말', code: data.value?.general.items.horse ?? null },
|
{ key: 'horse', name: '말', code: data.value?.general.items.horse ?? null },
|
||||||
{ key: 'weapon', name: '무기', code: data.value?.general.items.weapon ?? null },
|
{ key: 'weapon', name: '무기', code: data.value?.general.items.weapon ?? null },
|
||||||
@@ -319,12 +327,16 @@ onMounted(() => {
|
|||||||
|
|
||||||
<label class="setting-line">
|
<label class="setting-line">
|
||||||
수비 【
|
수비 【
|
||||||
<select v-model.number="form.defence_train">
|
<select
|
||||||
<option :value="90">수비 함(훈사90)</option>
|
id="defence_train"
|
||||||
<option :value="80">수비 함(훈사80)</option>
|
v-model.number="form.defence_train"
|
||||||
<option :value="60">수비 함(훈사60)</option>
|
:class="{ 'penalty-waived': noDefencePenaltyWaived }"
|
||||||
<option :value="40">수비 함(훈사40)</option>
|
>
|
||||||
<option :value="999">수비 안함 [훈련 -3, 사기 -6]</option>
|
<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>
|
</select>
|
||||||
】
|
】
|
||||||
</label>
|
</label>
|
||||||
@@ -584,6 +596,12 @@ dt {
|
|||||||
display: block;
|
display: block;
|
||||||
margin-top: 5px;
|
margin-top: 5px;
|
||||||
}
|
}
|
||||||
|
#defence_train {
|
||||||
|
width: 134px;
|
||||||
|
}
|
||||||
|
#defence_train.penalty-waived {
|
||||||
|
width: 86px;
|
||||||
|
}
|
||||||
.hint {
|
.hint {
|
||||||
margin: 0 0 13px;
|
margin: 0 0 13px;
|
||||||
color: orange;
|
color: orange;
|
||||||
|
|||||||
@@ -34,9 +34,36 @@ priority trigger를 의미 이벤트로 바꾸거나, 의미 이벤트를 `Trigg
|
|||||||
`RefOrderedActionStack`의 readonly unique-symbol brand는 임의 배열을 제품용
|
`RefOrderedActionStack`의 readonly unique-symbol brand는 임의 배열을 제품용
|
||||||
표준 stack으로 오인하지 않게 하는 shadow type입니다. 모든 slot을 명시하는
|
표준 stack으로 오인하지 않게 하는 shadow type입니다. 모든 slot을 명시하는
|
||||||
factory에서만 이 brand를 만들 수 있으며, 예약턴 runtime env에도 spread하지
|
factory에서만 이 brand를 만들 수 있으며, 예약턴 runtime env에도 spread하지
|
||||||
않고 그대로 전달합니다. 현재 시나리오 효과 runtime module은 이식되지 않아
|
않고 그대로 전달합니다. `scenarioEffect`가 없으면 scenario slot은
|
||||||
해당 slot은 명시적으로 `null`입니다. 따라서 이 brand는 순서와 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를 함께 재시작합니다.
|
||||||
|
|
||||||
## 닫힌 의미 이벤트
|
## 닫힌 의미 이벤트
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ import {
|
|||||||
WAR_TRAIT_KEYS,
|
WAR_TRAIT_KEYS,
|
||||||
} from './traits/index.js';
|
} from './traits/index.js';
|
||||||
import type { NationTraitModule } from './traits/nation/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> {
|
export interface ActionModuleBundle<TriggerState extends GeneralTriggerState = GeneralTriggerState> {
|
||||||
general: RefOrderedActionStack<GeneralActionModule<TriggerState>>;
|
general: RefOrderedActionStack<GeneralActionModule<TriggerState>>;
|
||||||
@@ -82,7 +84,8 @@ export const createRefOrderedActionStack = <Module>(slots: RefActionSlots<Module
|
|||||||
|
|
||||||
// General::getActionList와 같은 소유권 순서로 실제 턴과 시뮬레이터의 모듈을 조립한다.
|
// General::getActionList와 같은 소유권 순서로 실제 턴과 시뮬레이터의 모듈을 조립한다.
|
||||||
export const loadActionModuleBundle = async <TriggerState extends GeneralTriggerState = GeneralTriggerState>(
|
export const loadActionModuleBundle = async <TriggerState extends GeneralTriggerState = GeneralTriggerState>(
|
||||||
unitSet?: UnitSetDefinition
|
unitSet?: UnitSetDefinition,
|
||||||
|
scenarioEffect?: ScenarioEffectKey | null
|
||||||
): Promise<ActionModuleBundle<TriggerState>> => {
|
): Promise<ActionModuleBundle<TriggerState>> => {
|
||||||
const [domestic, war, personality, nation, itemModules] = await Promise.all([
|
const [domestic, war, personality, nation, itemModules] = await Promise.all([
|
||||||
loadDomesticTraitModules([...DOMESTIC_TRAIT_KEYS]),
|
loadDomesticTraitModules([...DOMESTIC_TRAIT_KEYS]),
|
||||||
@@ -95,6 +98,7 @@ export const loadActionModuleBundle = async <TriggerState extends GeneralTrigger
|
|||||||
const officer = createOfficerLevelActionModules<TriggerState>();
|
const officer = createOfficerLevelActionModules<TriggerState>();
|
||||||
const items = createItemActionModules(createItemModuleRegistry(itemModules));
|
const items = createItemActionModules(createItemModuleRegistry(itemModules));
|
||||||
const inherit = createInheritBuffModules();
|
const inherit = createInheritBuffModules();
|
||||||
|
const scenario = createScenarioEffectActionModules<TriggerState>(scenarioEffect);
|
||||||
const crewTypeCatalog = unitSet?.crewTypes?.length
|
const crewTypeCatalog = unitSet?.crewTypes?.length
|
||||||
? compileCrewTypeCatalog(unitSet, createCrewTypeWarTriggerRegistry())
|
? compileCrewTypeCatalog(unitSet, createCrewTypeWarTriggerRegistry())
|
||||||
: null;
|
: null;
|
||||||
@@ -110,8 +114,7 @@ export const loadActionModuleBundle = async <TriggerState extends GeneralTrigger
|
|||||||
? (crewTypeCatalog.generalActionModule as GeneralActionModule<TriggerState>)
|
? (crewTypeCatalog.generalActionModule as GeneralActionModule<TriggerState>)
|
||||||
: null,
|
: null,
|
||||||
inheritance: inherit.general as GeneralActionModule<TriggerState>,
|
inheritance: inherit.general as GeneralActionModule<TriggerState>,
|
||||||
// scenarioEffect는 현재 core runtime module이 없어 명시적으로 빈 slot입니다.
|
scenario: scenario.general,
|
||||||
scenario: null,
|
|
||||||
items: items.general,
|
items: items.general,
|
||||||
}),
|
}),
|
||||||
war: createRefOrderedActionStack<WarActionModule<TriggerState>>({
|
war: createRefOrderedActionStack<WarActionModule<TriggerState>>({
|
||||||
@@ -122,8 +125,7 @@ export const loadActionModuleBundle = async <TriggerState extends GeneralTrigger
|
|||||||
personality: new TraitWarActionRouter('personality', traitCatalog),
|
personality: new TraitWarActionRouter('personality', traitCatalog),
|
||||||
crewType: crewTypeCatalog ? (crewTypeCatalog.warActionModule as WarActionModule<TriggerState>) : null,
|
crewType: crewTypeCatalog ? (crewTypeCatalog.warActionModule as WarActionModule<TriggerState>) : null,
|
||||||
inheritance: inherit.war as WarActionModule<TriggerState>,
|
inheritance: inherit.war as WarActionModule<TriggerState>,
|
||||||
// ref의 scenarioEffect 위치를 보존하되 미이식 module은 별도 gap으로 남깁니다.
|
scenario: scenario.war,
|
||||||
scenario: null,
|
|
||||||
items: items.war,
|
items: items.war,
|
||||||
}),
|
}),
|
||||||
itemModules,
|
itemModules,
|
||||||
|
|||||||
@@ -3,4 +3,5 @@ export * from './general.js';
|
|||||||
export * from './types.js';
|
export * from './types.js';
|
||||||
export * from './officerLevel.js';
|
export * from './officerLevel.js';
|
||||||
export * from './bundle.js';
|
export * from './bundle.js';
|
||||||
|
export * from './scenarioEffect.js';
|
||||||
export * from './traits/index.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),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -13,7 +13,8 @@ export type TriggerDomesticActionType =
|
|||||||
| '기술'
|
| '기술'
|
||||||
| '모병'
|
| '모병'
|
||||||
| '단련'
|
| '단련'
|
||||||
| '조달';
|
| '조달'
|
||||||
|
| 'changeDefenceTrain';
|
||||||
|
|
||||||
export type TriggerDomesticVarType = 'cost' | 'score' | 'success' | 'fail' | 'train' | 'atmos' | 'rice' | 'probability';
|
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 { UnitSetDefinition } from '@sammo-ts/logic/world/types.js';
|
||||||
import type { NationTraitModule } from '@sammo-ts/logic/actionModules/traits/nation/index.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 { RefOrderedActionStack } from '@sammo-ts/logic/actionModules/bundle.js';
|
||||||
|
import type { ScenarioEffectKey } from '@sammo-ts/logic/scenario/scenarioEffect.js';
|
||||||
|
|
||||||
export interface TurnCommandItemCatalogEntry {
|
export interface TurnCommandItemCatalogEntry {
|
||||||
slot: 'horse' | 'weapon' | 'book' | 'item';
|
slot: 'horse' | 'weapon' | 'book' | 'item';
|
||||||
@@ -17,6 +18,7 @@ export interface TurnCommandItemCatalogEntry {
|
|||||||
|
|
||||||
export interface TurnCommandEnv {
|
export interface TurnCommandEnv {
|
||||||
unitSet?: UnitSetDefinition;
|
unitSet?: UnitSetDefinition;
|
||||||
|
scenarioEffect?: ScenarioEffectKey | null;
|
||||||
develCost: number;
|
develCost: number;
|
||||||
minAvailableRecruitPop?: number;
|
minAvailableRecruitPop?: number;
|
||||||
trainDelta: number;
|
trainDelta: number;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
import { SCENARIO_EFFECT_KEYS } from '../scenario/scenarioEffect.js';
|
||||||
|
|
||||||
export const ScenarioStatBlockSchema = z
|
export const ScenarioStatBlockSchema = z
|
||||||
.object({
|
.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)]);
|
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
|
const ScenarioBodyInputSchema = z
|
||||||
.object({
|
.object({
|
||||||
extends: ScenarioExtendsInputSchema.optional(),
|
extends: ScenarioExtendsInputSchema.optional(),
|
||||||
@@ -29,7 +36,7 @@ const ScenarioBodyInputSchema = z
|
|||||||
iconPath: z.string().optional(),
|
iconPath: z.string().optional(),
|
||||||
stat: ScenarioStatBlockSchema.optional(),
|
stat: ScenarioStatBlockSchema.optional(),
|
||||||
map: z.record(z.string(), z.unknown()).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(),
|
nation: z.array(z.unknown()).optional(),
|
||||||
diplomacy: z.array(z.unknown()).optional(),
|
diplomacy: z.array(z.unknown()).optional(),
|
||||||
general: z.array(z.unknown()).optional(),
|
general: z.array(z.unknown()).optional(),
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
export * from './types.js';
|
export * from './types.js';
|
||||||
export * from './parseScenario.js';
|
export * from './parseScenario.js';
|
||||||
|
export * from './scenarioEffect.js';
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import type {
|
|||||||
ScenarioNation,
|
ScenarioNation,
|
||||||
ScenarioStatBlock,
|
ScenarioStatBlock,
|
||||||
} from './types.js';
|
} from './types.js';
|
||||||
|
import { normalizeScenarioEffect } from './scenarioEffect.js';
|
||||||
|
|
||||||
type UnknownRecord = Record<string, unknown>;
|
type UnknownRecord = Record<string, unknown>;
|
||||||
|
|
||||||
@@ -45,12 +46,9 @@ const parseScenarioEnvironment = (mapConfig: UnknownRecord, constConfig: Unknown
|
|||||||
const merged = { ...mapConfig, ...constConfig };
|
const merged = { ...mapConfig, ...constConfig };
|
||||||
const mapName = asString(merged.mapName, 'che');
|
const mapName = asString(merged.mapName, 'che');
|
||||||
const unitSet = asString(merged.unitSet, 'che');
|
const unitSet = asString(merged.unitSet, 'che');
|
||||||
const scenarioEffect =
|
|
||||||
typeof merged.scenarioEffect === 'string' || merged.scenarioEffect === null ? merged.scenarioEffect : undefined;
|
|
||||||
|
|
||||||
const result: ScenarioEnvironment = { mapName, unitSet };
|
const result: ScenarioEnvironment = { mapName, unitSet };
|
||||||
if (scenarioEffect !== undefined) {
|
if (Object.hasOwn(merged, 'scenarioEffect')) {
|
||||||
result.scenarioEffect = scenarioEffect;
|
result.scenarioEffect = normalizeScenarioEffect(merged.scenarioEffect);
|
||||||
}
|
}
|
||||||
return result;
|
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);
|
||||||
|
};
|
||||||
@@ -16,7 +16,7 @@ export interface ScenarioDefaults {
|
|||||||
export interface ScenarioEnvironment {
|
export interface ScenarioEnvironment {
|
||||||
mapName: string;
|
mapName: string;
|
||||||
unitSet: string;
|
unitSet: string;
|
||||||
scenarioEffect?: string | null;
|
scenarioEffect?: ScenarioEffectKey | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ScenarioConfig {
|
export interface ScenarioConfig {
|
||||||
@@ -86,3 +86,4 @@ export interface ScenarioDefinition {
|
|||||||
initialEvents: unknown[];
|
initialEvents: unknown[];
|
||||||
ignoreDefaultEvents: boolean;
|
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', () => {
|
describe('typed general action events', () => {
|
||||||
it('folds synchronous event handlers in the supplied ref ownership order', () => {
|
it('folds synchronous event handlers in the supplied ref ownership order', () => {
|
||||||
const trace: string[] = [];
|
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) => ({
|
const modules: GeneralActionModule[] = names.map((name) => ({
|
||||||
eventHandlers: {
|
eventHandlers: {
|
||||||
'strategy.succeeded': (_context, event) => {
|
'strategy.succeeded': (_context, event) => {
|
||||||
@@ -56,8 +56,8 @@ describe('typed general action events', () => {
|
|||||||
personality: modules[4]!,
|
personality: modules[4]!,
|
||||||
crewType: modules[5]!,
|
crewType: modules[5]!,
|
||||||
inheritance: modules[6]!,
|
inheritance: modules[6]!,
|
||||||
scenario: null,
|
scenario: modules[7]!,
|
||||||
items: [modules[7]!],
|
items: [modules[8]!],
|
||||||
});
|
});
|
||||||
const pipeline = new GeneralActionPipeline(stack);
|
const pipeline = new GeneralActionPipeline(stack);
|
||||||
|
|
||||||
|
|||||||
@@ -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',
|
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 type { WorldSnapshot } from '../../src/world/types.js';
|
||||||
import { commandSpec as developAgricultureSpec } from '../../src/actions/turn/general/che_농지개간.js';
|
import { commandSpec as developAgricultureSpec } from '../../src/actions/turn/general/che_농지개간.js';
|
||||||
import type { TurnCommandEnv } from '../../src/actions/turn/commandEnv.js';
|
import type { TurnCommandEnv } from '../../src/actions/turn/commandEnv.js';
|
||||||
|
import { loadActionModuleBundle } from '../../src/actionModules/bundle.js';
|
||||||
|
|
||||||
describe('Domestic Affairs Scenario', () => {
|
describe('Domestic Affairs Scenario', () => {
|
||||||
it('should increase agriculture when executing "Farming" command', async () => {
|
it('should increase agriculture when executing "Farming" command', async () => {
|
||||||
@@ -97,6 +98,8 @@ describe('Domestic Affairs Scenario', () => {
|
|||||||
events: [],
|
events: [],
|
||||||
initialEvents: [],
|
initialEvents: [],
|
||||||
};
|
};
|
||||||
|
const moduleBaselineSnapshot = structuredClone(snapshot);
|
||||||
|
const moreEffectSnapshot = structuredClone(snapshot);
|
||||||
|
|
||||||
const world = new InMemoryWorld(snapshot);
|
const world = new InMemoryWorld(snapshot);
|
||||||
const runner = new TestGameRunner(world, 200, 1);
|
const runner = new TestGameRunner(world, 200, 1);
|
||||||
@@ -143,6 +146,39 @@ describe('Domestic Affairs Scenario', () => {
|
|||||||
const updatedCity = world.getCity(1)!;
|
const updatedCity = world.getCity(1)!;
|
||||||
// 레거시 che_농지개간: 능력치·경험등급·0.8~1.2 난수·성공 배율을 모두 반영한다.
|
// 레거시 che_농지개간: 능력치·경험등급·0.8~1.2 난수·성공 배율을 모두 반영한다.
|
||||||
expect(updatedCity.agriculture).toBe(664);
|
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 () => {
|
it('should not increase agriculture when city is already maxed', async () => {
|
||||||
|
|||||||
@@ -110,8 +110,30 @@
|
|||||||
},
|
},
|
||||||
"const": {
|
"const": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"propertyNames": {
|
"properties": {
|
||||||
"type": "string"
|
"scenarioEffect": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"event_UnlimitedDefenceThresholdChange",
|
||||||
|
"event_StrongAttacker",
|
||||||
|
"event_MoreEffect"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"const": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"const": "None"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"additionalProperties": {}
|
"additionalProperties": {}
|
||||||
},
|
},
|
||||||
@@ -236,8 +258,30 @@
|
|||||||
},
|
},
|
||||||
"const": {
|
"const": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"propertyNames": {
|
"properties": {
|
||||||
"type": "string"
|
"scenarioEffect": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"event_UnlimitedDefenceThresholdChange",
|
||||||
|
"event_StrongAttacker",
|
||||||
|
"event_MoreEffect"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"const": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"const": "None"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"additionalProperties": {}
|
"additionalProperties": {}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { LiteHashDRBG, RandUtil, type RNG } from '@sammo-ts/common';
|
|||||||
import {
|
import {
|
||||||
GENERAL_TURN_COMMAND_KEYS,
|
GENERAL_TURN_COMMAND_KEYS,
|
||||||
NATION_TURN_COMMAND_KEYS,
|
NATION_TURN_COMMAND_KEYS,
|
||||||
|
normalizeScenarioEffect,
|
||||||
type MapDefinition,
|
type MapDefinition,
|
||||||
type Nation,
|
type Nation,
|
||||||
type TurnCommandProfile,
|
type TurnCommandProfile,
|
||||||
@@ -51,6 +52,7 @@ export interface TurnCommandFixtureRequest {
|
|||||||
year?: number;
|
year?: number;
|
||||||
month?: number;
|
month?: number;
|
||||||
hiddenSeed?: string;
|
hiddenSeed?: string;
|
||||||
|
scenarioEffect?: string | null;
|
||||||
staticEventHandlers?: Record<string, string[]>;
|
staticEventHandlers?: Record<string, string[]>;
|
||||||
};
|
};
|
||||||
isolateWorld?: boolean;
|
isolateWorld?: boolean;
|
||||||
@@ -428,7 +430,13 @@ export const buildCoreTurnCommandWorldInput = (
|
|||||||
? { staticEventHandlers: request.setup.world.staticEventHandlers }
|
? { 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: {
|
scenarioMeta: {
|
||||||
title: '턴 명령 차등',
|
title: '턴 명령 차등',
|
||||||
|
|||||||
@@ -25,6 +25,20 @@ const withProjectedTraceMeta = (trace: CanonicalTurnCommandTrace): CanonicalTurn
|
|||||||
after: withProjectedGeneralMeta(trace.after),
|
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 => {
|
export const findTurnDifferentialWorkspaceRoot = (start: string): string | null => {
|
||||||
let current = path.resolve(start);
|
let current = path.resolve(start);
|
||||||
while (true) {
|
while (true) {
|
||||||
@@ -82,8 +96,7 @@ export const runReferenceTurnCommandTrace = (workspaceRoot: string, fixturePath:
|
|||||||
encoding: 'utf8',
|
encoding: 'utf8',
|
||||||
stdio: ['ignore', 'pipe', 'pipe'],
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
env: {
|
env: {
|
||||||
...process.env,
|
...referenceRunnerEnvironment(workspaceRoot, stackDirectory),
|
||||||
TURN_DIFFERENTIAL_STACK_DIR: stackDirectory,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return withProjectedTraceMeta(JSON.parse(stdout) as CanonicalTurnCommandTrace);
|
return withProjectedTraceMeta(JSON.parse(stdout) as CanonicalTurnCommandTrace);
|
||||||
@@ -101,8 +114,7 @@ export const runReferenceTurnCommandTraceRequest = (
|
|||||||
encoding: 'utf8',
|
encoding: 'utf8',
|
||||||
stdio: ['pipe', 'pipe', 'pipe'],
|
stdio: ['pipe', 'pipe', 'pipe'],
|
||||||
env: {
|
env: {
|
||||||
...process.env,
|
...referenceRunnerEnvironment(workspaceRoot, stackDirectory),
|
||||||
TURN_DIFFERENTIAL_STACK_DIR: stackDirectory,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return withProjectedTraceMeta(JSON.parse(stdout) as CanonicalTurnCommandTrace);
|
return withProjectedTraceMeta(JSON.parse(stdout) as CanonicalTurnCommandTrace);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { execFileSync } from 'node:child_process';
|
import { execFileSync } from 'node:child_process';
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
|
import os from 'node:os';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
|
||||||
import { LiteHashDRBG, RandUtil, type RNG } from '@sammo-ts/common';
|
import { LiteHashDRBG, RandUtil, type RNG } from '@sammo-ts/common';
|
||||||
@@ -28,6 +29,21 @@ interface ReferenceTrace {
|
|||||||
conquered: boolean;
|
conquered: boolean;
|
||||||
events: WarBattleTraceEvent[];
|
events: WarBattleTraceEvent[];
|
||||||
rng: RandomCall[];
|
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 {
|
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 readJson = <T>(filePath: string): T => JSON.parse(fs.readFileSync(filePath, 'utf8')) as T;
|
||||||
|
|
||||||
const runReferenceTrace = (workspaceRoot: string, fixtureJson: string): ReferenceTrace => {
|
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(
|
const stdout = execFileSync(
|
||||||
'docker',
|
'docker',
|
||||||
['compose', 'exec', '-T', 'php', 'php', '/var/www/html/hwe/compare/battle_trace.php', '-'],
|
['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);
|
).toBeLessThanOrEqual(tolerance);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const normalizeRandomArguments = (value: Record<string, unknown>): Record<string, unknown> =>
|
||||||
|
Array.isArray(value) && value.length === 0 ? {} : value;
|
||||||
|
|
||||||
const assertTraceParity = (
|
const assertTraceParity = (
|
||||||
coreEvents: WarBattleTraceEvent[],
|
coreEvents: WarBattleTraceEvent[],
|
||||||
reference: ReferenceTrace,
|
reference: ReferenceTrace,
|
||||||
@@ -176,8 +249,20 @@ const assertTraceParity = (
|
|||||||
coreEventNames,
|
coreEventNames,
|
||||||
`event sequence\ncore=${JSON.stringify(coreEventNames)}\nref=${JSON.stringify(referenceEventNames)}`
|
`event sequence\ncore=${JSON.stringify(coreEventNames)}\nref=${JSON.stringify(referenceEventNames)}`
|
||||||
).toEqual(referenceEventNames);
|
).toEqual(referenceEventNames);
|
||||||
expect(coreRng?.calls.map(({ operation, result }) => ({ operation, result }))).toEqual(
|
expect(
|
||||||
reference.rng.map(({ operation, result }) => ({ operation, result }))
|
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) {
|
for (let index = 0; index < reference.events.length; index += 1) {
|
||||||
@@ -185,10 +270,16 @@ const assertTraceParity = (
|
|||||||
const ref = reference.events[index]!;
|
const ref = reference.events[index]!;
|
||||||
expectNearlyEqual(core.attacker.hp, ref.attacker.hp, `event ${index} attacker.hp`);
|
expectNearlyEqual(core.attacker.hp, ref.attacker.hp, `event ${index} attacker.hp`);
|
||||||
expectNearlyEqual(core.attacker.warPower, ref.attacker.warPower, `event ${index} attacker.warPower`);
|
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) {
|
if (core.defender && ref.defender) {
|
||||||
expect(core.defender.kind, `event ${index} defender.kind`).toBe(ref.defender.kind);
|
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.hp, ref.defender.hp, `event ${index} defender.hp`);
|
||||||
expectNearlyEqual(core.defender.warPower, ref.defender.warPower, `event ${index} defender.warPower`);
|
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 {
|
} else {
|
||||||
expect(core.defender, `event ${index} defender presence`).toBe(ref.defender);
|
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 configuredWorkspaceRoot = process.env.TURN_DIFFERENTIAL_WORKSPACE_ROOT;
|
||||||
const workspaceRoot = configuredWorkspaceRoot ?? findWorkspaceRoot(process.cwd());
|
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;
|
const describeWithReference = workspaceRoot ? describe : describe.skip;
|
||||||
|
|
||||||
describeWithReference('ref ↔ core2026 battle differential', () => {
|
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 () => {
|
it('matches every legacy trait name and description', async () => {
|
||||||
const reference = runReferenceTraitCatalog(workspaceRoot!);
|
const reference = runReferenceTraitCatalog(workspaceRoot!);
|
||||||
const [nation, domestic, war, personality] = await Promise.all([
|
const [nation, domestic, war, personality] = await Promise.all([
|
||||||
@@ -285,9 +504,12 @@ describeWithReference('ref ↔ core2026 battle differential', () => {
|
|||||||
try {
|
try {
|
||||||
assertTraceParity(coreEvents, runReferenceTrace(workspaceRoot!, JSON.stringify(base)), coreRng);
|
assertTraceParity(coreEvents, runReferenceTrace(workspaceRoot!, JSON.stringify(base)), coreRng);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new Error(`${entry.kind}/${entry.key}: ${error instanceof Error ? error.message : String(error)}`, {
|
throw new Error(
|
||||||
|
`${entry.kind}/${entry.key}: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
|
{
|
||||||
cause: error,
|
cause: error,
|
||||||
});
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -806,11 +1028,15 @@ describeWithReference('ref ↔ core2026 battle differential', () => {
|
|||||||
? `\ncore=${JSON.stringify(
|
? `\ncore=${JSON.stringify(
|
||||||
coreEvents
|
coreEvents
|
||||||
.map((event, index) => ({ index, event }))
|
.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(
|
)}\nref=${JSON.stringify(
|
||||||
reference.events
|
reference.events
|
||||||
.map((event, index) => ({ index, event }))
|
.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}`);
|
failures.push(`${itemKey}: ${error instanceof Error ? error.message : String(error)}${debug}`);
|
||||||
@@ -910,11 +1136,15 @@ describeWithReference('ref ↔ core2026 battle differential', () => {
|
|||||||
? `\ncore=${JSON.stringify(
|
? `\ncore=${JSON.stringify(
|
||||||
coreEvents
|
coreEvents
|
||||||
.map((event, index) => ({ index, event }))
|
.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(
|
)}\nref=${JSON.stringify(
|
||||||
reference.events
|
reference.events
|
||||||
.map((event, index) => ({ index, event }))
|
.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}`);
|
failures.push(`${itemKey}: ${error instanceof Error ? error.message : String(error)}${debug}`);
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
} from '@sammo-ts/game-engine/turn/reservedTurnStore.js';
|
} from '@sammo-ts/game-engine/turn/reservedTurnStore.js';
|
||||||
import { loadMapDefinitionByName } from '@sammo-ts/game-engine/scenario/mapLoader.js';
|
import { loadMapDefinitionByName } from '@sammo-ts/game-engine/scenario/mapLoader.js';
|
||||||
import { loadUnitSetDefinitionByName } from '@sammo-ts/game-engine/scenario/unitSetLoader.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 { createGamePostgresConnector, type GamePrismaClient, type InputJsonValue } from '@sammo-ts/infra';
|
||||||
|
|
||||||
import {
|
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(
|
const fixturePath = path.join(
|
||||||
workspaceRoot!,
|
workspaceRoot!,
|
||||||
`docker_compose_files/reference/fixtures/turn-differential/${fixtureName}`
|
`docker_compose_files/reference/fixtures/turn-differential/${fixtureName}`
|
||||||
@@ -64,6 +65,7 @@ const readFixture = (fixtureName: string): TurnCommandFixtureRequest => {
|
|||||||
world: {
|
world: {
|
||||||
...fixture.setup?.world,
|
...fixture.setup?.world,
|
||||||
hiddenSeed: 'turn-command-differential-seed',
|
hiddenSeed: 'turn-command-differential-seed',
|
||||||
|
...(scenarioEffect ? { scenarioEffect } : {}),
|
||||||
},
|
},
|
||||||
generals: fixture.setup?.generals?.map((general) => ({
|
generals: fixture.setup?.generals?.map((general) => ({
|
||||||
...general,
|
...general,
|
||||||
@@ -117,6 +119,42 @@ integration('live sortie PostgreSQL persistence retry', () => {
|
|||||||
await disconnect?.();
|
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([
|
it.each([
|
||||||
['conquest and nation collapse', 'live-sortie-conquest.json'],
|
['conquest and nation collapse', 'live-sortie-conquest.json'],
|
||||||
['collapsed nation conflict cleanup', 'live-sortie-collapse-conflict.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'],
|
['emergency capital', 'live-sortie-emergency-capital.json'],
|
||||||
['conflict arbitration', 'live-sortie-conflict-arbitration.json'],
|
['conflict arbitration', 'live-sortie-conflict-arbitration.json'],
|
||||||
['tied conflict', 'live-sortie-conflict-tie.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',
|
'%s rolls back a lost lease and flushes exactly once on retry',
|
||||||
async (_label, fixtureName) => {
|
async (_label, fixtureName, scenarioEffect?: string) => {
|
||||||
const request = readFixture(fixtureName);
|
const request = readFixture(fixtureName, scenarioEffect);
|
||||||
const reference = runReferenceTurnCommandTraceRequest(
|
const reference = runReferenceTurnCommandTraceRequest(
|
||||||
workspaceRoot!,
|
workspaceRoot!,
|
||||||
request as unknown as Record<string, unknown>
|
request as unknown as Record<string, unknown>
|
||||||
);
|
);
|
||||||
const expected = await runCoreTurnCommandTrace(request, reference.before);
|
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 coreArgs = resolveCoreTurnCommandArgs(request);
|
||||||
const unitSet = await loadUnitSetDefinitionByName('che');
|
const unitSet = await loadUnitSetDefinitionByName('che');
|
||||||
const map = await loadMapDefinitionByName('che');
|
const map = await loadMapDefinitionByName('che');
|
||||||
@@ -431,6 +479,8 @@ integration('live sortie PostgreSQL persistence retry', () => {
|
|||||||
revision: committedRevision.revision,
|
revision: committedRevision.revision,
|
||||||
leaseOwner: committedRevision.leaseOwner,
|
leaseOwner: committedRevision.leaseOwner,
|
||||||
});
|
});
|
||||||
|
const reloaded = await loadTurnWorldFromDatabase({ databaseUrl: databaseUrl! });
|
||||||
|
expect(reloaded.snapshot.scenarioConfig.environment.scenarioEffect).toBe(scenarioEffect ?? null);
|
||||||
} finally {
|
} finally {
|
||||||
await hooks.close();
|
await hooks.close();
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user