feat: port scenario action effects

This commit is contained in:
2026-07-30 19:00:04 +00:00
parent 7f31459385
commit c68d992046
39 changed files with 1131 additions and 95 deletions
@@ -9,6 +9,7 @@ const buildPayload = (action: BattleSimJobPayload['action']): BattleSimJobPayloa
year: 200,
month: 1,
seed: 'test-seed',
scenarioEffect: null,
attackerGeneral: {
no: 1,
name: 'Attacker',
@@ -250,6 +251,14 @@ describe('battle sim processor', () => {
expect(result.lastWarLog?.generalActionLog).toContain('퇴각했습니다.');
});
it('treats a queued job from before scenarioEffect existed as the no-effect baseline', () => {
const baselinePayload = buildPayload('battle');
const legacyPayload = buildPayload('battle');
delete legacyPayload.scenarioEffect;
expect(processBattleSimJob(legacyPayload)).toEqual(processBattleSimJob(baselinePayload));
});
it('returns the fixed defender ID order for reorder action', () => {
const payload = buildPayload('reorder');
const result = processBattleSimJob(payload);
@@ -281,4 +290,53 @@ describe('battle sim processor', () => {
expect(() => processBattleSimJob(payload)).toThrow('Unknown crew type action');
});
it('applies StrongAttacker to general combat in the server-enriched simulator job', () => {
const baseline = processBattleSimJob(buildPayload('battle'));
const payload = buildPayload('battle');
payload.scenarioEffect = 'event_StrongAttacker';
const strong = processBattleSimJob(payload);
expect(strong.killed).toBeGreaterThan(baseline.killed ?? 0);
});
it('keeps StrongAttacker city combat identical but applies MoreEffect to it', () => {
const baselinePayload = buildPayload('battle');
baselinePayload.defenderGenerals = [];
const baseline = processBattleSimJob(baselinePayload);
const strongPayload = buildPayload('battle');
strongPayload.defenderGenerals = [];
strongPayload.scenarioEffect = 'event_StrongAttacker';
expect(processBattleSimJob(strongPayload)).toEqual(baseline);
const morePayload = buildPayload('battle');
morePayload.defenderGenerals = [];
morePayload.scenarioEffect = 'event_MoreEffect';
const more = processBattleSimJob(morePayload);
expect(more.dead).toBeLessThan(baseline.dead ?? Number.POSITIVE_INFINITY);
});
it('fails fast for an unknown server-derived scenario effect', () => {
const payload = buildPayload('battle');
payload.scenarioEffect = 'event_Missing';
expect(() => processBattleSimJob(payload)).toThrow('Unknown scenario effect: event_Missing');
});
it('runs the advance trigger when a progressed attacker meets the next fresh defender', () => {
const payload = buildPayload('battle');
payload.scenarioEffect = 'event_StrongAttacker';
payload.defenderGenerals[0]!.crew = 100;
payload.defenderGenerals.push({
...payload.defenderGenerals[0]!,
no: 3,
name: 'Next Defender',
crew: 1000,
});
const result = processBattleSimJob(payload);
expect(result.lastWarLog?.generalBattleDetailLog).toContain(
'적군의 전멸에 <font color=cyan>진격</font>이 이어집니다!'
);
});
});
+44 -1
View File
@@ -265,7 +265,7 @@ describe('battle router orchestration', () => {
currentYear: 200,
currentMonth: 1,
tickSeconds: 600,
config: {},
config: { environment: { scenarioEffect: 'event_MoreEffect' } },
meta: {},
updatedAt: new Date('2026-01-01T00:00:00Z'),
};
@@ -275,6 +275,7 @@ describe('battle router orchestration', () => {
expect(response.status).toBe('queued');
expect(battleSim.simulateCalls).toBe(1);
expect(battleSim.lastRequesterUserId).toBe('user-1');
expect(battleSim.lastPayload?.scenarioEffect).toBe('event_MoreEffect');
const queued = await caller.battle.getSimulation({ jobId: response.jobId });
expect(queued.status).toBe('queued');
@@ -286,6 +287,48 @@ describe('battle router orchestration', () => {
expect(completed.payload?.result).toBe(true);
});
it('uses the stored scenario effect even when a client sends a same-named field', async () => {
const battleSim = new QueuedBattleSimTransport();
const state: WorldStateRow = {
id: 1,
scenarioCode: 'default',
currentYear: 200,
currentMonth: 1,
tickSeconds: 600,
config: { environment: { scenarioEffect: 'event_MoreEffect' } },
meta: {},
updatedAt: new Date('2026-01-01T00:00:00Z'),
};
const caller = appRouter.createCaller(buildContext({ state, battleSim }));
const maliciousRequest = {
...buildBattleRequest(),
scenarioEffect: 'event_StrongAttacker',
} as ReturnType<typeof buildBattleRequest>;
await expect(caller.battle.simulate(maliciousRequest)).resolves.toMatchObject({ status: 'queued' });
expect(battleSim.lastPayload?.scenarioEffect).toBe('event_MoreEffect');
});
it('rejects an unknown stored scenario effect before queuing the simulation', async () => {
const battleSim = new QueuedBattleSimTransport();
const state: WorldStateRow = {
id: 1,
scenarioCode: 'default',
currentYear: 200,
currentMonth: 1,
tickSeconds: 600,
config: { environment: { scenarioEffect: 'event_Missing' } },
meta: {},
updatedAt: new Date('2026-01-01T00:00:00Z'),
};
const caller = appRouter.createCaller(buildContext({ state, battleSim }));
await expect(caller.battle.simulate(buildBattleRequest())).rejects.toThrow(
'Unknown scenario effect: event_Missing'
);
expect(battleSim.simulateCalls).toBe(0);
});
it('requires login, allows a user without a general, and does not open an input-event transaction', async () => {
const battleSim = new QueuedBattleSimTransport();
const state: WorldStateRow = {
@@ -51,6 +51,7 @@ liveDescribe('battle simulator worker with live Redis', () => {
unitSet: environment.unitSet,
config: environment.config,
time: { year: request.year, month: request.month, startYear },
scenarioEffect: environment.scenarioEffect,
};
const clientConnector = createRedisConnector(resolveRedisConfigFromEnv());
+48 -2
View File
@@ -350,7 +350,14 @@ describe('appRouter', () => {
currentYear: 1,
currentMonth: 2,
tickSeconds: 600,
config: { maxUserCnt: 500, hiddenSeed: 'config-secret' },
config: {
maxUserCnt: 500,
hiddenSeed: 'config-secret',
environment: {
scenarioEffect: 'event_StrongAttacker',
hiddenSeed: 'environment-secret',
},
},
meta: { otherTextInfo: 'sample', hiddenSeed: 'meta-secret' },
updatedAt: new Date('2026-01-01T00:00:00Z'),
};
@@ -360,11 +367,50 @@ describe('appRouter', () => {
expect(response?.scenarioCode).toBe('default');
expect(response?.currentYear).toBe(1);
expect(response?.config).toEqual({ maxUserCnt: 500 });
expect(response?.config).toEqual({
maxUserCnt: 500,
environment: { scenarioEffect: 'event_StrongAttacker' },
});
expect(response?.meta).toEqual({ otherTextInfo: 'sample' });
expect(response?.updatedAt).toBe('2026-01-01T00:00:00.000Z');
});
it.each(['', 'None', null])('normalizes the persisted no-effect sentinel %j in world snapshots', async (value) => {
const state: WorldStateRow = {
id: 1,
scenarioCode: 'default',
currentYear: 1,
currentMonth: 2,
tickSeconds: 600,
config: { environment: { scenarioEffect: value } },
meta: {},
updatedAt: new Date('2026-01-01T00:00:00Z'),
};
const caller = appRouter.createCaller(buildContext({ state }));
await expect(caller.world.getState()).resolves.toMatchObject({
config: { environment: { scenarioEffect: null } },
});
});
it('rejects unknown persisted scenario effects in world snapshots', async () => {
const state: WorldStateRow = {
id: 1,
scenarioCode: 'default',
currentYear: 1,
currentMonth: 2,
tickSeconds: 600,
config: { environment: { scenarioEffect: 'event_Missing' } },
meta: {},
updatedAt: new Date('2026-01-01T00:00:00Z'),
};
const caller = appRouter.createCaller(buildContext({ state }));
await expect(caller.world.getState()).rejects.toThrow();
});
it('requires profile administration permission for turn daemon control', async () => {
const auth: GameSessionTokenPayload = {
version: 1,