feat: port scenario action effects

This commit is contained in:
2026-07-30 19:00:04 +00:00
parent 7f31459385
commit c68d992046
39 changed files with 1131 additions and 95 deletions
+5 -1
View File
@@ -1,7 +1,7 @@
import type { WorldStateRow } from '../context.js';
import type { BattleSimJobPayload, BattleSimRequestPayload } from './types.js';
import { loadUnitSetDefinitionByName } from './unitSetLoader.js';
import type { WarEngineConfig } from '@sammo-ts/logic';
import { normalizeScenarioEffect, type ScenarioEffectKey, type WarEngineConfig } from '@sammo-ts/logic';
import { asRecord } from '@sammo-ts/common';
import type { UnitSetDefinition } from '@sammo-ts/logic';
@@ -79,6 +79,7 @@ export interface BattleSimEnvironment {
unitSet: UnitSetDefinition;
config: WarEngineConfig;
startYear: number;
scenarioEffect: ScenarioEffectKey | null;
}
export const buildBattleSimEnvironment = async (
@@ -89,6 +90,7 @@ export const buildBattleSimEnvironment = async (
const unitSet = await loadUnitSetDefinitionByName(unitSetName);
const configRecord = asRecord(worldState.config);
const scenarioEnvironment = asRecord(configRecord.environment ?? configRecord.map);
const constValues = asRecord(configRecord.const ?? configRecord.consts);
const castleCrewTypeId = resolveNumber(constValues, ['castleCrewTypeId'], resolveCastleCrewTypeId(unitSet));
const castleArmType = resolveCastleArmType(unitSet, castleCrewTypeId);
@@ -117,6 +119,7 @@ export const buildBattleSimEnvironment = async (
unitSet,
config,
startYear: resolveStartYear(worldState),
scenarioEffect: normalizeScenarioEffect(scenarioEnvironment.scenarioEffect),
};
};
@@ -136,5 +139,6 @@ export const buildBattleSimJobPayload = async (
month: request.month,
startYear: environment.startYear,
},
scenarioEffect: environment.scenarioEffect,
};
};
+9 -4
View File
@@ -12,6 +12,7 @@ import {
createItemActionModules,
createItemModuleRegistry,
createRefOrderedActionStack,
createScenarioEffectActionModules,
ITEM_KEYS,
loadItemModules,
createInheritBuffModules,
@@ -62,8 +63,12 @@ const domesticWarModule = new TraitWarActionRouter('domestic', traitCatalog);
const warTraitModule = new TraitWarActionRouter('war', traitCatalog);
const personalityWarModule = new TraitWarActionRouter('personality', traitCatalog);
const buildWarActionModules = (unitSet: UnitSetDefinition): RefOrderedActionStack<WarActionModule> => {
const buildWarActionModules = (
unitSet: UnitSetDefinition,
scenarioEffect?: string | null
): RefOrderedActionStack<WarActionModule> => {
const crewTypeCatalog = compileCrewTypeCatalog(unitSet, crewTypeWarTriggerRegistry);
const scenario = createScenarioEffectActionModules(scenarioEffect);
return createRefOrderedActionStack<WarActionModule>({
nation: nationWarModule,
officer: officerWarModule,
@@ -72,7 +77,7 @@ const buildWarActionModules = (unitSet: UnitSetDefinition): RefOrderedActionStac
personality: personalityWarModule,
crewType: crewTypeCatalog.warActionModule,
inheritance: inheritBuffModules.war,
scenario: null,
scenario: scenario.war,
items: itemWarModules,
});
};
@@ -296,7 +301,7 @@ const resolveDefenderOrderPayload = (payload: BattleSimJobPayload): number[] =>
const defenderCity = mapCityPayload(payload.defenderCity);
const attacker = mapGeneralPayload(payload.attackerGeneral);
const defenders = payload.defenderGenerals.map(mapGeneralPayload);
const warActionModules = buildWarActionModules(payload.unitSet);
const warActionModules = buildWarActionModules(payload.unitSet, payload.scenarioEffect);
return resolveDefenderOrder({
unitSet: payload.unitSet,
@@ -338,7 +343,7 @@ export const processBattleSimJob = (
}
let repeatCnt = payload.repeatCnt;
const warActionModules = buildWarActionModules(payload.unitSet);
const warActionModules = buildWarActionModules(payload.unitSet, payload.scenarioEffect);
const baseSeed = payload.seed ?? '';
if (baseSeed) {
repeatCnt = 1;
+1
View File
@@ -100,6 +100,7 @@ export interface BattleSimJobPayload extends BattleSimRequestPayload {
unitSet: UnitSetDefinition;
config: WarEngineConfig;
time: WarTimeContext;
scenarioEffect?: string | null;
}
export interface BattleSimLogBuckets {
+9
View File
@@ -1,6 +1,7 @@
import { z } from 'zod';
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
import type { DatabaseClient as InfraDatabaseClient, RedisConnector, GamePrisma } from '@sammo-ts/infra';
import { normalizeScenarioEffect, SCENARIO_EFFECT_KEYS } from '@sammo-ts/logic';
import type { TurnDaemonTransport } from './daemon/transport.js';
import type { BattleSimTransport } from './battleSim/transport.js';
@@ -25,6 +26,14 @@ export const zWorldStateConfig = z.object({
extendedGeneral: z.boolean().optional(),
turnTermMinutes: z.number().optional(),
syncTurnTime: z.boolean().optional(),
environment: z
.object({
scenarioEffect: z
.union([z.enum(SCENARIO_EFFECT_KEYS), z.literal(''), z.literal('None'), z.null()])
.transform(normalizeScenarioEffect)
.optional(),
})
.optional(),
});
export type WorldStateConfig = z.infer<typeof zWorldStateConfig>;
@@ -9,6 +9,7 @@ const buildPayload = (action: BattleSimJobPayload['action']): BattleSimJobPayloa
year: 200,
month: 1,
seed: 'test-seed',
scenarioEffect: null,
attackerGeneral: {
no: 1,
name: 'Attacker',
@@ -250,6 +251,14 @@ describe('battle sim processor', () => {
expect(result.lastWarLog?.generalActionLog).toContain('퇴각했습니다.');
});
it('treats a queued job from before scenarioEffect existed as the no-effect baseline', () => {
const baselinePayload = buildPayload('battle');
const legacyPayload = buildPayload('battle');
delete legacyPayload.scenarioEffect;
expect(processBattleSimJob(legacyPayload)).toEqual(processBattleSimJob(baselinePayload));
});
it('returns the fixed defender ID order for reorder action', () => {
const payload = buildPayload('reorder');
const result = processBattleSimJob(payload);
@@ -281,4 +290,53 @@ describe('battle sim processor', () => {
expect(() => processBattleSimJob(payload)).toThrow('Unknown crew type action');
});
it('applies StrongAttacker to general combat in the server-enriched simulator job', () => {
const baseline = processBattleSimJob(buildPayload('battle'));
const payload = buildPayload('battle');
payload.scenarioEffect = 'event_StrongAttacker';
const strong = processBattleSimJob(payload);
expect(strong.killed).toBeGreaterThan(baseline.killed ?? 0);
});
it('keeps StrongAttacker city combat identical but applies MoreEffect to it', () => {
const baselinePayload = buildPayload('battle');
baselinePayload.defenderGenerals = [];
const baseline = processBattleSimJob(baselinePayload);
const strongPayload = buildPayload('battle');
strongPayload.defenderGenerals = [];
strongPayload.scenarioEffect = 'event_StrongAttacker';
expect(processBattleSimJob(strongPayload)).toEqual(baseline);
const morePayload = buildPayload('battle');
morePayload.defenderGenerals = [];
morePayload.scenarioEffect = 'event_MoreEffect';
const more = processBattleSimJob(morePayload);
expect(more.dead).toBeLessThan(baseline.dead ?? Number.POSITIVE_INFINITY);
});
it('fails fast for an unknown server-derived scenario effect', () => {
const payload = buildPayload('battle');
payload.scenarioEffect = 'event_Missing';
expect(() => processBattleSimJob(payload)).toThrow('Unknown scenario effect: event_Missing');
});
it('runs the advance trigger when a progressed attacker meets the next fresh defender', () => {
const payload = buildPayload('battle');
payload.scenarioEffect = 'event_StrongAttacker';
payload.defenderGenerals[0]!.crew = 100;
payload.defenderGenerals.push({
...payload.defenderGenerals[0]!,
no: 3,
name: 'Next Defender',
crew: 1000,
});
const result = processBattleSimJob(payload);
expect(result.lastWarLog?.generalBattleDetailLog).toContain(
'적군의 전멸에 <font color=cyan>진격</font>이 이어집니다!'
);
});
});
+44 -1
View File
@@ -265,7 +265,7 @@ describe('battle router orchestration', () => {
currentYear: 200,
currentMonth: 1,
tickSeconds: 600,
config: {},
config: { environment: { scenarioEffect: 'event_MoreEffect' } },
meta: {},
updatedAt: new Date('2026-01-01T00:00:00Z'),
};
@@ -275,6 +275,7 @@ describe('battle router orchestration', () => {
expect(response.status).toBe('queued');
expect(battleSim.simulateCalls).toBe(1);
expect(battleSim.lastRequesterUserId).toBe('user-1');
expect(battleSim.lastPayload?.scenarioEffect).toBe('event_MoreEffect');
const queued = await caller.battle.getSimulation({ jobId: response.jobId });
expect(queued.status).toBe('queued');
@@ -286,6 +287,48 @@ describe('battle router orchestration', () => {
expect(completed.payload?.result).toBe(true);
});
it('uses the stored scenario effect even when a client sends a same-named field', async () => {
const battleSim = new QueuedBattleSimTransport();
const state: WorldStateRow = {
id: 1,
scenarioCode: 'default',
currentYear: 200,
currentMonth: 1,
tickSeconds: 600,
config: { environment: { scenarioEffect: 'event_MoreEffect' } },
meta: {},
updatedAt: new Date('2026-01-01T00:00:00Z'),
};
const caller = appRouter.createCaller(buildContext({ state, battleSim }));
const maliciousRequest = {
...buildBattleRequest(),
scenarioEffect: 'event_StrongAttacker',
} as ReturnType<typeof buildBattleRequest>;
await expect(caller.battle.simulate(maliciousRequest)).resolves.toMatchObject({ status: 'queued' });
expect(battleSim.lastPayload?.scenarioEffect).toBe('event_MoreEffect');
});
it('rejects an unknown stored scenario effect before queuing the simulation', async () => {
const battleSim = new QueuedBattleSimTransport();
const state: WorldStateRow = {
id: 1,
scenarioCode: 'default',
currentYear: 200,
currentMonth: 1,
tickSeconds: 600,
config: { environment: { scenarioEffect: 'event_Missing' } },
meta: {},
updatedAt: new Date('2026-01-01T00:00:00Z'),
};
const caller = appRouter.createCaller(buildContext({ state, battleSim }));
await expect(caller.battle.simulate(buildBattleRequest())).rejects.toThrow(
'Unknown scenario effect: event_Missing'
);
expect(battleSim.simulateCalls).toBe(0);
});
it('requires login, allows a user without a general, and does not open an input-event transaction', async () => {
const battleSim = new QueuedBattleSimTransport();
const state: WorldStateRow = {
@@ -51,6 +51,7 @@ liveDescribe('battle simulator worker with live Redis', () => {
unitSet: environment.unitSet,
config: environment.config,
time: { year: request.year, month: request.month, startYear },
scenarioEffect: environment.scenarioEffect,
};
const clientConnector = createRedisConnector(resolveRedisConfigFromEnv());
+48 -2
View File
@@ -350,7 +350,14 @@ describe('appRouter', () => {
currentYear: 1,
currentMonth: 2,
tickSeconds: 600,
config: { maxUserCnt: 500, hiddenSeed: 'config-secret' },
config: {
maxUserCnt: 500,
hiddenSeed: 'config-secret',
environment: {
scenarioEffect: 'event_StrongAttacker',
hiddenSeed: 'environment-secret',
},
},
meta: { otherTextInfo: 'sample', hiddenSeed: 'meta-secret' },
updatedAt: new Date('2026-01-01T00:00:00Z'),
};
@@ -360,11 +367,50 @@ describe('appRouter', () => {
expect(response?.scenarioCode).toBe('default');
expect(response?.currentYear).toBe(1);
expect(response?.config).toEqual({ maxUserCnt: 500 });
expect(response?.config).toEqual({
maxUserCnt: 500,
environment: { scenarioEffect: 'event_StrongAttacker' },
});
expect(response?.meta).toEqual({ otherTextInfo: 'sample' });
expect(response?.updatedAt).toBe('2026-01-01T00:00:00.000Z');
});
it.each(['', 'None', null])('normalizes the persisted no-effect sentinel %j in world snapshots', async (value) => {
const state: WorldStateRow = {
id: 1,
scenarioCode: 'default',
currentYear: 1,
currentMonth: 2,
tickSeconds: 600,
config: { environment: { scenarioEffect: value } },
meta: {},
updatedAt: new Date('2026-01-01T00:00:00Z'),
};
const caller = appRouter.createCaller(buildContext({ state }));
await expect(caller.world.getState()).resolves.toMatchObject({
config: { environment: { scenarioEffect: null } },
});
});
it('rejects unknown persisted scenario effects in world snapshots', async () => {
const state: WorldStateRow = {
id: 1,
scenarioCode: 'default',
currentYear: 1,
currentMonth: 2,
tickSeconds: 600,
config: { environment: { scenarioEffect: 'event_Missing' } },
meta: {},
updatedAt: new Date('2026-01-01T00:00:00Z'),
};
const caller = appRouter.createCaller(buildContext({ state }));
await expect(caller.world.getState()).rejects.toThrow();
});
it('requires profile administration permission for turn daemon control', async () => {
const auth: GameSessionTokenPayload = {
version: 1,
@@ -81,6 +81,7 @@ export const buildCommandEnv = (config: ScenarioConfig, unitSet?: UnitSetDefinit
return {
...(unitSet ? { unitSet } : {}),
scenarioEffect: config.environment.scenarioEffect ?? null,
develCost: resolveNumber(constValues, ['develCost', 'develcost', 'develrate'], 0),
minAvailableRecruitPop: resolveNumber(constValues, ['minAvailableRecruitPop'], 30000),
trainDelta: resolveNumber(constValues, ['trainDelta'], DEFAULT_TRAIN_DELTA),
@@ -169,7 +170,7 @@ export const buildReservedTurnDefinitions = async (options: {
general: Map<string, GeneralActionDefinition>;
nation: Map<string, GeneralActionDefinition>;
}> => {
const moduleBundle = await loadActionModuleBundle(options.env.unitSet);
const moduleBundle = await loadActionModuleBundle(options.env.unitSet, options.env.scenarioEffect);
const itemModules = moduleBundle.itemModules;
options.env.itemCatalog = Object.fromEntries(
itemModules.map((item) => [
+4 -1
View File
@@ -219,7 +219,10 @@ const createTurnDaemonRuntimeWithLease = async (
let redisConnector: ReturnType<typeof createRedisConnector> | null = null;
const nationTraits = await loadNationTraitModules([...NATION_TRAIT_KEYS], new NationTraitLoader());
const nationTraitMap = new Map(nationTraits.map((module) => [module.key, module]));
const monthlyActionModules = await loadActionModuleBundle(snapshot.unitSet);
const monthlyActionModules = await loadActionModuleBundle(
snapshot.unitSet,
snapshot.scenarioConfig.environment.scenarioEffect
);
const monthlyCommandEnv = buildCommandEnv(snapshot.scenarioConfig, snapshot.unitSet);
const unification = options.calendarHandler
? null
@@ -15,6 +15,7 @@ import {
buildVoteUniqueSeed,
countOccupiedUniqueItems,
createItemModuleRegistry,
isDefenceTrainPenaltyWaivedByScenarioEffect,
isValidTroopNameWidth,
loadItemModules,
normalizeTroopName,
@@ -948,10 +949,7 @@ async function handleSetMySetting(
nextMeta.defence_train = nextDefenceTrain;
if (nextDefenceTrain === 999) {
const scenarioEffect = world.getScenarioConfig().environment.scenarioEffect;
const ignoresPenalty =
scenarioEffect === 'event_UnlimitedDefenceThresholdChange' ||
scenarioEffect === 'event_StrongAttacker' ||
scenarioEffect === 'event_MoreEffect';
const ignoresPenalty = isDefenceTrainPenaltyWaivedByScenarioEffect(scenarioEffect);
const constValues = asRecord(world.getScenarioConfig().const);
const maxTrain = readMetaNumber(constValues, 'maxTrainByWar', 100);
const maxAtmos = readMetaNumber(constValues, 'maxAtmosByWar', 100);
+22 -2
View File
@@ -21,6 +21,7 @@ import type {
Troop,
TriggerValue,
} from '@sammo-ts/logic';
import { normalizeScenarioEffect } from '@sammo-ts/logic';
import { projectItemSlots, readItemInventoryFromMeta } from '@sammo-ts/logic/items/index.js';
import { z } from 'zod';
import { asRecord, isRecord } from '@sammo-ts/common';
@@ -90,7 +91,20 @@ const zScenarioStatBlock = z.object({
const zScenarioEnvironment = z.object({
mapName: z.string(),
unitSet: z.string(),
scenarioEffect: z.union([z.string(), z.null()]).optional(),
scenarioEffect: z
.union([z.string(), z.null()])
.optional()
.refine(
(value) => {
try {
normalizeScenarioEffect(value);
return true;
} catch {
return false;
}
},
{ message: 'Unknown scenario effect' }
),
});
const zScenarioConfig = z.object({
@@ -155,7 +169,13 @@ const mapScenarioConfig = (raw: JsonValue): ScenarioConfig => {
if (!parsed.success) {
throw new Error(`world_state.config is invalid: ${parsed.error.message}`);
}
return parsed.data;
return {
...parsed.data,
environment: {
...parsed.data.environment,
scenarioEffect: normalizeScenarioEffect(parsed.data.environment.scenarioEffect),
},
};
};
const mapGeneralRow = (
+30 -3
View File
@@ -108,6 +108,33 @@ const unitSet: UnitSetDefinition = {
};
describe('reserved turn crew type wiring', () => {
it('derives scenario modules from the stored scenario config and keeps them before items', async () => {
const env = buildCommandEnv(
{
...scenarioConfig,
environment: {
...scenarioConfig.environment,
scenarioEffect: 'event_MoreEffect',
},
},
unitSet
);
await buildReservedTurnDefinitions({
env,
commandProfile: { general: ['휴식'], nation: ['휴식'] },
defaultActionKey: '휴식',
});
expect(env.scenarioEffect).toBe('event_MoreEffect');
const scenarioGeneral = env.generalActionModules?.at(-2);
const scenarioWar = env.warActionModules?.at(-2);
const attacker = { isAttacker: () => true } as unknown as WarUnitGeneral;
const defender = { isAttacker: () => false } as unknown as WarUnitGeneral;
expect(scenarioGeneral?.onCalcDomestic?.({ general }, '상업', 'score', 10)).toBe(20);
expect(scenarioWar?.getWarPowerMultiplier?.({ general }, attacker, defender)).toEqual([1.4, 0.7143]);
});
it('installs the crew action router before inherit and item handlers', async () => {
const env = buildCommandEnv(scenarioConfig, unitSet);
@@ -221,9 +248,9 @@ describe('reserved turn crew type wiring', () => {
100,
100
);
expect(
warPipeline.getWarPowerMultiplier(attacker.getActionContext(), attacker, defender)
).toEqual([1.284, 0.93]);
expect(warPipeline.getWarPowerMultiplier(attacker.getActionContext(), attacker, defender)).toEqual([
1.284, 0.93,
]);
expect(warPipeline.onCalcStat(attacker.getActionContext(), 'bonusTrain', 100)).toBe(105);
});
});
@@ -198,7 +198,7 @@ describe('core monthly event actions at the real month boundary', () => {
]);
});
it('uses the ProcessIncome resource argument instead of inferring it from the month', async () => {
it('uses the ProcessIncome resource argument and keeps MoreEffect income dormant like ref', async () => {
const world = buildWorld(
[
{
@@ -226,7 +226,11 @@ describe('core monthly event actions at the real month boundary', () => {
iconPath: '',
map: {},
const: { baseGold: 0, baseRice: 0 },
environment: { mapName: map.id, unitSet: 'default' },
environment: {
mapName: map.id,
unitSet: 'default',
scenarioEffect: 'event_MoreEffect',
},
},
nationTraits: new Map(),
});
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest';
import type { TurnSchedule } from '@sammo-ts/logic';
import type { ScenarioEffectKey, TurnSchedule } from '@sammo-ts/logic';
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
@@ -51,7 +51,7 @@ const buildGeneral = (overrides: Partial<TurnGeneral> = {}): TurnGeneral => ({
const buildWorld = (
general = buildGeneral(),
options: { autorunLimit?: boolean; scenarioEffect?: string | null } = {}
options: { autorunLimit?: boolean; scenarioEffect?: ScenarioEffectKey | null } = {}
) => {
const state: TurnWorldState = {
id: 1,
@@ -143,15 +143,22 @@ describe('my information world commands', () => {
});
});
it('preserves the event scenarios that waive the no-defence penalty', async () => {
const fixture = buildWorld(buildGeneral(), { scenarioEffect: 'event_StrongAttacker' });
await fixture.handler.handle({
type: 'setMySetting',
generalId: 7,
settings: { defence_train: 999 },
});
expect(fixture.world.getGeneralById(7)).toMatchObject({ train: 90, atmos: 90 });
});
it.each([
'event_UnlimitedDefenceThresholdChange',
'event_StrongAttacker',
'event_MoreEffect',
] satisfies ScenarioEffectKey[])(
'preserves the %s scenario that waives the no-defence penalty',
async (scenarioEffect) => {
const fixture = buildWorld(buildGeneral(), { scenarioEffect });
await fixture.handler.handle({
type: 'setMySetting',
generalId: 7,
settings: { defence_train: 999 },
});
expect(fixture.world.getGeneralById(7)).toMatchObject({ train: 90, atmos: 90 });
}
);
it('applies vacation killturn and rejects it in automatic-turn mode', async () => {
const allowed = buildWorld();
+20 -9
View File
@@ -50,15 +50,7 @@ type ScenarioSeederPrismaClient = {
};
};
const requiredTables = [
'world_state',
'nation',
'city',
'general',
'diplomacy',
'troop',
'event',
];
const requiredTables = ['world_state', 'nation', 'city', 'general', 'diplomacy', 'troop', 'event'];
const hasRequiredTables = async (prisma: ScenarioSeederPrismaClient, schemaName: string): Promise<boolean> => {
for (const table of requiredTables) {
@@ -243,6 +235,25 @@ describeDb('scenario database seed', () => {
await connector.disconnect();
}
});
test('persists a tracked scenario effect in the world configuration', async () => {
await seedScenarioToDatabase({
scenarioId: 906,
databaseUrl,
});
const connector = createGamePostgresConnector({ url: databaseUrl });
await connector.connect();
try {
const prisma = connector.prisma as unknown as ScenarioSeederPrismaClient;
const worldState = await prisma.worldState.findFirst();
const config = (worldState?.config ?? {}) as Record<string, unknown>;
const environment = (config.environment ?? {}) as Record<string, unknown>;
expect(environment.scenarioEffect).toBe('event_StrongAttacker');
} finally {
await connector.disconnect();
}
});
});
describe('tracked scenario composition', () => {
+68 -6
View File
@@ -22,6 +22,7 @@ const persistParityArtifact = async (page: Page, name: string, geometry: unknown
type FixtureState = {
permission: 'head' | 'member';
myset: number;
scenarioEffect?: string | null;
settingMutations: Array<Record<string, unknown>>;
accessPages: string[];
};
@@ -154,7 +155,11 @@ const install = async (page: Page, state: FixtureState) => {
currentYear: 185,
currentMonth: 1,
tickSeconds: 600,
config: { npcMode: 0, const: { availableInstantAction: {} } },
config: {
npcMode: 0,
const: { availableInstantAction: {} },
environment: { scenarioEffect: state.scenarioEffect ?? null },
},
meta: {
turntime: '2026-01-01T00:00:00.000Z',
opentime: '2025-12-01T00:00:00.000Z',
@@ -266,11 +271,20 @@ test('접속량정보 keeps the legacy public 1016px chart geometry', async ({ p
test('내 정보&설정 keeps the legacy 1000px/500px geometry and saves in place', async ({ page }) => {
const state: FixtureState = { permission: 'head', myset: 3, settingMutations: [], accessPages: [] };
await install(page, state);
await page.setViewportSize({ width: 1200, height: 900 });
await page.setViewportSize({ width: 1000, height: 900 });
await page.goto('my-page');
await expect(page.locator('.title-row')).toContainText('내 정 보');
await expect(page.locator('#set_my_setting')).toBeVisible();
await expect.poll(() => state.accessPages).toContain('my-page');
const noDefenceOption = page.locator('option[value="999"]');
await expect(noDefenceOption).toHaveText('× [훈련 -3,사기 -6]');
await expect(page.locator('#defence_train option')).toHaveText([
'☆(훈사90)',
'◎(훈사80)',
'○(훈사60)',
'△(훈사40)',
'× [훈련 -3,사기 -6]',
]);
const desktop = await page.locator('#container').evaluate((element) => {
const rect = element.getBoundingClientRect();
@@ -311,14 +325,62 @@ test('내 정보&설정 keeps the legacy 1000px/500px geometry and saves in plac
expect(desktop.sectionBackgroundImage).toContain('back_green.jpg');
await persistParityArtifact(page, 'core-my-page-desktop', desktop);
await page
.locator('select')
.filter({ has: page.locator('option[value="999"]') })
.selectOption('999');
const defenceSelect = page.locator('select').filter({ has: page.locator('option[value="999"]') });
await defenceSelect.selectOption('999');
const noEffectState = await defenceSelect.evaluate((element) => {
const rect = element.getBoundingClientRect();
const style = getComputedStyle(element);
return {
scenarioEffect: null,
optionText: element.querySelector<HTMLOptionElement>('option[value="999"]')?.textContent,
selectedText: element.querySelector<HTMLOptionElement>('option:checked')?.textContent,
rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
fontSize: style.fontSize,
lineHeight: style.lineHeight,
color: style.color,
backgroundColor: style.backgroundColor,
};
});
expect(noEffectState.rect.width).toBe(134);
expect(noEffectState.rect.height).toBe(20);
await persistParityArtifact(page, 'core-my-page-no-effect-999', noEffectState);
await page.locator('#set_my_setting').click();
await expect.poll(() => state.settingMutations.length).toBe(1);
expect(state.settingMutations[0]).not.toHaveProperty('generalId');
for (const [effectIndex, scenarioEffect] of [
'event_UnlimitedDefenceThresholdChange',
'event_StrongAttacker',
'event_MoreEffect',
].entries()) {
state.scenarioEffect = scenarioEffect;
state.myset = 1;
await page.reload();
await expect(noDefenceOption).toHaveText('×');
await defenceSelect.selectOption('999');
const effectState = await defenceSelect.evaluate((element) => {
const rect = element.getBoundingClientRect();
const style = getComputedStyle(element);
return {
optionText: element.querySelector<HTMLOptionElement>('option[value="999"]')?.textContent,
selectedText: element.querySelector<HTMLOptionElement>('option:checked')?.textContent,
rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
fontSize: style.fontSize,
lineHeight: style.lineHeight,
color: style.color,
backgroundColor: style.backgroundColor,
};
});
expect(effectState.optionText).toBe('×');
expect(effectState.selectedText).toBe('×');
expect(effectState.rect.width).toBe(86);
expect(effectState.rect.height).toBe(20);
await persistParityArtifact(page, `core-my-page-${scenarioEffect}`, effectState);
await page.locator('#set_my_setting').click();
await expect.poll(() => state.settingMutations.length).toBe(effectIndex + 2);
expect(state.settingMutations.at(-1)).not.toHaveProperty('generalId');
}
await page.setViewportSize({ width: 500, height: 900 });
await page.reload();
const mobile = await page.locator('#container').evaluate((element) => {
+24 -6
View File
@@ -2,6 +2,7 @@
import { computed, onMounted, reactive, ref, watch } from 'vue';
import { trpc } from '../utils/trpc';
import { formatLog } from '../utils/formatLog';
import { isDefenceTrainPenaltyWaivedByScenarioEffect } from '@sammo-ts/logic';
const SCREEN_MODE_KEY = 'sam.screenMode';
const CUSTOM_CSS_KEY = 'sam_customCSS';
@@ -95,6 +96,13 @@ const statusLine = computed(() =>
const canSave = computed(() => (data.value?.settings.myset ?? 1) > 0);
const penalties = computed(() => Object.entries(data.value?.penalties ?? {}));
const noDefencePenaltyWaived = computed(() => {
const environment = asRecord(world.value?.config.environment);
return isDefenceTrainPenaltyWaivedByScenarioEffect(
typeof environment.scenarioEffect === 'string' ? environment.scenarioEffect : null
);
});
const noDefenceLabel = computed(() => (noDefencePenaltyWaived.value ? '×' : '× [훈련 -3,사기 -6]'));
const items = computed<Array<{ key: ItemSlotKey; name: string; code: string | null }>>(() => [
{ key: 'horse', name: '말', code: data.value?.general.items.horse ?? null },
{ key: 'weapon', name: '무기', code: data.value?.general.items.weapon ?? null },
@@ -319,12 +327,16 @@ onMounted(() => {
<label class="setting-line">
수비
<select v-model.number="form.defence_train">
<option :value="90">수비 (훈사90)</option>
<option :value="80">수비 (훈사80)</option>
<option :value="60">수비 (훈사60)</option>
<option :value="40">수비 (훈사40)</option>
<option :value="999">수비 안함 [훈련 -3, 사기 -6]</option>
<select
id="defence_train"
v-model.number="form.defence_train"
:class="{ 'penalty-waived': noDefencePenaltyWaived }"
>
<option :value="90">(훈사90)</option>
<option :value="80">(훈사80)</option>
<option :value="60">(훈사60)</option>
<option :value="40">(훈사40)</option>
<option :value="999">{{ noDefenceLabel }}</option>
</select>
</label>
@@ -584,6 +596,12 @@ dt {
display: block;
margin-top: 5px;
}
#defence_train {
width: 134px;
}
#defence_train.penalty-waived {
width: 86px;
}
.hint {
margin: 0 0 13px;
color: orange;