diff --git a/app/game-api/src/battleSim/environment.ts b/app/game-api/src/battleSim/environment.ts index f1acfa2..dde3c81 100644 --- a/app/game-api/src/battleSim/environment.ts +++ b/app/game-api/src/battleSim/environment.ts @@ -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, }; }; diff --git a/app/game-api/src/battleSim/processor.ts b/app/game-api/src/battleSim/processor.ts index d6f99e3..cf27089 100644 --- a/app/game-api/src/battleSim/processor.ts +++ b/app/game-api/src/battleSim/processor.ts @@ -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 => { +const buildWarActionModules = ( + unitSet: UnitSetDefinition, + scenarioEffect?: string | null +): RefOrderedActionStack => { const crewTypeCatalog = compileCrewTypeCatalog(unitSet, crewTypeWarTriggerRegistry); + const scenario = createScenarioEffectActionModules(scenarioEffect); return createRefOrderedActionStack({ 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; diff --git a/app/game-api/src/battleSim/types.ts b/app/game-api/src/battleSim/types.ts index 41d522b..1369a68 100644 --- a/app/game-api/src/battleSim/types.ts +++ b/app/game-api/src/battleSim/types.ts @@ -100,6 +100,7 @@ export interface BattleSimJobPayload extends BattleSimRequestPayload { unitSet: UnitSetDefinition; config: WarEngineConfig; time: WarTimeContext; + scenarioEffect?: string | null; } export interface BattleSimLogBuckets { diff --git a/app/game-api/src/context.ts b/app/game-api/src/context.ts index cab4ad9..06a4fa5 100644 --- a/app/game-api/src/context.ts +++ b/app/game-api/src/context.ts @@ -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; diff --git a/app/game-api/test/battleSimProcessor.test.ts b/app/game-api/test/battleSimProcessor.test.ts index 97178e0..1a438f8 100644 --- a/app/game-api/test/battleSimProcessor.test.ts +++ b/app/game-api/test/battleSimProcessor.test.ts @@ -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( + '적군의 전멸에 진격이 이어집니다!' + ); + }); }); diff --git a/app/game-api/test/battleSimRouter.test.ts b/app/game-api/test/battleSimRouter.test.ts index 3d7192d..0147e1f 100644 --- a/app/game-api/test/battleSimRouter.test.ts +++ b/app/game-api/test/battleSimRouter.test.ts @@ -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; + + 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 = { diff --git a/app/game-api/test/battleSimWorker.integration.test.ts b/app/game-api/test/battleSimWorker.integration.test.ts index 6a8ccf8..87469ba 100644 --- a/app/game-api/test/battleSimWorker.integration.test.ts +++ b/app/game-api/test/battleSimWorker.integration.test.ts @@ -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()); diff --git a/app/game-api/test/router.test.ts b/app/game-api/test/router.test.ts index ff00a58..52b10fb 100644 --- a/app/game-api/test/router.test.ts +++ b/app/game-api/test/router.test.ts @@ -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, diff --git a/app/game-engine/src/turn/reservedTurnCommands.ts b/app/game-engine/src/turn/reservedTurnCommands.ts index 7602116..efe17b8 100644 --- a/app/game-engine/src/turn/reservedTurnCommands.ts +++ b/app/game-engine/src/turn/reservedTurnCommands.ts @@ -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; nation: Map; }> => { - 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) => [ diff --git a/app/game-engine/src/turn/turnDaemon.ts b/app/game-engine/src/turn/turnDaemon.ts index 2bddd65..06e3a3b 100644 --- a/app/game-engine/src/turn/turnDaemon.ts +++ b/app/game-engine/src/turn/turnDaemon.ts @@ -219,7 +219,10 @@ const createTurnDaemonRuntimeWithLease = async ( let redisConnector: ReturnType | 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 diff --git a/app/game-engine/src/turn/worldCommandHandler.ts b/app/game-engine/src/turn/worldCommandHandler.ts index 7234256..02de6eb 100644 --- a/app/game-engine/src/turn/worldCommandHandler.ts +++ b/app/game-engine/src/turn/worldCommandHandler.ts @@ -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); diff --git a/app/game-engine/src/turn/worldLoader.ts b/app/game-engine/src/turn/worldLoader.ts index c0948f8..165a47c 100644 --- a/app/game-engine/src/turn/worldLoader.ts +++ b/app/game-engine/src/turn/worldLoader.ts @@ -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 = ( diff --git a/app/game-engine/test/crewTypeExecution.test.ts b/app/game-engine/test/crewTypeExecution.test.ts index 40205a6..28b29ef 100644 --- a/app/game-engine/test/crewTypeExecution.test.ts +++ b/app/game-engine/test/crewTypeExecution.test.ts @@ -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); }); }); diff --git a/app/game-engine/test/monthlyCoreEventAction.test.ts b/app/game-engine/test/monthlyCoreEventAction.test.ts index 3e9b096..62e21c3 100644 --- a/app/game-engine/test/monthlyCoreEventAction.test.ts +++ b/app/game-engine/test/monthlyCoreEventAction.test.ts @@ -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(), }); diff --git a/app/game-engine/test/myInformationCommands.test.ts b/app/game-engine/test/myInformationCommands.test.ts index 86c72b8..1f61d89 100644 --- a/app/game-engine/test/myInformationCommands.test.ts +++ b/app/game-engine/test/myInformationCommands.test.ts @@ -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 => ({ 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(); diff --git a/app/game-engine/test/scenarioSeeder.test.ts b/app/game-engine/test/scenarioSeeder.test.ts index 9d9765f..c21fbb2 100644 --- a/app/game-engine/test/scenarioSeeder.test.ts +++ b/app/game-engine/test/scenarioSeeder.test.ts @@ -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 => { 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; + const environment = (config.environment ?? {}) as Record; + expect(environment.scenarioEffect).toBe('event_StrongAttacker'); + } finally { + await connector.disconnect(); + } + }); }); describe('tracked scenario composition', () => { diff --git a/app/game-frontend/e2e/inGameMenus.spec.ts b/app/game-frontend/e2e/inGameMenus.spec.ts index 5f76e19..064f30e 100644 --- a/app/game-frontend/e2e/inGameMenus.spec.ts +++ b/app/game-frontend/e2e/inGameMenus.spec.ts @@ -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>; 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('option[value="999"]')?.textContent, + selectedText: element.querySelector('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('option[value="999"]')?.textContent, + selectedText: element.querySelector('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) => { diff --git a/app/game-frontend/src/views/MyPageView.vue b/app/game-frontend/src/views/MyPageView.vue index 8e34460..0b7d3cc 100644 --- a/app/game-frontend/src/views/MyPageView.vue +++ b/app/game-frontend/src/views/MyPageView.vue @@ -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>(() => [ { 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(() => { @@ -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; diff --git a/docs/architecture/action-module-protocol.md b/docs/architecture/action-module-protocol.md index 8ff2b95..0a95141 100644 --- a/docs/architecture/action-module-protocol.md +++ b/docs/architecture/action-module-protocol.md @@ -34,9 +34,36 @@ priority trigger를 의미 이벤트로 바꾸거나, 의미 이벤트를 `Trigg `RefOrderedActionStack`의 readonly unique-symbol brand는 임의 배열을 제품용 표준 stack으로 오인하지 않게 하는 shadow type입니다. 모든 slot을 명시하는 factory에서만 이 brand를 만들 수 있으며, 예약턴 runtime env에도 spread하지 -않고 그대로 전달합니다. 현재 시나리오 효과 runtime module은 이식되지 않아 -해당 slot은 명시적으로 `null`입니다. 따라서 이 brand는 순서와 slot 소유권을 -증명하며, 시나리오 효과 구현 완료를 뜻하지 않습니다. +않고 그대로 전달합니다. `scenarioEffect`가 없으면 scenario 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를 함께 재시작합니다. ## 닫힌 의미 이벤트 diff --git a/packages/logic/src/actionModules/bundle.ts b/packages/logic/src/actionModules/bundle.ts index 9d8a91a..dff8f8a 100644 --- a/packages/logic/src/actionModules/bundle.ts +++ b/packages/logic/src/actionModules/bundle.ts @@ -27,6 +27,8 @@ import { WAR_TRAIT_KEYS, } from './traits/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 { general: RefOrderedActionStack>; @@ -82,7 +84,8 @@ export const createRefOrderedActionStack = (slots: RefActionSlots( - unitSet?: UnitSetDefinition + unitSet?: UnitSetDefinition, + scenarioEffect?: ScenarioEffectKey | null ): Promise> => { const [domestic, war, personality, nation, itemModules] = await Promise.all([ loadDomesticTraitModules([...DOMESTIC_TRAIT_KEYS]), @@ -95,6 +98,7 @@ export const loadActionModuleBundle = async (); const items = createItemActionModules(createItemModuleRegistry(itemModules)); const inherit = createInheritBuffModules(); + const scenario = createScenarioEffectActionModules(scenarioEffect); const crewTypeCatalog = unitSet?.crewTypes?.length ? compileCrewTypeCatalog(unitSet, createCrewTypeWarTriggerRegistry()) : null; @@ -110,8 +114,7 @@ export const loadActionModuleBundle = async ) : null, inheritance: inherit.general as GeneralActionModule, - // scenarioEffect는 현재 core runtime module이 없어 명시적으로 빈 slot입니다. - scenario: null, + scenario: scenario.general, items: items.general, }), war: createRefOrderedActionStack>({ @@ -122,8 +125,7 @@ export const loadActionModuleBundle = async ) : null, inheritance: inherit.war as WarActionModule, - // ref의 scenarioEffect 위치를 보존하되 미이식 module은 별도 gap으로 남깁니다. - scenario: null, + scenario: scenario.war, items: items.war, }), itemModules, diff --git a/packages/logic/src/actionModules/index.ts b/packages/logic/src/actionModules/index.ts index c726a7b..bbd8df4 100644 --- a/packages/logic/src/actionModules/index.ts +++ b/packages/logic/src/actionModules/index.ts @@ -3,4 +3,5 @@ export * from './general.js'; export * from './types.js'; export * from './officerLevel.js'; export * from './bundle.js'; +export * from './scenarioEffect.js'; export * from './traits/index.js'; diff --git a/packages/logic/src/actionModules/scenarioEffect.ts b/packages/logic/src/actionModules/scenarioEffect.ts new file mode 100644 index 0000000..1d189c0 --- /dev/null +++ b/packages/logic/src/actionModules/scenarioEffect.ts @@ -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 { + general: GeneralActionModule | null; + war: WarActionModule | null; +} + +const MORE_EFFECT_DOMESTIC_ACTIONS = new Set(['상업', '농업', '치안', '기술', '성벽', '수비', '인구', '민심']); + +const createDefenceThresholdGeneralModule = < + TriggerState extends GeneralTriggerState, +>(): GeneralActionModule => ({ + onCalcDomestic: (_context, turnType, _varType, value) => (turnType === 'changeDefenceTrain' ? 0 : value), +}); + +const createAdvanceTriggerWarModule = ( + includeCityWarPower: boolean +): WarActionModule => ({ + 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 => ({ + 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 = ( + scenarioEffect?: string | null +): ScenarioEffectActionModules => { + const normalizedEffect = normalizeScenarioEffect(scenarioEffect); + if (!normalizedEffect) { + return { general: null, war: null }; + } + + switch (normalizedEffect) { + case 'event_UnlimitedDefenceThresholdChange': + return { + general: createDefenceThresholdGeneralModule(), + war: null, + }; + case 'event_StrongAttacker': + return { + general: createDefenceThresholdGeneralModule(), + war: createAdvanceTriggerWarModule(false), + }; + case 'event_MoreEffect': + return { + general: createMoreEffectGeneralModule(), + war: createAdvanceTriggerWarModule(true), + }; + } +}; diff --git a/packages/logic/src/actionModules/types.ts b/packages/logic/src/actionModules/types.ts index 6f3b553..96337d4 100644 --- a/packages/logic/src/actionModules/types.ts +++ b/packages/logic/src/actionModules/types.ts @@ -13,7 +13,8 @@ export type TriggerDomesticActionType = | '기술' | '모병' | '단련' - | '조달'; + | '조달' + | 'changeDefenceTrain'; export type TriggerDomesticVarType = 'cost' | 'score' | 'success' | 'fail' | 'train' | 'atmos' | 'rice' | 'probability'; diff --git a/packages/logic/src/actions/turn/commandEnv.ts b/packages/logic/src/actions/turn/commandEnv.ts index 6280c1e..b0b1f29 100644 --- a/packages/logic/src/actions/turn/commandEnv.ts +++ b/packages/logic/src/actions/turn/commandEnv.ts @@ -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 { NationTraitModule } from '@sammo-ts/logic/actionModules/traits/nation/index.js'; import type { RefOrderedActionStack } from '@sammo-ts/logic/actionModules/bundle.js'; +import type { ScenarioEffectKey } from '@sammo-ts/logic/scenario/scenarioEffect.js'; export interface TurnCommandItemCatalogEntry { slot: 'horse' | 'weapon' | 'book' | 'item'; @@ -17,6 +18,7 @@ export interface TurnCommandItemCatalogEntry { export interface TurnCommandEnv { unitSet?: UnitSetDefinition; + scenarioEffect?: ScenarioEffectKey | null; develCost: number; minAvailableRecruitPop?: number; trainDelta: number; diff --git a/packages/logic/src/resources/scenarioSchema.ts b/packages/logic/src/resources/scenarioSchema.ts index d661c52..f99c944 100644 --- a/packages/logic/src/resources/scenarioSchema.ts +++ b/packages/logic/src/resources/scenarioSchema.ts @@ -1,4 +1,5 @@ import { z } from 'zod'; +import { SCENARIO_EFFECT_KEYS } from '../scenario/scenarioEffect.js'; export const ScenarioStatBlockSchema = z .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)]); +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 .object({ extends: ScenarioExtendsInputSchema.optional(), @@ -29,7 +36,7 @@ const ScenarioBodyInputSchema = z iconPath: z.string().optional(), stat: ScenarioStatBlockSchema.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(), diplomacy: z.array(z.unknown()).optional(), general: z.array(z.unknown()).optional(), diff --git a/packages/logic/src/scenario/index.ts b/packages/logic/src/scenario/index.ts index 2275b57..d62583e 100644 --- a/packages/logic/src/scenario/index.ts +++ b/packages/logic/src/scenario/index.ts @@ -1,2 +1,3 @@ export * from './types.js'; export * from './parseScenario.js'; +export * from './scenarioEffect.js'; diff --git a/packages/logic/src/scenario/parseScenario.ts b/packages/logic/src/scenario/parseScenario.ts index 9d446c6..ef63242 100644 --- a/packages/logic/src/scenario/parseScenario.ts +++ b/packages/logic/src/scenario/parseScenario.ts @@ -13,6 +13,7 @@ import type { ScenarioNation, ScenarioStatBlock, } from './types.js'; +import { normalizeScenarioEffect } from './scenarioEffect.js'; type UnknownRecord = Record; @@ -45,12 +46,9 @@ const parseScenarioEnvironment = (mapConfig: UnknownRecord, constConfig: Unknown const merged = { ...mapConfig, ...constConfig }; const mapName = asString(merged.mapName, 'che'); const unitSet = asString(merged.unitSet, 'che'); - const scenarioEffect = - typeof merged.scenarioEffect === 'string' || merged.scenarioEffect === null ? merged.scenarioEffect : undefined; - const result: ScenarioEnvironment = { mapName, unitSet }; - if (scenarioEffect !== undefined) { - result.scenarioEffect = scenarioEffect; + if (Object.hasOwn(merged, 'scenarioEffect')) { + result.scenarioEffect = normalizeScenarioEffect(merged.scenarioEffect); } return result; }; diff --git a/packages/logic/src/scenario/scenarioEffect.ts b/packages/logic/src/scenario/scenarioEffect.ts new file mode 100644 index 0000000..4a8541e --- /dev/null +++ b/packages/logic/src/scenario/scenarioEffect.ts @@ -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([ + '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); +}; diff --git a/packages/logic/src/scenario/types.ts b/packages/logic/src/scenario/types.ts index 0794880..afabf41 100644 --- a/packages/logic/src/scenario/types.ts +++ b/packages/logic/src/scenario/types.ts @@ -16,7 +16,7 @@ export interface ScenarioDefaults { export interface ScenarioEnvironment { mapName: string; unitSet: string; - scenarioEffect?: string | null; + scenarioEffect?: ScenarioEffectKey | null; } export interface ScenarioConfig { @@ -86,3 +86,4 @@ export interface ScenarioDefinition { initialEvents: unknown[]; ignoreDefaultEvents: boolean; } +import type { ScenarioEffectKey } from './scenarioEffect.js'; diff --git a/packages/logic/src/war/triggers/che_전멸시페이즈증가.ts b/packages/logic/src/war/triggers/che_전멸시페이즈증가.ts new file mode 100644 index 0000000..fa8b076 --- /dev/null +++ b/packages/logic/src/war/triggers/che_전멸시페이즈증가.ts @@ -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, + _opposeEnv: Record + ): boolean { + if (self.getPhase() === 0 || oppose.getPhase() !== 0) { + return true; + } + + self.addBonusPhase(1); + self.getLogger().pushGeneralBattleDetailLog('적군의 전멸에 진격이 이어집니다!', LogFormat.PLAIN); + oppose.getLogger().pushGeneralBattleDetailLog('아군의 전멸에 상대의 진격이 이어집니다!', LogFormat.PLAIN); + return true; + } +} diff --git a/packages/logic/test/actionModuleEvents.test.ts b/packages/logic/test/actionModuleEvents.test.ts index 2171228..12dbbe7 100644 --- a/packages/logic/test/actionModuleEvents.test.ts +++ b/packages/logic/test/actionModuleEvents.test.ts @@ -37,7 +37,7 @@ const makeGeneral = (): General => ({ describe('typed general action events', () => { it('folds synchronous event handlers in the supplied ref ownership order', () => { 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) => ({ eventHandlers: { 'strategy.succeeded': (_context, event) => { @@ -56,8 +56,8 @@ describe('typed general action events', () => { personality: modules[4]!, crewType: modules[5]!, inheritance: modules[6]!, - scenario: null, - items: [modules[7]!], + scenario: modules[7]!, + items: [modules[8]!], }); const pipeline = new GeneralActionPipeline(stack); diff --git a/packages/logic/test/scenarioEffect.test.ts b/packages/logic/test/scenarioEffect.test.ts new file mode 100644 index 0000000..5218133 --- /dev/null +++ b/packages/logic/test/scenarioEffect.test.ts @@ -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: '적군의 전멸에 진격이 이어집니다!' }) + ); + expect(opposeLogger.flush()).toContainEqual( + expect.objectContaining({ text: '아군의 전멸에 상대의 진격이 이어집니다!' }) + ); + }); + + 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' + ); + }); +}); diff --git a/packages/logic/test/scenarioParser.test.ts b/packages/logic/test/scenarioParser.test.ts index 242750e..763c679 100644 --- a/packages/logic/test/scenarioParser.test.ts +++ b/packages/logic/test/scenarioParser.test.ts @@ -76,4 +76,33 @@ describe('scenario parser', () => { 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; + raw.const = { + ...((raw.const as Record | undefined) ?? {}), + scenarioEffect: 'event_Missing', + }; + + expect(() => parseScenarioDefinition(raw, defaults)).toThrow(); + }); }); diff --git a/packages/logic/test/scenarios/domestic.test.ts b/packages/logic/test/scenarios/domestic.test.ts index 7f9fc24..e341f34 100644 --- a/packages/logic/test/scenarios/domestic.test.ts +++ b/packages/logic/test/scenarios/domestic.test.ts @@ -5,6 +5,7 @@ import type { City, General, Nation } from '../../src/domain/entities.js'; import type { WorldSnapshot } from '../../src/world/types.js'; import { commandSpec as developAgricultureSpec } from '../../src/actions/turn/general/che_농지개간.js'; import type { TurnCommandEnv } from '../../src/actions/turn/commandEnv.js'; +import { loadActionModuleBundle } from '../../src/actionModules/bundle.js'; describe('Domestic Affairs Scenario', () => { it('should increase agriculture when executing "Farming" command', async () => { @@ -97,6 +98,8 @@ describe('Domestic Affairs Scenario', () => { events: [], initialEvents: [], }; + const moduleBaselineSnapshot = structuredClone(snapshot); + const moreEffectSnapshot = structuredClone(snapshot); const world = new InMemoryWorld(snapshot); const runner = new TestGameRunner(world, 200, 1); @@ -143,6 +146,39 @@ describe('Domestic Affairs Scenario', () => { const updatedCity = world.getCity(1)!; // 레거시 che_농지개간: 능력치·경험등급·0.8~1.2 난수·성공 배율을 모두 반영한다. 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 () => { diff --git a/resources/schema/scenario.json b/resources/schema/scenario.json index 8a9a886..83380e7 100644 --- a/resources/schema/scenario.json +++ b/resources/schema/scenario.json @@ -110,8 +110,30 @@ }, "const": { "type": "object", - "propertyNames": { - "type": "string" + "properties": { + "scenarioEffect": { + "anyOf": [ + { + "type": "string", + "enum": [ + "event_UnlimitedDefenceThresholdChange", + "event_StrongAttacker", + "event_MoreEffect" + ] + }, + { + "type": "string", + "const": "" + }, + { + "type": "string", + "const": "None" + }, + { + "type": "null" + } + ] + } }, "additionalProperties": {} }, @@ -236,8 +258,30 @@ }, "const": { "type": "object", - "propertyNames": { - "type": "string" + "properties": { + "scenarioEffect": { + "anyOf": [ + { + "type": "string", + "enum": [ + "event_UnlimitedDefenceThresholdChange", + "event_StrongAttacker", + "event_MoreEffect" + ] + }, + { + "type": "string", + "const": "" + }, + { + "type": "string", + "const": "None" + }, + { + "type": "null" + } + ] + } }, "additionalProperties": {} }, diff --git a/tools/integration-tests/src/turn-differential/coreCommandTrace.ts b/tools/integration-tests/src/turn-differential/coreCommandTrace.ts index 85c0994..e6b1935 100644 --- a/tools/integration-tests/src/turn-differential/coreCommandTrace.ts +++ b/tools/integration-tests/src/turn-differential/coreCommandTrace.ts @@ -2,6 +2,7 @@ import { LiteHashDRBG, RandUtil, type RNG } from '@sammo-ts/common'; import { GENERAL_TURN_COMMAND_KEYS, NATION_TURN_COMMAND_KEYS, + normalizeScenarioEffect, type MapDefinition, type Nation, type TurnCommandProfile, @@ -51,6 +52,7 @@ export interface TurnCommandFixtureRequest { year?: number; month?: number; hiddenSeed?: string; + scenarioEffect?: string | null; staticEventHandlers?: Record; }; isolateWorld?: boolean; @@ -428,7 +430,13 @@ export const buildCoreTurnCommandWorldInput = ( ? { 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: { title: '턴 명령 차등', diff --git a/tools/integration-tests/src/turn-differential/referenceSnapshot.ts b/tools/integration-tests/src/turn-differential/referenceSnapshot.ts index 0fa1c9f..3cc4c7c 100644 --- a/tools/integration-tests/src/turn-differential/referenceSnapshot.ts +++ b/tools/integration-tests/src/turn-differential/referenceSnapshot.ts @@ -25,6 +25,20 @@ const withProjectedTraceMeta = (trace: CanonicalTurnCommandTrace): CanonicalTurn 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 => { let current = path.resolve(start); while (true) { @@ -82,8 +96,7 @@ export const runReferenceTurnCommandTrace = (workspaceRoot: string, fixturePath: encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], env: { - ...process.env, - TURN_DIFFERENTIAL_STACK_DIR: stackDirectory, + ...referenceRunnerEnvironment(workspaceRoot, stackDirectory), }, }); return withProjectedTraceMeta(JSON.parse(stdout) as CanonicalTurnCommandTrace); @@ -101,8 +114,7 @@ export const runReferenceTurnCommandTraceRequest = ( encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'], env: { - ...process.env, - TURN_DIFFERENTIAL_STACK_DIR: stackDirectory, + ...referenceRunnerEnvironment(workspaceRoot, stackDirectory), }, }); return withProjectedTraceMeta(JSON.parse(stdout) as CanonicalTurnCommandTrace); diff --git a/tools/integration-tests/test/battleDifferential.test.ts b/tools/integration-tests/test/battleDifferential.test.ts index ef27600..e4536d7 100644 --- a/tools/integration-tests/test/battleDifferential.test.ts +++ b/tools/integration-tests/test/battleDifferential.test.ts @@ -1,5 +1,6 @@ import { execFileSync } from 'node:child_process'; import fs from 'node:fs'; +import os from 'node:os'; import path from 'node:path'; import { LiteHashDRBG, RandUtil, type RNG } from '@sammo-ts/common'; @@ -28,6 +29,21 @@ interface ReferenceTrace { conquered: boolean; events: WarBattleTraceEvent[]; rng: RandomCall[]; + logs: { + attacker: ReferenceLogBuckets; + defenders: Record; + city: ReferenceLogBuckets; + }; +} + +interface ReferenceLogBuckets { + generalHistoryLog: string[]; + generalActionLog: string[]; + generalBattleResultLog: string[]; + generalBattleDetailLog: string[]; + nationalHistoryLog: string[]; + globalHistoryLog: string[]; + globalActionLog: string[]; } interface RandomCall { @@ -110,6 +126,60 @@ const findWorkspaceRoot = (start: string): string | null => { const readJson = (filePath: string): T => JSON.parse(fs.readFileSync(filePath, 'utf8')) as T; 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( 'docker', ['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); }; +const normalizeRandomArguments = (value: Record): Record => + Array.isArray(value) && value.length === 0 ? {} : value; + const assertTraceParity = ( coreEvents: WarBattleTraceEvent[], reference: ReferenceTrace, @@ -176,8 +249,20 @@ const assertTraceParity = ( coreEventNames, `event sequence\ncore=${JSON.stringify(coreEventNames)}\nref=${JSON.stringify(referenceEventNames)}` ).toEqual(referenceEventNames); - expect(coreRng?.calls.map(({ operation, result }) => ({ operation, result }))).toEqual( - reference.rng.map(({ operation, result }) => ({ operation, result })) + expect( + 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) { @@ -185,10 +270,16 @@ const assertTraceParity = ( const ref = reference.events[index]!; expectNearlyEqual(core.attacker.hp, ref.attacker.hp, `event ${index} attacker.hp`); 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) { 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.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 { 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 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; describeWithReference('ref ↔ core2026 battle differential', () => { + it('matches all scenario effects across general, direct-city, and fresh-defender combat', () => { + const unitSet = readJson( + 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( + 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( + '●적군의 전멸에 진격이 이어집니다!' + ); + expect(reference.logs.defenders['3']?.generalBattleDetailLog).toContain( + '●아군의 전멸에 상대의 진격이 이어집니다!' + ); + expect(coreResult.lastWarLog?.generalBattleDetailLog).toContain( + '적군의 전멸에 진격이 이어집니다!' + ); + } + } 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 () => { const reference = runReferenceTraitCatalog(workspaceRoot!); const [nation, domestic, war, personality] = await Promise.all([ @@ -285,9 +504,12 @@ describeWithReference('ref ↔ core2026 battle differential', () => { try { assertTraceParity(coreEvents, runReferenceTrace(workspaceRoot!, JSON.stringify(base)), coreRng); } catch (error) { - throw new Error(`${entry.kind}/${entry.key}: ${error instanceof Error ? error.message : String(error)}`, { - cause: error, - }); + throw new Error( + `${entry.kind}/${entry.key}: ${error instanceof Error ? error.message : String(error)}`, + { + cause: error, + } + ); } } }); @@ -805,12 +1027,16 @@ describeWithReference('ref ↔ core2026 battle differential', () => { process.env['ITEM_PARITY_DEBUG'] === '1' ? `\ncore=${JSON.stringify( coreEvents - .map((event, index) => ({ index, event })) - .filter(({ event }) => event.event === 'phase_power' || event.event === 'phase_damage') + .map((event, index) => ({ index, event })) + .filter( + ({ event }) => event.event === 'phase_power' || event.event === 'phase_damage' + ) )}\nref=${JSON.stringify( reference.events - .map((event, index) => ({ index, event })) - .filter(({ event }) => event.event === 'phase_power' || event.event === 'phase_damage') + .map((event, index) => ({ index, event })) + .filter( + ({ event }) => event.event === 'phase_power' || event.event === 'phase_damage' + ) )}` : ''; failures.push(`${itemKey}: ${error instanceof Error ? error.message : String(error)}${debug}`); @@ -910,11 +1136,15 @@ describeWithReference('ref ↔ core2026 battle differential', () => { ? `\ncore=${JSON.stringify( coreEvents .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( reference.events .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}`); diff --git a/tools/integration-tests/test/liveSortiePersistence.integration.test.ts b/tools/integration-tests/test/liveSortiePersistence.integration.test.ts index cc5e2eb..010a996 100644 --- a/tools/integration-tests/test/liveSortiePersistence.integration.test.ts +++ b/tools/integration-tests/test/liveSortiePersistence.integration.test.ts @@ -13,6 +13,7 @@ import { } from '@sammo-ts/game-engine/turn/reservedTurnStore.js'; import { loadMapDefinitionByName } from '@sammo-ts/game-engine/scenario/mapLoader.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 { @@ -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( workspaceRoot!, `docker_compose_files/reference/fixtures/turn-differential/${fixtureName}` @@ -64,6 +65,7 @@ const readFixture = (fixtureName: string): TurnCommandFixtureRequest => { world: { ...fixture.setup?.world, hiddenSeed: 'turn-command-differential-seed', + ...(scenarioEffect ? { scenarioEffect } : {}), }, generals: fixture.setup?.generals?.map((general) => ({ ...general, @@ -117,6 +119,42 @@ integration('live sortie PostgreSQL persistence retry', () => { 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([ ['conquest and nation collapse', 'live-sortie-conquest.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'], ['conflict arbitration', 'live-sortie-conflict-arbitration.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', - async (_label, fixtureName) => { - const request = readFixture(fixtureName); + async (_label, fixtureName, scenarioEffect?: string) => { + const request = readFixture(fixtureName, scenarioEffect); const reference = runReferenceTurnCommandTraceRequest( workspaceRoot!, request as unknown as Record ); 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 unitSet = await loadUnitSetDefinitionByName('che'); const map = await loadMapDefinitionByName('che'); @@ -431,6 +479,8 @@ integration('live sortie PostgreSQL persistence retry', () => { revision: committedRevision.revision, leaseOwner: committedRevision.leaseOwner, }); + const reloaded = await loadTurnWorldFromDatabase({ databaseUrl: databaseUrl! }); + expect(reloaded.snapshot.scenarioConfig.environment.scenarioEffect).toBe(scenarioEffect ?? null); } finally { await hooks.close(); }