feat: port scenario action effects
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import type { WorldStateRow } from '../context.js';
|
||||
import type { BattleSimJobPayload, BattleSimRequestPayload } from './types.js';
|
||||
import { loadUnitSetDefinitionByName } from './unitSetLoader.js';
|
||||
import type { WarEngineConfig } from '@sammo-ts/logic';
|
||||
import { normalizeScenarioEffect, type ScenarioEffectKey, type WarEngineConfig } from '@sammo-ts/logic';
|
||||
import { asRecord } from '@sammo-ts/common';
|
||||
import type { UnitSetDefinition } from '@sammo-ts/logic';
|
||||
|
||||
@@ -79,6 +79,7 @@ export interface BattleSimEnvironment {
|
||||
unitSet: UnitSetDefinition;
|
||||
config: WarEngineConfig;
|
||||
startYear: number;
|
||||
scenarioEffect: ScenarioEffectKey | null;
|
||||
}
|
||||
|
||||
export const buildBattleSimEnvironment = async (
|
||||
@@ -89,6 +90,7 @@ export const buildBattleSimEnvironment = async (
|
||||
const unitSet = await loadUnitSetDefinitionByName(unitSetName);
|
||||
|
||||
const configRecord = asRecord(worldState.config);
|
||||
const scenarioEnvironment = asRecord(configRecord.environment ?? configRecord.map);
|
||||
const constValues = asRecord(configRecord.const ?? configRecord.consts);
|
||||
const castleCrewTypeId = resolveNumber(constValues, ['castleCrewTypeId'], resolveCastleCrewTypeId(unitSet));
|
||||
const castleArmType = resolveCastleArmType(unitSet, castleCrewTypeId);
|
||||
@@ -117,6 +119,7 @@ export const buildBattleSimEnvironment = async (
|
||||
unitSet,
|
||||
config,
|
||||
startYear: resolveStartYear(worldState),
|
||||
scenarioEffect: normalizeScenarioEffect(scenarioEnvironment.scenarioEffect),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -136,5 +139,6 @@ export const buildBattleSimJobPayload = async (
|
||||
month: request.month,
|
||||
startYear: environment.startYear,
|
||||
},
|
||||
scenarioEffect: environment.scenarioEffect,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
createItemActionModules,
|
||||
createItemModuleRegistry,
|
||||
createRefOrderedActionStack,
|
||||
createScenarioEffectActionModules,
|
||||
ITEM_KEYS,
|
||||
loadItemModules,
|
||||
createInheritBuffModules,
|
||||
@@ -62,8 +63,12 @@ const domesticWarModule = new TraitWarActionRouter('domestic', traitCatalog);
|
||||
const warTraitModule = new TraitWarActionRouter('war', traitCatalog);
|
||||
const personalityWarModule = new TraitWarActionRouter('personality', traitCatalog);
|
||||
|
||||
const buildWarActionModules = (unitSet: UnitSetDefinition): RefOrderedActionStack<WarActionModule> => {
|
||||
const buildWarActionModules = (
|
||||
unitSet: UnitSetDefinition,
|
||||
scenarioEffect?: string | null
|
||||
): RefOrderedActionStack<WarActionModule> => {
|
||||
const crewTypeCatalog = compileCrewTypeCatalog(unitSet, crewTypeWarTriggerRegistry);
|
||||
const scenario = createScenarioEffectActionModules(scenarioEffect);
|
||||
return createRefOrderedActionStack<WarActionModule>({
|
||||
nation: nationWarModule,
|
||||
officer: officerWarModule,
|
||||
@@ -72,7 +77,7 @@ const buildWarActionModules = (unitSet: UnitSetDefinition): RefOrderedActionStac
|
||||
personality: personalityWarModule,
|
||||
crewType: crewTypeCatalog.warActionModule,
|
||||
inheritance: inheritBuffModules.war,
|
||||
scenario: null,
|
||||
scenario: scenario.war,
|
||||
items: itemWarModules,
|
||||
});
|
||||
};
|
||||
@@ -296,7 +301,7 @@ const resolveDefenderOrderPayload = (payload: BattleSimJobPayload): number[] =>
|
||||
const defenderCity = mapCityPayload(payload.defenderCity);
|
||||
const attacker = mapGeneralPayload(payload.attackerGeneral);
|
||||
const defenders = payload.defenderGenerals.map(mapGeneralPayload);
|
||||
const warActionModules = buildWarActionModules(payload.unitSet);
|
||||
const warActionModules = buildWarActionModules(payload.unitSet, payload.scenarioEffect);
|
||||
|
||||
return resolveDefenderOrder({
|
||||
unitSet: payload.unitSet,
|
||||
@@ -338,7 +343,7 @@ export const processBattleSimJob = (
|
||||
}
|
||||
|
||||
let repeatCnt = payload.repeatCnt;
|
||||
const warActionModules = buildWarActionModules(payload.unitSet);
|
||||
const warActionModules = buildWarActionModules(payload.unitSet, payload.scenarioEffect);
|
||||
const baseSeed = payload.seed ?? '';
|
||||
if (baseSeed) {
|
||||
repeatCnt = 1;
|
||||
|
||||
@@ -100,6 +100,7 @@ export interface BattleSimJobPayload extends BattleSimRequestPayload {
|
||||
unitSet: UnitSetDefinition;
|
||||
config: WarEngineConfig;
|
||||
time: WarTimeContext;
|
||||
scenarioEffect?: string | null;
|
||||
}
|
||||
|
||||
export interface BattleSimLogBuckets {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { z } from 'zod';
|
||||
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
|
||||
import type { DatabaseClient as InfraDatabaseClient, RedisConnector, GamePrisma } from '@sammo-ts/infra';
|
||||
import { normalizeScenarioEffect, SCENARIO_EFFECT_KEYS } from '@sammo-ts/logic';
|
||||
|
||||
import type { TurnDaemonTransport } from './daemon/transport.js';
|
||||
import type { BattleSimTransport } from './battleSim/transport.js';
|
||||
@@ -25,6 +26,14 @@ export const zWorldStateConfig = z.object({
|
||||
extendedGeneral: z.boolean().optional(),
|
||||
turnTermMinutes: z.number().optional(),
|
||||
syncTurnTime: z.boolean().optional(),
|
||||
environment: z
|
||||
.object({
|
||||
scenarioEffect: z
|
||||
.union([z.enum(SCENARIO_EFFECT_KEYS), z.literal(''), z.literal('None'), z.null()])
|
||||
.transform(normalizeScenarioEffect)
|
||||
.optional(),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
export type WorldStateConfig = z.infer<typeof zWorldStateConfig>;
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ const buildPayload = (action: BattleSimJobPayload['action']): BattleSimJobPayloa
|
||||
year: 200,
|
||||
month: 1,
|
||||
seed: 'test-seed',
|
||||
scenarioEffect: null,
|
||||
attackerGeneral: {
|
||||
no: 1,
|
||||
name: 'Attacker',
|
||||
@@ -250,6 +251,14 @@ describe('battle sim processor', () => {
|
||||
expect(result.lastWarLog?.generalActionLog).toContain('퇴각했습니다.');
|
||||
});
|
||||
|
||||
it('treats a queued job from before scenarioEffect existed as the no-effect baseline', () => {
|
||||
const baselinePayload = buildPayload('battle');
|
||||
const legacyPayload = buildPayload('battle');
|
||||
delete legacyPayload.scenarioEffect;
|
||||
|
||||
expect(processBattleSimJob(legacyPayload)).toEqual(processBattleSimJob(baselinePayload));
|
||||
});
|
||||
|
||||
it('returns the fixed defender ID order for reorder action', () => {
|
||||
const payload = buildPayload('reorder');
|
||||
const result = processBattleSimJob(payload);
|
||||
@@ -281,4 +290,53 @@ describe('battle sim processor', () => {
|
||||
|
||||
expect(() => processBattleSimJob(payload)).toThrow('Unknown crew type action');
|
||||
});
|
||||
|
||||
it('applies StrongAttacker to general combat in the server-enriched simulator job', () => {
|
||||
const baseline = processBattleSimJob(buildPayload('battle'));
|
||||
const payload = buildPayload('battle');
|
||||
payload.scenarioEffect = 'event_StrongAttacker';
|
||||
const strong = processBattleSimJob(payload);
|
||||
|
||||
expect(strong.killed).toBeGreaterThan(baseline.killed ?? 0);
|
||||
});
|
||||
|
||||
it('keeps StrongAttacker city combat identical but applies MoreEffect to it', () => {
|
||||
const baselinePayload = buildPayload('battle');
|
||||
baselinePayload.defenderGenerals = [];
|
||||
const baseline = processBattleSimJob(baselinePayload);
|
||||
|
||||
const strongPayload = buildPayload('battle');
|
||||
strongPayload.defenderGenerals = [];
|
||||
strongPayload.scenarioEffect = 'event_StrongAttacker';
|
||||
expect(processBattleSimJob(strongPayload)).toEqual(baseline);
|
||||
|
||||
const morePayload = buildPayload('battle');
|
||||
morePayload.defenderGenerals = [];
|
||||
morePayload.scenarioEffect = 'event_MoreEffect';
|
||||
const more = processBattleSimJob(morePayload);
|
||||
expect(more.dead).toBeLessThan(baseline.dead ?? Number.POSITIVE_INFINITY);
|
||||
});
|
||||
|
||||
it('fails fast for an unknown server-derived scenario effect', () => {
|
||||
const payload = buildPayload('battle');
|
||||
payload.scenarioEffect = 'event_Missing';
|
||||
expect(() => processBattleSimJob(payload)).toThrow('Unknown scenario effect: event_Missing');
|
||||
});
|
||||
|
||||
it('runs the advance trigger when a progressed attacker meets the next fresh defender', () => {
|
||||
const payload = buildPayload('battle');
|
||||
payload.scenarioEffect = 'event_StrongAttacker';
|
||||
payload.defenderGenerals[0]!.crew = 100;
|
||||
payload.defenderGenerals.push({
|
||||
...payload.defenderGenerals[0]!,
|
||||
no: 3,
|
||||
name: 'Next Defender',
|
||||
crew: 1000,
|
||||
});
|
||||
|
||||
const result = processBattleSimJob(payload);
|
||||
expect(result.lastWarLog?.generalBattleDetailLog).toContain(
|
||||
'적군의 전멸에 <font color=cyan>진격</font>이 이어집니다!'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -265,7 +265,7 @@ describe('battle router orchestration', () => {
|
||||
currentYear: 200,
|
||||
currentMonth: 1,
|
||||
tickSeconds: 600,
|
||||
config: {},
|
||||
config: { environment: { scenarioEffect: 'event_MoreEffect' } },
|
||||
meta: {},
|
||||
updatedAt: new Date('2026-01-01T00:00:00Z'),
|
||||
};
|
||||
@@ -275,6 +275,7 @@ describe('battle router orchestration', () => {
|
||||
expect(response.status).toBe('queued');
|
||||
expect(battleSim.simulateCalls).toBe(1);
|
||||
expect(battleSim.lastRequesterUserId).toBe('user-1');
|
||||
expect(battleSim.lastPayload?.scenarioEffect).toBe('event_MoreEffect');
|
||||
|
||||
const queued = await caller.battle.getSimulation({ jobId: response.jobId });
|
||||
expect(queued.status).toBe('queued');
|
||||
@@ -286,6 +287,48 @@ describe('battle router orchestration', () => {
|
||||
expect(completed.payload?.result).toBe(true);
|
||||
});
|
||||
|
||||
it('uses the stored scenario effect even when a client sends a same-named field', async () => {
|
||||
const battleSim = new QueuedBattleSimTransport();
|
||||
const state: WorldStateRow = {
|
||||
id: 1,
|
||||
scenarioCode: 'default',
|
||||
currentYear: 200,
|
||||
currentMonth: 1,
|
||||
tickSeconds: 600,
|
||||
config: { environment: { scenarioEffect: 'event_MoreEffect' } },
|
||||
meta: {},
|
||||
updatedAt: new Date('2026-01-01T00:00:00Z'),
|
||||
};
|
||||
const caller = appRouter.createCaller(buildContext({ state, battleSim }));
|
||||
const maliciousRequest = {
|
||||
...buildBattleRequest(),
|
||||
scenarioEffect: 'event_StrongAttacker',
|
||||
} as ReturnType<typeof buildBattleRequest>;
|
||||
|
||||
await expect(caller.battle.simulate(maliciousRequest)).resolves.toMatchObject({ status: 'queued' });
|
||||
expect(battleSim.lastPayload?.scenarioEffect).toBe('event_MoreEffect');
|
||||
});
|
||||
|
||||
it('rejects an unknown stored scenario effect before queuing the simulation', async () => {
|
||||
const battleSim = new QueuedBattleSimTransport();
|
||||
const state: WorldStateRow = {
|
||||
id: 1,
|
||||
scenarioCode: 'default',
|
||||
currentYear: 200,
|
||||
currentMonth: 1,
|
||||
tickSeconds: 600,
|
||||
config: { environment: { scenarioEffect: 'event_Missing' } },
|
||||
meta: {},
|
||||
updatedAt: new Date('2026-01-01T00:00:00Z'),
|
||||
};
|
||||
const caller = appRouter.createCaller(buildContext({ state, battleSim }));
|
||||
|
||||
await expect(caller.battle.simulate(buildBattleRequest())).rejects.toThrow(
|
||||
'Unknown scenario effect: event_Missing'
|
||||
);
|
||||
expect(battleSim.simulateCalls).toBe(0);
|
||||
});
|
||||
|
||||
it('requires login, allows a user without a general, and does not open an input-event transaction', async () => {
|
||||
const battleSim = new QueuedBattleSimTransport();
|
||||
const state: WorldStateRow = {
|
||||
|
||||
@@ -51,6 +51,7 @@ liveDescribe('battle simulator worker with live Redis', () => {
|
||||
unitSet: environment.unitSet,
|
||||
config: environment.config,
|
||||
time: { year: request.year, month: request.month, startYear },
|
||||
scenarioEffect: environment.scenarioEffect,
|
||||
};
|
||||
|
||||
const clientConnector = createRedisConnector(resolveRedisConfigFromEnv());
|
||||
|
||||
@@ -350,7 +350,14 @@ describe('appRouter', () => {
|
||||
currentYear: 1,
|
||||
currentMonth: 2,
|
||||
tickSeconds: 600,
|
||||
config: { maxUserCnt: 500, hiddenSeed: 'config-secret' },
|
||||
config: {
|
||||
maxUserCnt: 500,
|
||||
hiddenSeed: 'config-secret',
|
||||
environment: {
|
||||
scenarioEffect: 'event_StrongAttacker',
|
||||
hiddenSeed: 'environment-secret',
|
||||
},
|
||||
},
|
||||
meta: { otherTextInfo: 'sample', hiddenSeed: 'meta-secret' },
|
||||
updatedAt: new Date('2026-01-01T00:00:00Z'),
|
||||
};
|
||||
@@ -360,11 +367,50 @@ describe('appRouter', () => {
|
||||
|
||||
expect(response?.scenarioCode).toBe('default');
|
||||
expect(response?.currentYear).toBe(1);
|
||||
expect(response?.config).toEqual({ maxUserCnt: 500 });
|
||||
expect(response?.config).toEqual({
|
||||
maxUserCnt: 500,
|
||||
environment: { scenarioEffect: 'event_StrongAttacker' },
|
||||
});
|
||||
expect(response?.meta).toEqual({ otherTextInfo: 'sample' });
|
||||
expect(response?.updatedAt).toBe('2026-01-01T00:00:00.000Z');
|
||||
});
|
||||
|
||||
it.each(['', 'None', null])('normalizes the persisted no-effect sentinel %j in world snapshots', async (value) => {
|
||||
const state: WorldStateRow = {
|
||||
id: 1,
|
||||
scenarioCode: 'default',
|
||||
currentYear: 1,
|
||||
currentMonth: 2,
|
||||
tickSeconds: 600,
|
||||
config: { environment: { scenarioEffect: value } },
|
||||
meta: {},
|
||||
updatedAt: new Date('2026-01-01T00:00:00Z'),
|
||||
};
|
||||
|
||||
const caller = appRouter.createCaller(buildContext({ state }));
|
||||
|
||||
await expect(caller.world.getState()).resolves.toMatchObject({
|
||||
config: { environment: { scenarioEffect: null } },
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects unknown persisted scenario effects in world snapshots', async () => {
|
||||
const state: WorldStateRow = {
|
||||
id: 1,
|
||||
scenarioCode: 'default',
|
||||
currentYear: 1,
|
||||
currentMonth: 2,
|
||||
tickSeconds: 600,
|
||||
config: { environment: { scenarioEffect: 'event_Missing' } },
|
||||
meta: {},
|
||||
updatedAt: new Date('2026-01-01T00:00:00Z'),
|
||||
};
|
||||
|
||||
const caller = appRouter.createCaller(buildContext({ state }));
|
||||
|
||||
await expect(caller.world.getState()).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('requires profile administration permission for turn daemon control', async () => {
|
||||
const auth: GameSessionTokenPayload = {
|
||||
version: 1,
|
||||
|
||||
Reference in New Issue
Block a user