feat: port scenario action effects
This commit is contained in:
@@ -81,6 +81,7 @@ export const buildCommandEnv = (config: ScenarioConfig, unitSet?: UnitSetDefinit
|
||||
|
||||
return {
|
||||
...(unitSet ? { unitSet } : {}),
|
||||
scenarioEffect: config.environment.scenarioEffect ?? null,
|
||||
develCost: resolveNumber(constValues, ['develCost', 'develcost', 'develrate'], 0),
|
||||
minAvailableRecruitPop: resolveNumber(constValues, ['minAvailableRecruitPop'], 30000),
|
||||
trainDelta: resolveNumber(constValues, ['trainDelta'], DEFAULT_TRAIN_DELTA),
|
||||
@@ -169,7 +170,7 @@ export const buildReservedTurnDefinitions = async (options: {
|
||||
general: Map<string, GeneralActionDefinition>;
|
||||
nation: Map<string, GeneralActionDefinition>;
|
||||
}> => {
|
||||
const moduleBundle = await loadActionModuleBundle(options.env.unitSet);
|
||||
const moduleBundle = await loadActionModuleBundle(options.env.unitSet, options.env.scenarioEffect);
|
||||
const itemModules = moduleBundle.itemModules;
|
||||
options.env.itemCatalog = Object.fromEntries(
|
||||
itemModules.map((item) => [
|
||||
|
||||
@@ -219,7 +219,10 @@ const createTurnDaemonRuntimeWithLease = async (
|
||||
let redisConnector: ReturnType<typeof createRedisConnector> | null = null;
|
||||
const nationTraits = await loadNationTraitModules([...NATION_TRAIT_KEYS], new NationTraitLoader());
|
||||
const nationTraitMap = new Map(nationTraits.map((module) => [module.key, module]));
|
||||
const monthlyActionModules = await loadActionModuleBundle(snapshot.unitSet);
|
||||
const monthlyActionModules = await loadActionModuleBundle(
|
||||
snapshot.unitSet,
|
||||
snapshot.scenarioConfig.environment.scenarioEffect
|
||||
);
|
||||
const monthlyCommandEnv = buildCommandEnv(snapshot.scenarioConfig, snapshot.unitSet);
|
||||
const unification = options.calendarHandler
|
||||
? null
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
buildVoteUniqueSeed,
|
||||
countOccupiedUniqueItems,
|
||||
createItemModuleRegistry,
|
||||
isDefenceTrainPenaltyWaivedByScenarioEffect,
|
||||
isValidTroopNameWidth,
|
||||
loadItemModules,
|
||||
normalizeTroopName,
|
||||
@@ -948,10 +949,7 @@ async function handleSetMySetting(
|
||||
nextMeta.defence_train = nextDefenceTrain;
|
||||
if (nextDefenceTrain === 999) {
|
||||
const scenarioEffect = world.getScenarioConfig().environment.scenarioEffect;
|
||||
const ignoresPenalty =
|
||||
scenarioEffect === 'event_UnlimitedDefenceThresholdChange' ||
|
||||
scenarioEffect === 'event_StrongAttacker' ||
|
||||
scenarioEffect === 'event_MoreEffect';
|
||||
const ignoresPenalty = isDefenceTrainPenaltyWaivedByScenarioEffect(scenarioEffect);
|
||||
const constValues = asRecord(world.getScenarioConfig().const);
|
||||
const maxTrain = readMetaNumber(constValues, 'maxTrainByWar', 100);
|
||||
const maxAtmos = readMetaNumber(constValues, 'maxAtmosByWar', 100);
|
||||
|
||||
@@ -21,6 +21,7 @@ import type {
|
||||
Troop,
|
||||
TriggerValue,
|
||||
} from '@sammo-ts/logic';
|
||||
import { normalizeScenarioEffect } from '@sammo-ts/logic';
|
||||
import { projectItemSlots, readItemInventoryFromMeta } from '@sammo-ts/logic/items/index.js';
|
||||
import { z } from 'zod';
|
||||
import { asRecord, isRecord } from '@sammo-ts/common';
|
||||
@@ -90,7 +91,20 @@ const zScenarioStatBlock = z.object({
|
||||
const zScenarioEnvironment = z.object({
|
||||
mapName: z.string(),
|
||||
unitSet: z.string(),
|
||||
scenarioEffect: z.union([z.string(), z.null()]).optional(),
|
||||
scenarioEffect: z
|
||||
.union([z.string(), z.null()])
|
||||
.optional()
|
||||
.refine(
|
||||
(value) => {
|
||||
try {
|
||||
normalizeScenarioEffect(value);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
{ message: 'Unknown scenario effect' }
|
||||
),
|
||||
});
|
||||
|
||||
const zScenarioConfig = z.object({
|
||||
@@ -155,7 +169,13 @@ const mapScenarioConfig = (raw: JsonValue): ScenarioConfig => {
|
||||
if (!parsed.success) {
|
||||
throw new Error(`world_state.config is invalid: ${parsed.error.message}`);
|
||||
}
|
||||
return parsed.data;
|
||||
return {
|
||||
...parsed.data,
|
||||
environment: {
|
||||
...parsed.data.environment,
|
||||
scenarioEffect: normalizeScenarioEffect(parsed.data.environment.scenarioEffect),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const mapGeneralRow = (
|
||||
|
||||
@@ -108,6 +108,33 @@ const unitSet: UnitSetDefinition = {
|
||||
};
|
||||
|
||||
describe('reserved turn crew type wiring', () => {
|
||||
it('derives scenario modules from the stored scenario config and keeps them before items', async () => {
|
||||
const env = buildCommandEnv(
|
||||
{
|
||||
...scenarioConfig,
|
||||
environment: {
|
||||
...scenarioConfig.environment,
|
||||
scenarioEffect: 'event_MoreEffect',
|
||||
},
|
||||
},
|
||||
unitSet
|
||||
);
|
||||
|
||||
await buildReservedTurnDefinitions({
|
||||
env,
|
||||
commandProfile: { general: ['휴식'], nation: ['휴식'] },
|
||||
defaultActionKey: '휴식',
|
||||
});
|
||||
|
||||
expect(env.scenarioEffect).toBe('event_MoreEffect');
|
||||
const scenarioGeneral = env.generalActionModules?.at(-2);
|
||||
const scenarioWar = env.warActionModules?.at(-2);
|
||||
const attacker = { isAttacker: () => true } as unknown as WarUnitGeneral;
|
||||
const defender = { isAttacker: () => false } as unknown as WarUnitGeneral;
|
||||
expect(scenarioGeneral?.onCalcDomestic?.({ general }, '상업', 'score', 10)).toBe(20);
|
||||
expect(scenarioWar?.getWarPowerMultiplier?.({ general }, attacker, defender)).toEqual([1.4, 0.7143]);
|
||||
});
|
||||
|
||||
it('installs the crew action router before inherit and item handlers', async () => {
|
||||
const env = buildCommandEnv(scenarioConfig, unitSet);
|
||||
|
||||
@@ -221,9 +248,9 @@ describe('reserved turn crew type wiring', () => {
|
||||
100,
|
||||
100
|
||||
);
|
||||
expect(
|
||||
warPipeline.getWarPowerMultiplier(attacker.getActionContext(), attacker, defender)
|
||||
).toEqual([1.284, 0.93]);
|
||||
expect(warPipeline.getWarPowerMultiplier(attacker.getActionContext(), attacker, defender)).toEqual([
|
||||
1.284, 0.93,
|
||||
]);
|
||||
expect(warPipeline.onCalcStat(attacker.getActionContext(), 'bonusTrain', 100)).toBe(105);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -198,7 +198,7 @@ describe('core monthly event actions at the real month boundary', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('uses the ProcessIncome resource argument instead of inferring it from the month', async () => {
|
||||
it('uses the ProcessIncome resource argument and keeps MoreEffect income dormant like ref', async () => {
|
||||
const world = buildWorld(
|
||||
[
|
||||
{
|
||||
@@ -226,7 +226,11 @@ describe('core monthly event actions at the real month boundary', () => {
|
||||
iconPath: '',
|
||||
map: {},
|
||||
const: { baseGold: 0, baseRice: 0 },
|
||||
environment: { mapName: map.id, unitSet: 'default' },
|
||||
environment: {
|
||||
mapName: map.id,
|
||||
unitSet: 'default',
|
||||
scenarioEffect: 'event_MoreEffect',
|
||||
},
|
||||
},
|
||||
nationTraits: new Map(),
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { TurnSchedule } from '@sammo-ts/logic';
|
||||
import type { ScenarioEffectKey, TurnSchedule } from '@sammo-ts/logic';
|
||||
|
||||
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
|
||||
import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
|
||||
@@ -51,7 +51,7 @@ const buildGeneral = (overrides: Partial<TurnGeneral> = {}): TurnGeneral => ({
|
||||
|
||||
const buildWorld = (
|
||||
general = buildGeneral(),
|
||||
options: { autorunLimit?: boolean; scenarioEffect?: string | null } = {}
|
||||
options: { autorunLimit?: boolean; scenarioEffect?: ScenarioEffectKey | null } = {}
|
||||
) => {
|
||||
const state: TurnWorldState = {
|
||||
id: 1,
|
||||
@@ -143,15 +143,22 @@ describe('my information world commands', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves the event scenarios that waive the no-defence penalty', async () => {
|
||||
const fixture = buildWorld(buildGeneral(), { scenarioEffect: 'event_StrongAttacker' });
|
||||
await fixture.handler.handle({
|
||||
type: 'setMySetting',
|
||||
generalId: 7,
|
||||
settings: { defence_train: 999 },
|
||||
});
|
||||
expect(fixture.world.getGeneralById(7)).toMatchObject({ train: 90, atmos: 90 });
|
||||
});
|
||||
it.each([
|
||||
'event_UnlimitedDefenceThresholdChange',
|
||||
'event_StrongAttacker',
|
||||
'event_MoreEffect',
|
||||
] satisfies ScenarioEffectKey[])(
|
||||
'preserves the %s scenario that waives the no-defence penalty',
|
||||
async (scenarioEffect) => {
|
||||
const fixture = buildWorld(buildGeneral(), { scenarioEffect });
|
||||
await fixture.handler.handle({
|
||||
type: 'setMySetting',
|
||||
generalId: 7,
|
||||
settings: { defence_train: 999 },
|
||||
});
|
||||
expect(fixture.world.getGeneralById(7)).toMatchObject({ train: 90, atmos: 90 });
|
||||
}
|
||||
);
|
||||
|
||||
it('applies vacation killturn and rejects it in automatic-turn mode', async () => {
|
||||
const allowed = buildWorld();
|
||||
|
||||
@@ -50,15 +50,7 @@ type ScenarioSeederPrismaClient = {
|
||||
};
|
||||
};
|
||||
|
||||
const requiredTables = [
|
||||
'world_state',
|
||||
'nation',
|
||||
'city',
|
||||
'general',
|
||||
'diplomacy',
|
||||
'troop',
|
||||
'event',
|
||||
];
|
||||
const requiredTables = ['world_state', 'nation', 'city', 'general', 'diplomacy', 'troop', 'event'];
|
||||
|
||||
const hasRequiredTables = async (prisma: ScenarioSeederPrismaClient, schemaName: string): Promise<boolean> => {
|
||||
for (const table of requiredTables) {
|
||||
@@ -243,6 +235,25 @@ describeDb('scenario database seed', () => {
|
||||
await connector.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
test('persists a tracked scenario effect in the world configuration', async () => {
|
||||
await seedScenarioToDatabase({
|
||||
scenarioId: 906,
|
||||
databaseUrl,
|
||||
});
|
||||
|
||||
const connector = createGamePostgresConnector({ url: databaseUrl });
|
||||
await connector.connect();
|
||||
try {
|
||||
const prisma = connector.prisma as unknown as ScenarioSeederPrismaClient;
|
||||
const worldState = await prisma.worldState.findFirst();
|
||||
const config = (worldState?.config ?? {}) as Record<string, unknown>;
|
||||
const environment = (config.environment ?? {}) as Record<string, unknown>;
|
||||
expect(environment.scenarioEffect).toBe('event_StrongAttacker');
|
||||
} finally {
|
||||
await connector.disconnect();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('tracked scenario composition', () => {
|
||||
|
||||
Reference in New Issue
Block a user