diff --git a/app/game-api/test/commandTable.test.ts b/app/game-api/test/commandTable.test.ts index 9741acc8..d0d1fd96 100644 --- a/app/game-api/test/commandTable.test.ts +++ b/app/game-api/test/commandTable.test.ts @@ -136,7 +136,7 @@ describe('buildTurnCommandTable', () => { ], 군사: ['che_징병', 'che_모병', 'che_훈련', 'che_사기진작', 'che_출병', 'che_집합', 'che_소집해제'], 인사: ['che_이동', 'che_인재탐색', 'che_귀환', 'che_임관', 'che_랜덤임관'], - 계략: ['che_화계'], + 계략: ['che_선동', 'che_탈취', 'che_파괴', 'che_화계'], 국가: ['che_증여', 'che_헌납', 'che_물자조달', 'che_거병', 'che_건국', 'che_선양', 'che_해산'], }); }); diff --git a/app/game-engine/src/turn/reservedTurnHandler.ts b/app/game-engine/src/turn/reservedTurnHandler.ts index d7d23a0b..5795d86d 100644 --- a/app/game-engine/src/turn/reservedTurnHandler.ts +++ b/app/game-engine/src/turn/reservedTurnHandler.ts @@ -87,6 +87,9 @@ const LEGACY_STAT_CHANGE_GENERAL_ACTIONS = new Set([ 'che_견문', 'che_무작위건국', 'che_화계', + 'che_선동', + 'che_파괴', + 'che_탈취', 'che_집합', 'cr_건국', 'che_이동', diff --git a/app/game-engine/test/defaultCommandProfileAiCoverage.test.ts b/app/game-engine/test/defaultCommandProfileAiCoverage.test.ts index c8c8cc06..53596881 100644 --- a/app/game-engine/test/defaultCommandProfileAiCoverage.test.ts +++ b/app/game-engine/test/defaultCommandProfileAiCoverage.test.ts @@ -23,10 +23,15 @@ const GENERAL_REF_EDITOR_ACTIONS = [ 'che_징병', 'che_출병', 'che_농지개간', + 'che_선동', + 'che_탈취', + 'che_파괴', 'che_화계', 'che_증여', 'che_장비매매', ] as const; +const GENERAL_REF_STRATEGY_ACTIONS = ['che_선동', 'che_탈취', 'che_파괴', 'che_화계'] as const; +const GENERAL_REF_STRATEGY_ACTION_SET = new Set(GENERAL_REF_STRATEGY_ACTIONS); const NATION_REF_EDITOR_ACTIONS = ['che_포상', 'che_발령', 'che_증축', 'che_필사즉생'] as const; describe('default turn command profile AI coverage', () => { @@ -41,6 +46,9 @@ describe('default turn command profile AI coverage', () => { const profile = await loadTurnCommandProfile(); expect(profile.general).toEqual(expect.arrayContaining([...GENERAL_REF_EDITOR_ACTIONS])); + expect(profile.general.filter((action) => GENERAL_REF_STRATEGY_ACTION_SET.has(action))).toEqual( + GENERAL_REF_STRATEGY_ACTIONS + ); expect(profile.nation).toEqual(expect.arrayContaining([...NATION_REF_EDITOR_ACTIONS])); }); }); diff --git a/app/game-frontend/e2e/commandArguments.spec.ts b/app/game-frontend/e2e/commandArguments.spec.ts index 2991c6ac..159cb53f 100644 --- a/app/game-frontend/e2e/commandArguments.spec.ts +++ b/app/game-frontend/e2e/commandArguments.spec.ts @@ -114,7 +114,7 @@ const inputOptions = { const commandTable = { general: [ { - category: '군사', + category: '계략', values: [ { key: 'che_화계', @@ -133,6 +133,26 @@ const commandTable = { }, ], }, + ...[ + { key: 'che_선동', name: '선동' }, + { key: 'che_탈취', name: '탈취' }, + { key: 'che_파괴', name: '파괴' }, + ].map(({ key, name }) => ({ + key, + name, + reqArg: true, + possible: true, + status: 'needsInput', + inputFields: [ + { + key: 'destCityId', + label: '대상 도시', + kind: 'select', + required: true, + optionSource: 'cities', + }, + ], + })), ], }, { @@ -461,6 +481,32 @@ const install = async (page: Page, rejectGeneral = false) => { return requests; }; +test('renders and accepts every Ref strategy command at mobile width', async ({ page }) => { + await install(page); + await page.setViewportSize({ width: 500, height: 900 }); + await page.goto('/'); + await page.getByRole('button', { name: '1턴 명령 입력', exact: true }).click(); + + const picker = page.getByTestId('command-picker'); + await picker.getByRole('button', { name: '계략', exact: true }).click(); + const strategies = [ + { name: '선동', guidance: '선택한 도시에 선동을 실행합니다.' }, + { name: '탈취', guidance: '선택한 도시에 탈취를 실행합니다.' }, + { name: '파괴', guidance: '선택한 도시에 파괴를 실행합니다.' }, + { name: '화계', guidance: '선택한 도시에 화계를 실행합니다.' }, + ]; + for (const strategy of strategies) { + const button = picker.getByRole('button', { name: strategy.name, exact: true }); + await expect(button).toBeVisible(); + await button.click(); + const form = picker.getByTestId('command-argument-form'); + await expect(form.getByTestId('command-argument-guidance')).toContainText(strategy.guidance); + await expect(form.locator('select option')).toHaveCount(2); + await picker.getByRole('button', { name: '명령 다시 선택', exact: true }).click(); + } + await picker.screenshot({ path: test.info().outputPath('all-strategy-commands-mobile.png') }); +}); + test('enters general and nation command arguments and sends exact values', async ({ page }) => { const requests = await install(page); await page.setViewportSize({ width: 1200, height: 900 }); diff --git a/app/game-frontend/e2e/commandArgumentsLive.spec.ts b/app/game-frontend/e2e/commandArgumentsLive.spec.ts index 27425cfb..811a54bd 100644 --- a/app/game-frontend/e2e/commandArgumentsLive.spec.ts +++ b/app/game-frontend/e2e/commandArgumentsLive.spec.ts @@ -69,29 +69,36 @@ test('reserves an argument command in the real game API and reads it back from P try { await page.goto('/'); await expect(page.getByRole('heading', { name: '전장 현황' })).toBeVisible(); - await page.getByRole('button', { name: '계략', exact: true }).click(); - await page.getByRole('button', { name: /화계/ }).click(); - const form = page.getByTestId('command-argument-form'); - await expect(form).toBeVisible(); - const citySelect = form.locator('select'); - const optionValues = await citySelect - .locator('option') - .evaluateAll((options) => options.map((option) => (option as HTMLOptionElement).value)); - const targetCityId = Number(optionValues.find((value) => Number(value) !== context.general.cityId)); - expect(targetCityId).toBeGreaterThan(0); - await citySelect.selectOption(String(targetCityId)); - const generalSection = page.locator('.reserved-section').filter({ hasText: '일반 예턴' }); const lastTurn = generalSection.locator('.reserved-item').nth(29); - await lastTurn.getByRole('button', { name: '배치' }).click(); - await expect(lastTurn.locator('.turn-action')).toHaveText('che_화계'); + const form = page.getByTestId('command-argument-form'); + for (const strategy of [ + { key: 'che_선동', name: '선동' }, + { key: 'che_탈취', name: '탈취' }, + { key: 'che_파괴', name: '파괴' }, + { key: 'che_화계', name: '화계' }, + ]) { + await page.getByRole('button', { name: '계략', exact: true }).click(); + await page.getByRole('button', { name: new RegExp(strategy.name) }).click(); + await expect(form).toBeVisible(); + const citySelect = form.locator('select'); + const optionValues = await citySelect + .locator('option') + .evaluateAll((options) => options.map((option) => (option as HTMLOptionElement).value)); + const targetCityId = Number(optionValues.find((value) => Number(value) !== context.general.cityId)); + expect(targetCityId).toBeGreaterThan(0); + await citySelect.selectOption(String(targetCityId)); - const persisted = (await game.turns.reserved.getGeneral.query({ generalId })).turns[29]; - expect(persisted).toEqual({ - index: 29, - action: 'che_화계', - args: { destCityId: targetCityId }, - }); + await lastTurn.getByRole('button', { name: '배치' }).click(); + await expect(lastTurn.locator('.turn-action')).toHaveText(strategy.key); + + const persisted = (await game.turns.reserved.getGeneral.query({ generalId })).turns[29]; + expect(persisted).toEqual({ + index: 29, + action: strategy.key, + args: { destCityId: targetCityId }, + }); + } await page.getByRole('button', { name: '국가:인사', exact: true }).click(); await page.getByRole('button', { name: /포상/ }).click(); diff --git a/packages/logic/src/actions/turn/general/che_선동.ts b/packages/logic/src/actions/turn/general/che_선동.ts index 93ba9694..62ea1c92 100644 --- a/packages/logic/src/actions/turn/general/che_선동.ts +++ b/packages/logic/src/actions/turn/general/che_선동.ts @@ -1,206 +1,100 @@ -import type { GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js'; -import type { Constraint, ConstraintContext } from '@sammo-ts/logic/constraints/types.js'; -import { - notBeNeutral, - occupiedCity, - suppliedCity, - notOccupiedDestCity, - notNeutralDestCity, - reqGeneralGold, - reqGeneralRice, - disallowDiplomacyBetweenStatus, -} from '@sammo-ts/logic/constraints/presets.js'; -import type { GeneralActionDefinition } from '@sammo-ts/logic/actions/definition.js'; -import type { - GeneralActionOutcome, - GeneralActionResolveContext, - GeneralActionResolver, - GeneralActionEffect, -} from '@sammo-ts/logic/actions/engine.js'; -import { createGeneralPatchEffect, createCityPatchEffect } from '@sammo-ts/logic/actions/engine.js'; -import { LogCategory, LogFormat } from '@sammo-ts/logic/logging/types.js'; -import { z } from 'zod'; -import { readLegacyCityTrust, storeLegacyCityTrust } from './legacyCityTrust.js'; -import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js'; -import type { ActionContextBase, ActionContextOptions } from '@sammo-ts/logic/actions/turn/actionContext.js'; -import type { GeneralTurnCommandSpec } from './index.js'; import { JosaUtil } from '@sammo-ts/common'; -import { parseArgsWithSchema } from '../parseArgs.js'; -import { GeneralActionPipeline } from '@sammo-ts/logic/actionModules/general.js'; -import { consumeSuccessfulStrategyItem } from './strategyItemConsumption.js'; +import { createCityPatchEffect, type GeneralActionEffect } from '@sammo-ts/logic/actions/engine.js'; +import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js'; +import type { GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js'; +import { LogCategory, LogFormat, LogScope } from '@sammo-ts/logic/logging/types.js'; + +import type { GeneralTurnCommandSpec } from './index.js'; +import { readLegacyCityTrust, storeLegacyCityTrust } from './legacyCityTrust.js'; import { + STRATEGY_ARGS_SCHEMA, + StrategyActionDefinition, + StrategyActionResolver, buildStrategyActionContext, - CommandResolver as StrategyCommandResolver, - type FireAttackResolveContext, -} from './che_화계.js'; + type StrategyActionConfig, + type StrategyArgs, + type StrategyResolveContext, + type StrategyResult, +} from './strategyCommand.js'; -export interface AgitateResolveContext< - TriggerState extends GeneralTriggerState = GeneralTriggerState, -> extends FireAttackResolveContext { - env?: TurnCommandEnv; -} +const CONFIG = { + key: 'che_선동', + name: '선동', + statKey: 'leadership', + statExpKey: 'leadership_exp', + damageMode: 'agitate', + injuryGeneral: true, +} as const satisfies StrategyActionConfig; -const ACTION_NAME = '선동'; -const ACTION_KEY = 'che_선동'; -const ARGS_SCHEMA = z.object({ - destCityId: z.number(), -}); -export type AgitateArgs = z.infer; +export type AgitateArgs = StrategyArgs; +export type AgitateResolveContext = + StrategyResolveContext; export class ActionResolver< TriggerState extends GeneralTriggerState = GeneralTriggerState, -> implements GeneralActionResolver { - readonly key = ACTION_KEY; - private readonly pipeline: GeneralActionPipeline; - private readonly command: StrategyCommandResolver; - +> extends StrategyActionResolver { constructor(env: TurnCommandEnv) { - const modules = env.generalActionModules ?? []; - this.pipeline = new GeneralActionPipeline(modules); - this.command = new StrategyCommandResolver(modules, { - ...env, - statKey: 'leadership', - damageMode: 'agitate', - }); + super(env, CONFIG); } - resolve(context: GeneralActionResolveContext, args: AgitateArgs): GeneralActionOutcome { - const ctx = context as AgitateResolveContext; - const general = ctx.general; - const destCity = ctx.destCity; - if (!destCity) throw new Error('Target city missing'); - const effects: GeneralActionEffect[] = []; - const city = ctx.city; - if (!city) throw new Error('Source city missing'); - const result = this.command.resolve( - { - ...ctx, - city, - destCity, - destGenerals: ctx.destGenerals, - }, - ctx.rng - ); - general.gold = Math.max(0, general.gold - result.costGold); - general.rice = Math.max(0, general.rice - result.costRice); - general.experience += result.exp; - general.dedication += result.dedication; - general.meta.leadership_exp = - (typeof general.meta.leadership_exp === 'number' ? general.meta.leadership_exp : 0) + 1; - if (!result.success) { - ctx.addLog( - `${destCity.name}에 ${ACTION_NAME}${JosaUtil.pick(ACTION_NAME, '이')} 실패했습니다.` - ); - return { effects }; - } - general.meta.firenum = (typeof general.meta.firenum === 'number' ? general.meta.firenum : 0) + 1; - const newSecu = Math.max(0, destCity.security - result.agriDamage); + protected resolveSuccess( + context: StrategyResolveContext, + _args: StrategyArgs, + result: StrategyResult, + effects: GeneralActionEffect[] + ): void { const currentTrust = - typeof destCity.meta.trust === 'number' ? readLegacyCityTrust(destCity.meta.trust) : 50; - const newTrust = storeLegacyCityTrust(Math.max(0, currentTrust - result.commDamage)); + typeof context.destCity.meta.trust === 'number' ? readLegacyCityTrust(context.destCity.meta.trust) : 50; + const nextTrust = storeLegacyCityTrust(Math.max(0, currentTrust - result.secondaryAmount)); - // Log - const commandName = ACTION_NAME; - const destCityName = destCity.name; - ctx.addLog(`${destCityName}에 ${commandName}${JosaUtil.pick(commandName, '이')} 성공했습니다.`, { - category: LogCategory.ACTION, - format: LogFormat.MONTH, - }); - ctx.addLog( - `도시의 치안이 ${result.agriDamage}, 민심이 ${result.commDamage.toFixed( - 1 - )}만큼 감소하고, 장수 ${result.injuryCount}명이 부상 당했습니다.`, - { - category: LogCategory.ACTION, - format: LogFormat.PLAIN, - } - ); - - // City Update effects.push( createCityPatchEffect( { - security: newSecu, + security: Math.max(0, context.destCity.security - result.primaryAmount), state: 32, meta: { - ...destCity.meta, - trust: newTrust, + ...context.destCity.meta, + trust: nextTrust, }, }, - args.destCityId + context.destCity.id ) ); - consumeSuccessfulStrategyItem(this.pipeline, context); - for (const injured of result.injuredGenerals) { - effects.push(createGeneralPatchEffect(injured.patch, injured.id)); - ctx.addLog('계략으로 인해 부상을 당했습니다.', { - generalId: injured.id, - format: LogFormat.MONTH, - }); - } - - return { effects }; + const destCityName = context.destCity.name; + context.addLog(`${destCityName}의 백성들이 동요하고 있습니다.`, { + scope: LogScope.SYSTEM, + category: LogCategory.SUMMARY, + format: LogFormat.MONTH, + }); + context.addLog( + `${destCityName}에 ${CONFIG.name}${JosaUtil.pick(CONFIG.name, '이')} 성공했습니다.`, + { format: LogFormat.MONTH } + ); + context.addLog( + `도시의 치안이 ${result.primaryAmount}, 민심이 ${result.secondaryAmount.toFixed( + 1 + )}만큼 감소하고, 장수 ${result.injuryCount}명이 부상 당했습니다.`, + { format: LogFormat.PLAIN } + ); } } export class ActionDefinition< TriggerState extends GeneralTriggerState = GeneralTriggerState, -> implements GeneralActionDefinition> { - public readonly key = ACTION_KEY; - public readonly name = ACTION_NAME; - private readonly resolver: ActionResolver; - +> extends StrategyActionDefinition { constructor(env: TurnCommandEnv) { - this.resolver = new ActionResolver(env); - } - - parseArgs(raw: unknown): AgitateArgs | null { - return parseArgsWithSchema(ARGS_SCHEMA, raw); - } - - buildMinConstraints(ctx: ConstraintContext, _args: AgitateArgs): Constraint[] { - const env = ctx.env; - const cost = ((env.develCost as number) ?? 100) * 5; - return [notBeNeutral(), occupiedCity(), suppliedCity(), reqGeneralGold(() => cost), reqGeneralRice(() => cost)]; - } - - buildConstraints(ctx: ConstraintContext, _args: AgitateArgs): Constraint[] { - const env = ctx.env; - const cost = ((env.develCost as number) ?? 100) * 5; - return [ - notBeNeutral(), - occupiedCity(), - suppliedCity(), - notOccupiedDestCity(), - notNeutralDestCity(), - reqGeneralGold(() => cost), - reqGeneralRice(() => cost), - disallowDiplomacyBetweenStatus({ - 7: '불가침국입니다.', - }), - ]; - } - - resolve(context: GeneralActionResolveContext, args: AgitateArgs): GeneralActionOutcome { - return this.resolver.resolve(context, args); + super(env, CONFIG, new ActionResolver(env)); } } -export const actionContextBuilder = (base: ActionContextBase, options: ActionContextOptions) => { - const strategyContext = buildStrategyActionContext(base, options); - if (!strategyContext) return null; - return { - ...strategyContext, - env: options.scenarioConfig.const as unknown as TurnCommandEnv, - }; -}; +export const actionContextBuilder = buildStrategyActionContext; export const commandSpec: GeneralTurnCommandSpec = { - key: 'che_선동', - category: '군사', + key: CONFIG.key, + category: '계략', reqArg: true, availabilityArgs: { destCityId: 0 }, - argsSchema: ARGS_SCHEMA, + argsSchema: STRATEGY_ARGS_SCHEMA, createDefinition: (env: TurnCommandEnv) => new ActionDefinition(env), }; diff --git a/packages/logic/src/actions/turn/general/che_탈취.ts b/packages/logic/src/actions/turn/general/che_탈취.ts index 40fd74f1..e8346285 100644 --- a/packages/logic/src/actions/turn/general/che_탈취.ts +++ b/packages/logic/src/actions/turn/general/che_탈취.ts @@ -1,124 +1,64 @@ -import type { GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js'; -import type { Constraint, ConstraintContext } from '@sammo-ts/logic/constraints/types.js'; -import { - notBeNeutral, - occupiedCity, - suppliedCity, - notOccupiedDestCity, - notNeutralDestCity, - reqGeneralGold, - reqGeneralRice, - disallowDiplomacyBetweenStatus, -} from '@sammo-ts/logic/constraints/presets.js'; -import type { GeneralActionDefinition } from '@sammo-ts/logic/actions/definition.js'; -import type { - GeneralActionOutcome, - GeneralActionResolveContext, - GeneralActionResolver, - GeneralActionEffect, -} from '@sammo-ts/logic/actions/engine.js'; -import { createCityPatchEffect, createNationPatchEffect } from '@sammo-ts/logic/actions/engine.js'; -import { LogCategory, LogFormat } from '@sammo-ts/logic/logging/types.js'; -import { z } from 'zod'; -import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js'; -import type { ActionContextBase, ActionContextOptions } from '@sammo-ts/logic/actions/turn/actionContext.js'; -import type { GeneralTurnCommandSpec } from './index.js'; import { JosaUtil } from '@sammo-ts/common'; -import { parseArgsWithSchema } from '../parseArgs.js'; -import { GeneralActionPipeline } from '@sammo-ts/logic/actionModules/general.js'; -import { consumeSuccessfulStrategyItem } from './strategyItemConsumption.js'; import { + createCityPatchEffect, + createNationPatchEffect, + type GeneralActionEffect, +} from '@sammo-ts/logic/actions/engine.js'; +import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js'; +import type { GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js'; +import { LogCategory, LogFormat, LogScope } from '@sammo-ts/logic/logging/types.js'; + +import type { GeneralTurnCommandSpec } from './index.js'; +import { + STRATEGY_ARGS_SCHEMA, + StrategyActionDefinition, + StrategyActionResolver, buildStrategyActionContext, - CommandResolver as StrategyCommandResolver, - type FireAttackResolveContext, -} from './che_화계.js'; + type StrategyActionConfig, + type StrategyArgs, + type StrategyResolveContext, + type StrategyResult, +} from './strategyCommand.js'; -export interface SeizeResolveContext< - TriggerState extends GeneralTriggerState = GeneralTriggerState, -> extends FireAttackResolveContext { - env?: TurnCommandEnv; - year?: number; - startYear?: number; -} +const CONFIG = { + key: 'che_탈취', + name: '탈취', + statKey: 'strength', + statExpKey: 'strength_exp', + damageMode: 'seize', + injuryGeneral: false, +} as const satisfies StrategyActionConfig; -const ACTION_NAME = '탈취'; -const ACTION_KEY = 'che_탈취'; -const ARGS_SCHEMA = z.object({ - destCityId: z.number(), -}); -export type SeizeArgs = z.infer; +export type SeizeArgs = StrategyArgs; +export type SeizeResolveContext = + StrategyResolveContext; export class ActionResolver< TriggerState extends GeneralTriggerState = GeneralTriggerState, -> implements GeneralActionResolver { - readonly key = ACTION_KEY; - private readonly pipeline: GeneralActionPipeline; - private readonly command: StrategyCommandResolver; - +> extends StrategyActionResolver { constructor(env: TurnCommandEnv) { - const modules = env.generalActionModules ?? []; - this.pipeline = new GeneralActionPipeline(modules); - this.command = new StrategyCommandResolver(modules, { - ...env, - statKey: 'strength', - damageMode: 'seize', - injuryGeneral: false, - }); + super(env, CONFIG); } - resolve(context: GeneralActionResolveContext, args: SeizeArgs): GeneralActionOutcome { - const ctx = context as SeizeResolveContext; - const general = ctx.general; - const nation = ctx.nation; // Own nation - const destCity = ctx.destCity; - const destNation = ctx.destNation; + protected resolveSuccess( + context: StrategyResolveContext, + args: StrategyArgs, + result: StrategyResult, + effects: GeneralActionEffect[] + ): void { + const { general, nation, destCity, destNation } = context; + const currentYear = context.year ?? 200; + const startYear = context.startYear ?? currentYear; + const yearCoefficient = Math.sqrt(1 + Math.max(0, currentYear - startYear) / 4) / 2; + const commerceRatio = destCity.commerce / destCity.commerceMax; + const agricultureRatio = destCity.agriculture / destCity.agricultureMax; - if (!destCity) throw new Error('Target city missing'); - const effects: GeneralActionEffect[] = []; - const city = ctx.city; - if (!city) throw new Error('Source city missing'); - const result = this.command.resolve({ ...ctx, city, destCity }, ctx.rng); - general.gold = Math.max(0, general.gold - result.costGold); - general.rice = Math.max(0, general.rice - result.costRice); - general.experience += result.exp; - general.dedication += result.dedication; - general.meta.strength_exp = (typeof general.meta.strength_exp === 'number' ? general.meta.strength_exp : 0) + 1; - if (!result.success) { - ctx.addLog( - `${destCity.name}에 ${ACTION_NAME}${JosaUtil.pick(ACTION_NAME, '이')} 실패했습니다.` - ); - return { effects }; - } - general.meta.firenum = (typeof general.meta.firenum === 'number' ? general.meta.firenum : 0) + 1; - - const currentYear = ctx.year ?? 200; - const startYear = ctx.startYear ?? currentYear; - const yearCoef = Math.sqrt(1 + Math.max(0, currentYear - startYear) / 4) / 2; - - const commRatio = destCity.commerce / destCity.commerceMax; - const agriRatio = destCity.agriculture / destCity.agricultureMax; - - const rawGold = result.agriDamage * destCity.level * yearCoef * (0.25 + commRatio / 4); - const rawRice = result.commDamage * destCity.level * yearCoef * (0.25 + agriRatio / 4); - - // 레거시는 탈취량을 부동소수점으로 유지한 채 국가/장수 DB 정수 필드에 - // 기록할 때 반올림한다. 여기서 미리 내림하면 국고와 본국 몫이 1씩 - // 달라질 수 있다. - let stolenGold = rawGold; - let stolenRice = rawRice; - - const isSupplied = destCity.supplyState === 1; - - if (isSupplied && destNation) { - const minGold = 0; - const minRice = 0; - - const availableGold = Math.max(0, destNation.gold - minGold); - const availableRice = Math.max(0, destNation.rice - minRice); - - stolenGold = Math.min(stolenGold, availableGold); - stolenRice = Math.min(stolenRice, availableRice); + let stolenGold = result.primaryAmount * destCity.level * yearCoefficient * (0.25 + commerceRatio / 4); + let stolenRice = result.secondaryAmount * destCity.level * yearCoefficient * (0.25 + agricultureRatio / 4); + if (destCity.supplyState === 1 && destNation) { + stolenGold = Math.min(stolenGold, Math.max(0, destNation.gold)); + stolenRice = Math.min(stolenRice, Math.max(0, destNation.rice)); effects.push( createNationPatchEffect( { @@ -128,40 +68,18 @@ export class ActionResolver< destNation.id ) ); - - effects.push( - createCityPatchEffect( - { - // 레거시는 같은 명령 안에서 잠시 34로 쓴 뒤 최종 32로 - // 덮어쓴다. 관찰 가능한 최종 상태는 32다. - state: 32, - }, - args.destCityId - ) - ); - } else { - // 레거시는 미보급 도시 자원을 먼저 감소시키지만 같은 명령 끝의 - // 원본 destCity 전체 저장이 이를 덮어쓴다. 관찰 가능한 최종 - // 상태는 자원 변화 없이 state 32만 남는다. - effects.push( - createCityPatchEffect( - { - state: 32, - }, - args.destCityId - ) - ); } - let myShareGold = stolenGold; - let myShareRice = stolenRice; + // Ref는 미보급 도시의 일시 자원 감소를 원본 destCity 저장으로 덮어쓴다. + effects.push(createCityPatchEffect({ state: 32 }, args.destCityId)); + let generalShareGold = stolenGold; + let generalShareRice = stolenRice; if (nation && nation.id !== 0) { const nationShareGold = Math.round(stolenGold * 0.7); const nationShareRice = Math.round(stolenRice * 0.7); - myShareGold -= nationShareGold; - myShareRice -= nationShareRice; - + generalShareGold -= nationShareGold; + generalShareRice -= nationShareRice; effects.push( createNationPatchEffect( { @@ -172,85 +90,43 @@ export class ActionResolver< ) ); } + general.gold = Math.round(general.gold + generalShareGold); + general.rice = Math.round(general.rice + generalShareRice); - const commandName = ACTION_NAME; const destCityName = destCity.name; - ctx.addLog(`${destCityName}에 ${commandName}${JosaUtil.pick(commandName, '이')} 성공했습니다.`, { - category: LogCategory.ACTION, + context.addLog(`${destCityName}에서 금과 쌀을 도둑맞았습니다.`, { + scope: LogScope.SYSTEM, + category: LogCategory.SUMMARY, format: LogFormat.MONTH, }); - ctx.addLog(`금${stolenGold} 쌀${stolenRice}을 획득했습니다.`, { - category: LogCategory.ACTION, - format: LogFormat.PLAIN, - }); - - consumeSuccessfulStrategyItem(this.pipeline, context); - general.gold = Math.round(general.gold + myShareGold); - general.rice = Math.round(general.rice + myShareRice); - - return { effects }; + context.addLog( + `${destCityName}에 ${CONFIG.name}${JosaUtil.pick(CONFIG.name, '이')} 성공했습니다.`, + { format: LogFormat.MONTH } + ); + context.addLog( + `금${Math.round(stolenGold).toLocaleString('en-US')} 쌀${Math.round(stolenRice).toLocaleString( + 'en-US' + )}을 획득했습니다.`, + { format: LogFormat.PLAIN } + ); } } export class ActionDefinition< TriggerState extends GeneralTriggerState = GeneralTriggerState, -> implements GeneralActionDefinition> { - public readonly key = ACTION_KEY; - public readonly name = ACTION_NAME; - private readonly resolver: ActionResolver; - +> extends StrategyActionDefinition { constructor(env: TurnCommandEnv) { - this.resolver = new ActionResolver(env); - } - - parseArgs(raw: unknown): SeizeArgs | null { - return parseArgsWithSchema(ARGS_SCHEMA, raw); - } - - buildMinConstraints(ctx: ConstraintContext, _args: SeizeArgs): Constraint[] { - const env = ctx.env; - const cost = ((env.develCost as number) ?? 100) * 5; - return [notBeNeutral(), occupiedCity(), suppliedCity(), reqGeneralGold(() => cost), reqGeneralRice(() => cost)]; - } - - buildConstraints(ctx: ConstraintContext, _args: SeizeArgs): Constraint[] { - const env = ctx.env; - const cost = ((env.develCost as number) ?? 100) * 5; - return [ - notBeNeutral(), - occupiedCity(), - suppliedCity(), - notOccupiedDestCity(), - notNeutralDestCity(), - reqGeneralGold(() => cost), - reqGeneralRice(() => cost), - disallowDiplomacyBetweenStatus({ - 7: '불가침국입니다.', - }), - ]; - } - - resolve(context: GeneralActionResolveContext, args: SeizeArgs): GeneralActionOutcome { - return this.resolver.resolve(context, args); + super(env, CONFIG, new ActionResolver(env)); } } -export const actionContextBuilder = (base: ActionContextBase, options: ActionContextOptions) => { - const strategyContext = buildStrategyActionContext(base, options); - if (!strategyContext) return null; - return { - ...strategyContext, - env: options.scenarioConfig.const as unknown as TurnCommandEnv, - year: options.world.currentYear, - startYear: options.scenarioMeta?.startYear ?? options.world.currentYear, - }; -}; +export const actionContextBuilder = buildStrategyActionContext; export const commandSpec: GeneralTurnCommandSpec = { - key: 'che_탈취', - category: '군사', + key: CONFIG.key, + category: '계략', reqArg: true, availabilityArgs: { destCityId: 0 }, - argsSchema: ARGS_SCHEMA, + argsSchema: STRATEGY_ARGS_SCHEMA, createDefinition: (env: TurnCommandEnv) => new ActionDefinition(env), }; diff --git a/packages/logic/src/actions/turn/general/che_파괴.ts b/packages/logic/src/actions/turn/general/che_파괴.ts index ae9aa0db..0f6833e4 100644 --- a/packages/logic/src/actions/turn/general/che_파괴.ts +++ b/packages/logic/src/actions/turn/general/che_파괴.ts @@ -1,189 +1,90 @@ -import type { GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js'; -import type { Constraint, ConstraintContext } from '@sammo-ts/logic/constraints/types.js'; -import { - notBeNeutral, - occupiedCity, - suppliedCity, - notOccupiedDestCity, - notNeutralDestCity, - reqGeneralGold, - reqGeneralRice, - disallowDiplomacyBetweenStatus, -} from '@sammo-ts/logic/constraints/presets.js'; -import type { GeneralActionDefinition } from '@sammo-ts/logic/actions/definition.js'; -import type { - GeneralActionOutcome, - GeneralActionResolveContext, - GeneralActionResolver, - GeneralActionEffect, -} from '@sammo-ts/logic/actions/engine.js'; -import { createGeneralPatchEffect, createCityPatchEffect } from '@sammo-ts/logic/actions/engine.js'; -import { LogCategory, LogFormat } from '@sammo-ts/logic/logging/types.js'; -import { z } from 'zod'; -import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js'; -import type { ActionContextBase, ActionContextOptions } from '@sammo-ts/logic/actions/turn/actionContext.js'; -import type { GeneralTurnCommandSpec } from './index.js'; import { JosaUtil } from '@sammo-ts/common'; -import { parseArgsWithSchema } from '../parseArgs.js'; -import { GeneralActionPipeline } from '@sammo-ts/logic/actionModules/general.js'; -import { consumeSuccessfulStrategyItem } from './strategyItemConsumption.js'; +import { createCityPatchEffect, type GeneralActionEffect } from '@sammo-ts/logic/actions/engine.js'; +import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js'; +import type { GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js'; +import { LogFormat, LogCategory, LogScope } from '@sammo-ts/logic/logging/types.js'; + +import type { GeneralTurnCommandSpec } from './index.js'; import { + STRATEGY_ARGS_SCHEMA, + StrategyActionDefinition, + StrategyActionResolver, buildStrategyActionContext, - CommandResolver as StrategyCommandResolver, - type FireAttackResolveContext, -} from './che_화계.js'; + type StrategyActionConfig, + type StrategyArgs, + type StrategyResolveContext, + type StrategyResult, +} from './strategyCommand.js'; -export interface DestroyResolveContext< - TriggerState extends GeneralTriggerState = GeneralTriggerState, -> extends FireAttackResolveContext { - env?: TurnCommandEnv; -} +const CONFIG = { + key: 'che_파괴', + name: '파괴', + statKey: 'strength', + statExpKey: 'strength_exp', + damageMode: 'destroy', + injuryGeneral: true, +} as const satisfies StrategyActionConfig; -const ACTION_NAME = '파괴'; -const ACTION_KEY = 'che_파괴'; -const ARGS_SCHEMA = z.object({ - destCityId: z.number(), -}); -export type DestroyArgs = z.infer; +export type DestroyArgs = StrategyArgs; +export type DestroyResolveContext = + StrategyResolveContext; export class ActionResolver< TriggerState extends GeneralTriggerState = GeneralTriggerState, -> implements GeneralActionResolver { - readonly key = ACTION_KEY; - private readonly pipeline: GeneralActionPipeline; - private readonly command: StrategyCommandResolver; - +> extends StrategyActionResolver { constructor(env: TurnCommandEnv) { - const modules = env.generalActionModules ?? []; - this.pipeline = new GeneralActionPipeline(modules); - this.command = new StrategyCommandResolver(modules, { - ...env, - statKey: 'strength', - damageMode: 'destroy', - }); + super(env, CONFIG); } - resolve(context: GeneralActionResolveContext, args: DestroyArgs): GeneralActionOutcome { - const ctx = context as DestroyResolveContext; - const general = ctx.general; - const destCity = ctx.destCity; - if (!destCity) throw new Error('Target city missing'); - const effects: GeneralActionEffect[] = []; - const city = ctx.city; - if (!city) throw new Error('Source city missing'); - const result = this.command.resolve({ ...ctx, city, destCity }, ctx.rng); - general.gold = Math.max(0, general.gold - result.costGold); - general.rice = Math.max(0, general.rice - result.costRice); - general.experience += result.exp; - general.dedication += result.dedication; - general.meta.strength_exp = (typeof general.meta.strength_exp === 'number' ? general.meta.strength_exp : 0) + 1; - if (!result.success) { - ctx.addLog( - `${destCity.name}에 ${ACTION_NAME}${JosaUtil.pick(ACTION_NAME, '이')} 실패했습니다.` - ); - return { effects }; - } - general.meta.firenum = (typeof general.meta.firenum === 'number' ? general.meta.firenum : 0) + 1; - const newDef = Math.max(0, destCity.defence - result.agriDamage); - const newWall = Math.max(0, destCity.wall - result.commDamage); - - // Log - const commandName = ACTION_NAME; - const destCityName = destCity.name; - ctx.addLog(`${destCityName}에 ${commandName}${JosaUtil.pick(commandName, '이')} 성공했습니다.`, { - category: LogCategory.ACTION, - format: LogFormat.MONTH, - }); - ctx.addLog( - `도시의 수비가 ${result.agriDamage}, 성벽이 ${result.commDamage}만큼 감소하고, 장수 ${result.injuryCount}명이 부상 당했습니다.`, - { - category: LogCategory.ACTION, - format: LogFormat.PLAIN, - } - ); - - // City Update + protected resolveSuccess( + context: StrategyResolveContext, + _args: StrategyArgs, + result: StrategyResult, + effects: GeneralActionEffect[] + ): void { effects.push( createCityPatchEffect( { - defence: newDef, - wall: newWall, - state: 32, // Legacy sabotage state + defence: Math.max(0, context.destCity.defence - result.primaryAmount), + wall: Math.max(0, context.destCity.wall - result.secondaryAmount), + state: 32, }, - args.destCityId + context.destCity.id ) ); - consumeSuccessfulStrategyItem(this.pipeline, context); - for (const injured of result.injuredGenerals) { - effects.push(createGeneralPatchEffect(injured.patch, injured.id)); - ctx.addLog('계략으로 인해 부상을 당했습니다.', { - generalId: injured.id, - format: LogFormat.MONTH, - }); - } - - return { effects }; + const destCityName = context.destCity.name; + context.addLog(`누군가가 ${destCityName}의 성벽을 허물었습니다.`, { + scope: LogScope.SYSTEM, + category: LogCategory.SUMMARY, + format: LogFormat.MONTH, + }); + context.addLog( + `${destCityName}에 ${CONFIG.name}${JosaUtil.pick(CONFIG.name, '이')} 성공했습니다.`, + { format: LogFormat.MONTH } + ); + context.addLog( + `도시의 수비가 ${result.primaryAmount}, 성벽이 ${result.secondaryAmount}만큼 감소하고, 장수 ${result.injuryCount}명이 부상 당했습니다.`, + { format: LogFormat.PLAIN } + ); } } export class ActionDefinition< TriggerState extends GeneralTriggerState = GeneralTriggerState, -> implements GeneralActionDefinition> { - public readonly key = ACTION_KEY; - public readonly name = ACTION_NAME; - private readonly resolver: ActionResolver; - +> extends StrategyActionDefinition { constructor(env: TurnCommandEnv) { - this.resolver = new ActionResolver(env); - } - - parseArgs(raw: unknown): DestroyArgs | null { - return parseArgsWithSchema(ARGS_SCHEMA, raw); - } - - buildMinConstraints(ctx: ConstraintContext, _args: DestroyArgs): Constraint[] { - const env = ctx.env; - const cost = ((env.develCost as number) ?? 100) * 5; - return [notBeNeutral(), occupiedCity(), suppliedCity(), reqGeneralGold(() => cost), reqGeneralRice(() => cost)]; - } - - buildConstraints(ctx: ConstraintContext, _args: DestroyArgs): Constraint[] { - const env = ctx.env; - const cost = ((env.develCost as number) ?? 100) * 5; - return [ - notBeNeutral(), - occupiedCity(), - suppliedCity(), - notOccupiedDestCity(), - notNeutralDestCity(), - reqGeneralGold(() => cost), - reqGeneralRice(() => cost), - disallowDiplomacyBetweenStatus({ - 7: '불가침국입니다.', - }), - ]; - } - - resolve(context: GeneralActionResolveContext, args: DestroyArgs): GeneralActionOutcome { - return this.resolver.resolve(context, args); + super(env, CONFIG, new ActionResolver(env)); } } -export const actionContextBuilder = (base: ActionContextBase, options: ActionContextOptions) => { - const strategyContext = buildStrategyActionContext(base, options); - if (!strategyContext) return null; - return { - ...strategyContext, - env: options.scenarioConfig.const as unknown as TurnCommandEnv, - }; -}; +export const actionContextBuilder = buildStrategyActionContext; export const commandSpec: GeneralTurnCommandSpec = { - key: 'che_파괴', - category: '군사', + key: CONFIG.key, + category: '계략', reqArg: true, availabilityArgs: { destCityId: 0 }, - argsSchema: ARGS_SCHEMA, + argsSchema: STRATEGY_ARGS_SCHEMA, createDefinition: (env: TurnCommandEnv) => new ActionDefinition(env), }; diff --git a/packages/logic/src/actions/turn/general/che_화계.ts b/packages/logic/src/actions/turn/general/che_화계.ts index 4efdd675..2cf7d874 100644 --- a/packages/logic/src/actions/turn/general/che_화계.ts +++ b/packages/logic/src/actions/turn/general/che_화계.ts @@ -1,361 +1,54 @@ -import type { RandomGenerator } from '@sammo-ts/common'; -import type { City, General, GeneralMeta, GeneralTriggerState, Nation } from '@sammo-ts/logic/domain/entities.js'; -import type { Constraint, ConstraintContext, StateView } from '@sammo-ts/logic/constraints/types.js'; -import { - disallowDiplomacyBetweenStatus, - notBeNeutral, - notNeutralDestCity, - notOccupiedDestCity, - occupiedCity, - reqGeneralGold, - reqGeneralRice, - suppliedCity, -} from '@sammo-ts/logic/constraints/presets.js'; -import type { GeneralActionContext } from '@sammo-ts/logic/triggers/general.js'; -import { GeneralActionPipeline, type GeneralActionModule } from '@sammo-ts/logic/actionModules/general.js'; -import type { GeneralActionDefinition } from '@sammo-ts/logic/actions/definition.js'; -import type { - GeneralActionEffect, - GeneralActionOutcome, - GeneralActionResolveContext, - GeneralActionResolver, -} from '@sammo-ts/logic/actions/engine.js'; -import { createCityPatchEffect, createGeneralPatchEffect } from '@sammo-ts/logic/actions/engine.js'; -import { LogCategory, LogFormat, LogScope } from '@sammo-ts/logic/logging/types.js'; +import type { GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js'; import { JosaUtil } from '@sammo-ts/common'; -import { z } from 'zod'; +import { createCityPatchEffect, type GeneralActionEffect } from '@sammo-ts/logic/actions/engine.js'; +import { LogCategory, LogFormat, LogScope } from '@sammo-ts/logic/logging/types.js'; import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js'; -import type { - ActionContextBase, - ActionContextBuilder, - ActionContextOptions, -} from '@sammo-ts/logic/actions/turn/actionContext.js'; import type { GeneralTurnCommandSpec } from './index.js'; -import { clamp } from 'es-toolkit'; -import { parseArgsWithSchema } from '../parseArgs.js'; -import { consumeSuccessfulStrategyItem } from './strategyItemConsumption.js'; -import { searchDistance } from '@sammo-ts/logic/world/distance.js'; -import { formatDestCityConstraintFailure } from '../constraintFailure.js'; -export interface FireAttackEnvironment { - develCost: number; - sabotageDefaultProb: number; - sabotageProbCoefByStat: number; - sabotageDefenceCoefByGeneralCount: number; - sabotageDamageMin: number; - sabotageDamageMax: number; - maxSuccessProbability?: number; - statKey?: 'leadership' | 'strength' | 'intelligence'; - getDistance?: (sourceCityId: number, destCityId: number) => number | null; - getDefenceCorrection?: (context: FireAttackContext, defender: General) => number; - getInjuryProbability?: (context: FireAttackContext, defender: General) => number; - damageMode?: 'fire' | 'agitate' | 'destroy' | 'seize'; - injuryGeneral?: boolean; -} +import { + STRATEGY_ARGS_SCHEMA, + StrategyActionDefinition, + StrategyActionResolver, + buildStrategyActionContext, + type StrategyActionConfig, + type StrategyArgs, + type StrategyResolveContext, + type StrategyResult, +} from './strategyCommand.js'; -export interface FireAttackContext< - TriggerState extends GeneralTriggerState = GeneralTriggerState, -> extends GeneralActionContext { - general: General; - city: City; - nation?: Nation | null; - destCity: City; - destNation?: Nation | null; - destGenerals: General[]; - distance?: number; -} +const CONFIG = { + key: 'che_화계', + name: '화계', + statKey: 'intelligence', + statExpKey: 'intel_exp', + damageMode: 'fire', + injuryGeneral: true, +} as const satisfies StrategyActionConfig; -export interface FireAttackResolveContext< - TriggerState extends GeneralTriggerState = GeneralTriggerState, -> extends GeneralActionResolveContext { - destCity: City; - destNation?: Nation | null; - destGenerals: General[]; - distance?: number; -} - -export interface FireAttackResult { - success: boolean; - probability: number; - distance: number; - costGold: number; - costRice: number; - exp: number; - dedication: number; - agriDamage: number; - commDamage: number; - injuryCount: number; - injuredGenerals: Array<{ - id: number; - patch: Partial>; - }>; -} - -const ACTION_NAME = '화계'; -const ACTION_KEY = '계략'; -const ARGS_SCHEMA = z.object({ - destCityId: z.number(), -}); -export type FireAttackArgs = z.infer; -const STAT_EXP_KEY = 'intel_exp'; -const DEFAULT_MAX_PROB = 0.5; -const INJURY_MAX = 80; const CITY_STATE_BURNING = 32; -const randomRangeInt = (rng: RandomGenerator, min: number, max: number): number => rng.nextInt(min, max + 1); - -const getStatValue = (general: General, statKey: 'leadership' | 'strength' | 'intelligence'): number => { - if (statKey === 'leadership') { - return general.stats.leadership; - } - if (statKey === 'strength') { - return general.stats.strength; - } - return general.stats.intelligence; -}; - -const addMetaNumber = (meta: GeneralMeta, key: string, delta: number): GeneralMeta => { - const current = typeof meta[key] === 'number' ? (meta[key] as number) : 0; - return { ...meta, [key]: current + delta }; -}; - -// 화계 성공/실패 및 피해량 계산을 담당한다. -export class CommandResolver { - private readonly pipeline: GeneralActionPipeline; - private readonly env: FireAttackEnvironment; - private readonly statKey: 'leadership' | 'strength' | 'intelligence'; - - constructor( - modules: ReadonlyArray | null | undefined>, - env: FireAttackEnvironment - ) { - this.pipeline = new GeneralActionPipeline(modules); - this.env = env; - this.statKey = env.statKey ?? 'intelligence'; - } - - getCost(): { gold: number; rice: number } { - const cost = this.env.develCost * 5; - return { gold: cost, rice: cost }; - } - - private calcAttackProb(context: FireAttackContext): number { - const stat = getStatValue(context.general, this.statKey); - const prob = stat / this.env.sabotageProbCoefByStat; - return this.pipeline.onCalcDomestic(context, ACTION_KEY, 'success', prob); - } - - private calcDefenceProb(context: FireAttackContext): number { - const destNationId = context.destCity.nationId; - let maxStat = 0; - let probCorrection = 0; - let affectCount = 0; - - for (const defender of context.destGenerals ?? []) { - if (defender.nationId !== destNationId) { - continue; - } - affectCount += 1; - maxStat = Math.max(maxStat, getStatValue(defender, this.statKey)); - probCorrection += this.env.getDefenceCorrection?.(context, defender) ?? 0; - } - - let prob = maxStat / this.env.sabotageProbCoefByStat; - prob += probCorrection; - prob += (Math.log2(affectCount + 1) - 1.25) * this.env.sabotageDefenceCoefByGeneralCount; - - prob += context.destCity.security / context.destCity.securityMax / 5; - prob += context.destCity.supplyState ? 0.1 : 0; - - return prob; - } - - resolve(context: FireAttackContext, rng: RandomGenerator): FireAttackResult { - const { gold: costGold, rice: costRice } = this.getCost(); - const distance = context.distance ?? this.env.getDistance?.(context.general.cityId, context.destCity.id) ?? 99; - - const attackProb = this.calcAttackProb(context); - const defenceProb = this.calcDefenceProb(context); - let probability = this.env.sabotageDefaultProb + attackProb - defenceProb; - probability /= distance; - probability = clamp(probability, 0, this.env.maxSuccessProbability ?? DEFAULT_MAX_PROB); - - const success = rng.nextBool(probability); - - if (!success) { - return { - success, - probability, - distance, - costGold, - costRice, - exp: randomRangeInt(rng, 1, 100), - dedication: randomRangeInt(rng, 1, 70), - agriDamage: 0, - commDamage: 0, - injuryCount: 0, - injuredGenerals: [], - }; - } - - const injuryProbDefault = 0.3; - const injuredGenerals: Array<{ - id: number; - patch: Partial>; - }> = []; - for (const defender of this.env.injuryGeneral === false ? [] : (context.destGenerals ?? [])) { - if (defender.nationId !== context.destCity.nationId) { - continue; - } - const injuryProb = this.env.getInjuryProbability?.(context, defender) ?? injuryProbDefault; - if (!rng.nextBool(injuryProb)) { - continue; - } - const injuryAmount = randomRangeInt(rng, 1, 16); - injuredGenerals.push({ - id: defender.id, - patch: { - injury: clamp(defender.injury + injuryAmount, 0, INJURY_MAX), - crew: Math.round(defender.crew * 0.98), - atmos: Math.round(defender.atmos * 0.98), - train: Math.round(defender.train * 0.98), - }, - }); - } - - const damageMode = this.env.damageMode ?? 'fire'; - let agriDamage: number; - let commDamage: number; - if (damageMode === 'agitate') { - agriDamage = clamp( - randomRangeInt(rng, this.env.sabotageDamageMin, this.env.sabotageDamageMax), - 0, - context.destCity.security - ); - const trust = typeof context.destCity.meta.trust === 'number' ? context.destCity.meta.trust : 0; - commDamage = clamp( - (this.env.sabotageDamageMin + - rng.nextFloat1() * (this.env.sabotageDamageMax - this.env.sabotageDamageMin)) / - 50, - 0, - trust - ); - } else if (damageMode === 'destroy') { - agriDamage = clamp( - randomRangeInt(rng, this.env.sabotageDamageMin, this.env.sabotageDamageMax), - 0, - context.destCity.defence - ); - commDamage = clamp( - randomRangeInt(rng, this.env.sabotageDamageMin, this.env.sabotageDamageMax), - 0, - context.destCity.wall - ); - } else if (damageMode === 'seize') { - agriDamage = randomRangeInt(rng, this.env.sabotageDamageMin, this.env.sabotageDamageMax); - commDamage = randomRangeInt(rng, this.env.sabotageDamageMin, this.env.sabotageDamageMax); - } else { - agriDamage = clamp( - randomRangeInt(rng, this.env.sabotageDamageMin, this.env.sabotageDamageMax), - 0, - context.destCity.agriculture - ); - commDamage = clamp( - randomRangeInt(rng, this.env.sabotageDamageMin, this.env.sabotageDamageMax), - 0, - context.destCity.commerce - ); - } - - return { - success, - probability, - distance, - costGold, - costRice, - exp: randomRangeInt(rng, 201, 300), - dedication: randomRangeInt(rng, 141, 210), - agriDamage, - commDamage, - injuryCount: injuredGenerals.length, - injuredGenerals, - }; - } -} +export type FireAttackArgs = StrategyArgs; +export type FireAttackResolveContext = + StrategyResolveContext; export class ActionResolver< TriggerState extends GeneralTriggerState = GeneralTriggerState, -> implements GeneralActionResolver { - readonly key = 'che_화계'; - private readonly command: CommandResolver; - private readonly pipeline: GeneralActionPipeline; - - constructor( - modules: ReadonlyArray | null | undefined>, - env: FireAttackEnvironment - ) { - this.command = new CommandResolver(modules, env); - this.pipeline = new GeneralActionPipeline(modules); +> extends StrategyActionResolver { + constructor(env: TurnCommandEnv) { + super(env, CONFIG); } - resolve( - context: FireAttackResolveContext, - _args: FireAttackArgs - ): GeneralActionOutcome { - void _args; - const general = context.general; - const city = context.city; - if (!city) { - throw new Error('Fire attack requires a city context.'); - } - - const result = this.command.resolve( - { - ...context, - city, - nation: context.nation ?? null, - destCity: context.destCity, - destNation: context.destNation ?? null, - destGenerals: context.destGenerals, - }, - context.rng - ); - - const effects: Array> = []; - - const nextGold = Math.max(0, general.gold - result.costGold); - const nextRice = Math.max(0, general.rice - result.costRice); - const nextExperience = general.experience + result.exp; - const nextDedication = general.dedication + result.dedication; - - const metaWithStatExp = addMetaNumber(general.meta, STAT_EXP_KEY, 1); - const metaUpdated = result.success ? addMetaNumber(metaWithStatExp, 'firenum', 1) : metaWithStatExp; - - // 직접 수정 (Immer Draft) - general.gold = nextGold; - general.rice = nextRice; - general.experience = nextExperience; - general.dedication = nextDedication; - general.meta = metaUpdated; - - const commandName = ACTION_NAME; - - if (!result.success) { - context.addLog( - `${context.destCity.name}에 ${commandName}${JosaUtil.pick(commandName, '이')} 실패했습니다.`, - { - format: LogFormat.MONTH, - } - ); - return { effects: [] }; - } - - // 타겟 도시는 Draft가 아니므로 Effect 반환 + protected resolveSuccess( + context: StrategyResolveContext, + _args: StrategyArgs, + result: StrategyResult, + effects: GeneralActionEffect[] + ): void { effects.push( createCityPatchEffect( { - agriculture: context.destCity.agriculture - result.agriDamage, - commerce: context.destCity.commerce - result.commDamage, + agriculture: context.destCity.agriculture - result.primaryAmount, + commerce: context.destCity.commerce - result.secondaryAmount, state: CITY_STATE_BURNING, }, context.destCity.id @@ -369,126 +62,31 @@ export class ActionResolver< format: LogFormat.MONTH, }); context.addLog( - `${context.destCity.name}에 ${commandName}${JosaUtil.pick(commandName, '이')} 성공했습니다.`, - { - format: LogFormat.MONTH, - } + `${destCityName}에 ${CONFIG.name}${JosaUtil.pick(CONFIG.name, '이')} 성공했습니다.`, + { format: LogFormat.MONTH } ); context.addLog( - `도시의 농업이 ${result.agriDamage}, 상업이 ${result.commDamage}만큼 감소하고, 장수 ${result.injuryCount}명이 부상 당했습니다.`, - { - format: LogFormat.PLAIN, - } + `도시의 농업이 ${result.primaryAmount}, 상업이 ${result.secondaryAmount}만큼 감소하고, 장수 ${result.injuryCount}명이 부상 당했습니다.`, + { format: LogFormat.PLAIN } ); - - const itemCode = general.role.items.item; - const consumedItems = consumeSuccessfulStrategyItem(this.pipeline, context); - if (typeof itemCode === 'string' && consumedItems.includes(itemCode)) { - context.addLog(`${itemCode}${JosaUtil.pick(itemCode, '을')} 사용!`, { - format: LogFormat.PLAIN, - }); - } - - for (const injured of result.injuredGenerals) { - // 타겟 장수는 Draft가 아니므로 Effect 반환 - effects.push(createGeneralPatchEffect(injured.patch, injured.id)); - context.addLog('계략으로 인해 부상을 당했습니다.', { - generalId: injured.id, - format: LogFormat.MONTH, - }); - } - - return { effects }; } } export class ActionDefinition< TriggerState extends GeneralTriggerState = GeneralTriggerState, -> implements GeneralActionDefinition> { - public readonly key = 'che_화계'; - public readonly name = ACTION_NAME; - private readonly command: CommandResolver; - private readonly resolver: ActionResolver; - - constructor( - modules: ReadonlyArray | null | undefined>, - env: FireAttackEnvironment - ) { - this.command = new CommandResolver(modules, env); - this.resolver = new ActionResolver(modules, env); - } - - parseArgs(raw: unknown): FireAttackArgs | null { - return parseArgsWithSchema(ARGS_SCHEMA, raw); - } - - buildMinConstraints(_ctx: ConstraintContext, _args: FireAttackArgs): Constraint[] { - const { gold, rice } = this.command.getCost(); - return [notBeNeutral(), occupiedCity(), suppliedCity(), reqGeneralGold(() => gold), reqGeneralRice(() => rice)]; - } - - buildConstraints(_ctx: ConstraintContext, _args: FireAttackArgs): Constraint[] { - void _ctx; - void _args; - const { gold, rice } = this.command.getCost(); - return [ - notBeNeutral(), - occupiedCity(), - suppliedCity(), - notOccupiedDestCity(), - notNeutralDestCity(), - reqGeneralGold(() => gold), - reqGeneralRice(() => rice), - disallowDiplomacyBetweenStatus({ - 7: '불가침국입니다.', - }), - ]; - } - - formatConstraintFailure( - reason: string, - _ctx: ConstraintContext, - args: FireAttackArgs, - view: StateView - ): string | null { - return formatDestCityConstraintFailure(reason, this.name, args.destCityId, view, 'location'); - } - - resolve(context: FireAttackResolveContext, args: FireAttackArgs): GeneralActionOutcome { - return this.resolver.resolve(context, args); +> extends StrategyActionDefinition { + constructor(env: TurnCommandEnv) { + super(env, CONFIG, new ActionResolver(env)); } } -export const buildStrategyActionContext = (base: ActionContextBase, options: ActionContextOptions) => { - const destCityId = options.actionArgs.destCityId; - if (typeof destCityId !== 'number' || !options.worldRef) { - return null; - } - const destCity = options.worldRef.getCityById(destCityId); - if (!destCity) { - return null; - } - const destNation = destCity.nationId > 0 ? options.worldRef.getNationById(destCity.nationId) : null; - const destGenerals = options.worldRef - .listGenerals() - .filter((general) => general.cityId === destCity.id && general.nationId === destCity.nationId); - const distance = options.map ? (searchDistance(options.map, base.general.cityId, 5)[destCity.id] ?? 99) : 99; - return { - ...base, - destCity, - destNation, - destGenerals, - distance, - }; -}; - -export const actionContextBuilder: ActionContextBuilder = buildStrategyActionContext; +export const actionContextBuilder = buildStrategyActionContext; export const commandSpec: GeneralTurnCommandSpec = { - key: 'che_화계', + key: CONFIG.key, category: '계략', reqArg: true, availabilityArgs: { destCityId: 0 }, - argsSchema: ARGS_SCHEMA, - createDefinition: (env: TurnCommandEnv) => new ActionDefinition(env.generalActionModules ?? [], env), + argsSchema: STRATEGY_ARGS_SCHEMA, + createDefinition: (env: TurnCommandEnv) => new ActionDefinition(env), }; diff --git a/packages/logic/src/actions/turn/general/strategyCommand.ts b/packages/logic/src/actions/turn/general/strategyCommand.ts new file mode 100644 index 00000000..0c34928e --- /dev/null +++ b/packages/logic/src/actions/turn/general/strategyCommand.ts @@ -0,0 +1,449 @@ +import { JosaUtil, type RandomGenerator } from '@sammo-ts/common'; +import { GeneralActionPipeline, type GeneralActionModule } from '@sammo-ts/logic/actionModules/general.js'; +import type { GeneralActionDefinition } from '@sammo-ts/logic/actions/definition.js'; +import type { + GeneralActionEffect, + GeneralActionOutcome, + GeneralActionResolveContext, + GeneralActionResolver, +} from '@sammo-ts/logic/actions/engine.js'; +import { createGeneralPatchEffect } from '@sammo-ts/logic/actions/engine.js'; +import type { + ActionContextBase, + ActionContextBuilder, + ActionContextOptions, +} from '@sammo-ts/logic/actions/turn/actionContext.js'; +import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js'; +import { + disallowDiplomacyBetweenStatus, + notBeNeutral, + notNeutralDestCity, + notOccupiedDestCity, + occupiedCity, + reqGeneralGold, + reqGeneralRice, + suppliedCity, +} from '@sammo-ts/logic/constraints/presets.js'; +import type { Constraint, ConstraintContext, StateView } from '@sammo-ts/logic/constraints/types.js'; +import type { City, General, GeneralMeta, GeneralTriggerState, Nation } from '@sammo-ts/logic/domain/entities.js'; +import { LogFormat } from '@sammo-ts/logic/logging/types.js'; +import type { GeneralActionContext } from '@sammo-ts/logic/triggers/general.js'; +import { searchDistance } from '@sammo-ts/logic/world/distance.js'; +import { clamp } from 'es-toolkit'; +import { z } from 'zod'; + +import { formatDestCityConstraintFailure } from '../constraintFailure.js'; +import { parseArgsWithSchema } from '../parseArgs.js'; +import { consumeSuccessfulStrategyItem } from './strategyItemConsumption.js'; + +export const STRATEGY_ARGS_SCHEMA = z.object({ + destCityId: z.number(), +}); + +export type StrategyArgs = z.infer; +export type StrategyStatKey = 'leadership' | 'strength' | 'intelligence'; +export type StrategyStatExpKey = 'leadership_exp' | 'strength_exp' | 'intel_exp'; +export type StrategyDamageMode = 'fire' | 'agitate' | 'destroy' | 'seize'; + +export interface StrategyActionConfig { + key: 'che_화계' | 'che_선동' | 'che_파괴' | 'che_탈취'; + name: '화계' | '선동' | '파괴' | '탈취'; + statKey: StrategyStatKey; + statExpKey: StrategyStatExpKey; + damageMode: StrategyDamageMode; + injuryGeneral: boolean; +} + +export interface StrategyEnvironment { + develCost: number; + sabotageDefaultProb: number; + sabotageProbCoefByStat: number; + sabotageDefenceCoefByGeneralCount: number; + sabotageDamageMin: number; + sabotageDamageMax: number; + maxSuccessProbability?: number; + getDistance?: (sourceCityId: number, destCityId: number) => number | null; + getDefenceCorrection?: (context: StrategyContext, defender: General) => number; + getInjuryProbability?: (context: StrategyContext, defender: General) => number; +} + +export interface StrategyContext< + TriggerState extends GeneralTriggerState = GeneralTriggerState, +> extends GeneralActionContext { + general: General; + city: City; + nation?: Nation | null; + destCity: City; + destNation?: Nation | null; + destGenerals: General[]; + distance?: number; +} + +export interface StrategyResolveContext< + TriggerState extends GeneralTriggerState = GeneralTriggerState, +> extends GeneralActionResolveContext { + destCity: City; + destNation?: Nation | null; + destGenerals: General[]; + distance?: number; + year?: number; + startYear?: number; +} + +export interface StrategyProbability { + attack: number; + defence: number; + distance: number; + success: number; +} + +export interface StrategyResult { + success: boolean; + probability: StrategyProbability; + costGold: number; + costRice: number; + exp: number; + dedication: number; + primaryAmount: number; + secondaryAmount: number; + injuryCount: number; + injuredGenerals: Array<{ + id: number; + patch: Partial>; + }>; +} + +const DEFAULT_MAX_PROBABILITY = 0.5; +const INJURY_MAX = 80; + +const randomRangeInt = (rng: RandomGenerator, min: number, max: number): number => rng.nextInt(min, max + 1); + +const getStatValue = (general: General, statKey: StrategyStatKey): number => general.stats[statKey]; + +const addMetaNumber = (meta: GeneralMeta, key: string, delta: number): GeneralMeta => { + const current = typeof meta[key] === 'number' ? (meta[key] as number) : 0; + return { ...meta, [key]: current + delta }; +}; + +/** Ref `che_화계`가 소유한 네 계략의 공통 확률, RNG, 비용과 성장 계산을 분리한 기반. */ +export class StrategyCommandResolver { + private readonly pipeline: GeneralActionPipeline; + + constructor( + modules: ReadonlyArray | null | undefined>, + private readonly env: StrategyEnvironment, + private readonly config: StrategyActionConfig + ) { + this.pipeline = new GeneralActionPipeline(modules); + } + + getCost(): { gold: number; rice: number } { + const cost = this.env.develCost * 5; + return { gold: cost, rice: cost }; + } + + getProbability(context: StrategyContext): StrategyProbability { + const attackBase = getStatValue(context.general, this.config.statKey) / this.env.sabotageProbCoefByStat; + const attack = this.pipeline.onCalcDomestic(context, '계략', 'success', attackBase); + + const destNationId = context.destCity.nationId; + let maxStat = 0; + let defenceCorrection = 0; + let affectCount = 0; + for (const defender of context.destGenerals ?? []) { + if (defender.nationId !== destNationId) { + continue; + } + affectCount += 1; + maxStat = Math.max(maxStat, getStatValue(defender, this.config.statKey)); + defenceCorrection += this.env.getDefenceCorrection?.(context, defender) ?? 0; + } + + let defence = maxStat / this.env.sabotageProbCoefByStat; + defence += defenceCorrection; + defence += (Math.log2(affectCount + 1) - 1.25) * this.env.sabotageDefenceCoefByGeneralCount; + defence += context.destCity.security / context.destCity.securityMax / 5; + defence += context.destCity.supplyState ? 0.1 : 0; + + const distance = context.distance ?? this.env.getDistance?.(context.general.cityId, context.destCity.id) ?? 99; + const success = clamp( + (this.env.sabotageDefaultProb + attack - defence) / distance, + 0, + this.env.maxSuccessProbability ?? DEFAULT_MAX_PROBABILITY + ); + return { attack, defence, distance, success }; + } + + resolve(context: StrategyContext, rng: RandomGenerator): StrategyResult { + const { gold: costGold, rice: costRice } = this.getCost(); + const probability = this.getProbability(context); + const success = rng.nextBool(probability.success); + + if (!success) { + return { + success, + probability, + costGold, + costRice, + exp: randomRangeInt(rng, 1, 100), + dedication: randomRangeInt(rng, 1, 70), + primaryAmount: 0, + secondaryAmount: 0, + injuryCount: 0, + injuredGenerals: [], + }; + } + + const injuredGenerals: Array<{ + id: number; + patch: Partial>; + }> = []; + for (const defender of this.config.injuryGeneral ? (context.destGenerals ?? []) : []) { + if (defender.nationId !== context.destCity.nationId) { + continue; + } + const injuryProbability = this.env.getInjuryProbability?.(context, defender) ?? 0.3; + if (!rng.nextBool(injuryProbability)) { + continue; + } + injuredGenerals.push({ + id: defender.id, + patch: { + injury: clamp(defender.injury + randomRangeInt(rng, 1, 16), 0, INJURY_MAX), + crew: Math.round(defender.crew * 0.98), + atmos: Math.round(defender.atmos * 0.98), + train: Math.round(defender.train * 0.98), + }, + }); + } + + let primaryAmount: number; + let secondaryAmount: number; + if (this.config.damageMode === 'agitate') { + primaryAmount = clamp( + randomRangeInt(rng, this.env.sabotageDamageMin, this.env.sabotageDamageMax), + 0, + context.destCity.security + ); + const trust = typeof context.destCity.meta.trust === 'number' ? context.destCity.meta.trust : 0; + secondaryAmount = clamp( + (this.env.sabotageDamageMin + + rng.nextFloat1() * (this.env.sabotageDamageMax - this.env.sabotageDamageMin)) / + 50, + 0, + trust + ); + } else if (this.config.damageMode === 'destroy') { + primaryAmount = clamp( + randomRangeInt(rng, this.env.sabotageDamageMin, this.env.sabotageDamageMax), + 0, + context.destCity.defence + ); + secondaryAmount = clamp( + randomRangeInt(rng, this.env.sabotageDamageMin, this.env.sabotageDamageMax), + 0, + context.destCity.wall + ); + } else if (this.config.damageMode === 'seize') { + primaryAmount = randomRangeInt(rng, this.env.sabotageDamageMin, this.env.sabotageDamageMax); + secondaryAmount = randomRangeInt(rng, this.env.sabotageDamageMin, this.env.sabotageDamageMax); + } else { + primaryAmount = clamp( + randomRangeInt(rng, this.env.sabotageDamageMin, this.env.sabotageDamageMax), + 0, + context.destCity.agriculture + ); + secondaryAmount = clamp( + randomRangeInt(rng, this.env.sabotageDamageMin, this.env.sabotageDamageMax), + 0, + context.destCity.commerce + ); + } + + return { + success, + probability, + costGold, + costRice, + exp: randomRangeInt(rng, 201, 300), + dedication: randomRangeInt(rng, 141, 210), + primaryAmount, + secondaryAmount, + injuryCount: injuredGenerals.length, + injuredGenerals, + }; + } +} + +export abstract class StrategyActionResolver< + TriggerState extends GeneralTriggerState = GeneralTriggerState, +> implements GeneralActionResolver { + public readonly key: StrategyActionConfig['key']; + protected readonly pipeline: GeneralActionPipeline; + private readonly command: StrategyCommandResolver; + + protected constructor( + protected readonly env: TurnCommandEnv, + protected readonly config: StrategyActionConfig + ) { + const modules = env.generalActionModules ?? []; + this.key = config.key; + this.pipeline = new GeneralActionPipeline(modules); + this.command = new StrategyCommandResolver(modules, env, config); + } + + protected abstract resolveSuccess( + context: StrategyResolveContext, + args: StrategyArgs, + result: StrategyResult, + effects: GeneralActionEffect[] + ): void; + + resolve( + context: GeneralActionResolveContext, + args: StrategyArgs + ): GeneralActionOutcome { + const strategyContext = context as StrategyResolveContext; + const { general, city, destCity } = strategyContext; + if (!city) { + throw new Error('Strategy command requires a source city context.'); + } + if (!destCity) { + throw new Error('Strategy command requires a target city context.'); + } + + const result = this.command.resolve( + { + ...strategyContext, + city, + destCity, + destGenerals: strategyContext.destGenerals, + }, + strategyContext.rng + ); + + general.gold = Math.max(0, general.gold - result.costGold); + general.rice = Math.max(0, general.rice - result.costRice); + general.experience += result.exp; + general.dedication += result.dedication; + general.meta = addMetaNumber(general.meta, this.config.statExpKey, 1); + + if (!result.success) { + strategyContext.addLog( + `${destCity.name}에 ${this.config.name}${JosaUtil.pick(this.config.name, '이')} 실패했습니다.`, + { format: LogFormat.MONTH } + ); + return { effects: [] }; + } + + general.meta = addMetaNumber(general.meta, 'firenum', 1); + const effects: GeneralActionEffect[] = []; + + // Ref의 SabotageInjury()는 대상 도시 효과/성공 로그보다 먼저 저장된다. + for (const injured of result.injuredGenerals) { + effects.push(createGeneralPatchEffect(injured.patch, injured.id)); + strategyContext.addLog('계략으로 인해 부상을 당했습니다.', { + generalId: injured.id, + format: LogFormat.MONTH, + }); + } + + this.resolveSuccess(strategyContext, args, result, effects); + + const itemCode = general.role.items.item; + const consumedItems = consumeSuccessfulStrategyItem(this.pipeline, strategyContext); + if (typeof itemCode === 'string' && consumedItems.includes(itemCode)) { + const item = this.env.itemCatalog?.[itemCode]; + const itemName = item?.name ?? itemCode; + const itemRawName = item?.rawName ?? itemName; + strategyContext.addLog(`${itemName}${JosaUtil.pick(itemRawName, '을')} 사용!`, { + format: LogFormat.PLAIN, + }); + } + + return { effects }; + } +} + +export class StrategyActionDefinition< + TriggerState extends GeneralTriggerState = GeneralTriggerState, +> implements GeneralActionDefinition> { + public readonly key: StrategyActionConfig['key']; + public readonly name: StrategyActionConfig['name']; + private readonly command: StrategyCommandResolver; + + protected constructor( + env: TurnCommandEnv, + config: StrategyActionConfig, + private readonly resolver: StrategyActionResolver + ) { + this.key = config.key; + this.name = config.name; + this.command = new StrategyCommandResolver(env.generalActionModules ?? [], env, config); + } + + parseArgs(raw: unknown): StrategyArgs | null { + return parseArgsWithSchema(STRATEGY_ARGS_SCHEMA, raw); + } + + buildMinConstraints(_ctx: ConstraintContext, _args: StrategyArgs): Constraint[] { + const { gold, rice } = this.command.getCost(); + return [notBeNeutral(), occupiedCity(), suppliedCity(), reqGeneralGold(() => gold), reqGeneralRice(() => rice)]; + } + + buildConstraints(_ctx: ConstraintContext, _args: StrategyArgs): Constraint[] { + const { gold, rice } = this.command.getCost(); + return [ + notBeNeutral(), + occupiedCity(), + suppliedCity(), + notOccupiedDestCity(), + notNeutralDestCity(), + reqGeneralGold(() => gold), + reqGeneralRice(() => rice), + disallowDiplomacyBetweenStatus({ + 7: '불가침국입니다.', + }), + ]; + } + + formatConstraintFailure( + reason: string, + _ctx: ConstraintContext, + args: StrategyArgs, + view: StateView + ): string | null { + return formatDestCityConstraintFailure(reason, this.name, args.destCityId, view, 'location'); + } + + resolve(context: StrategyResolveContext, args: StrategyArgs): GeneralActionOutcome { + return this.resolver.resolve(context, args); + } +} + +export const buildStrategyActionContext = (base: ActionContextBase, options: ActionContextOptions) => { + const destCityId = options.actionArgs.destCityId; + if (typeof destCityId !== 'number' || !options.worldRef) { + return null; + } + const destCity = options.worldRef.getCityById(destCityId); + if (!destCity) { + return null; + } + const destNation = destCity.nationId > 0 ? options.worldRef.getNationById(destCity.nationId) : null; + const destGenerals = options.worldRef + .listGenerals() + .filter((general) => general.cityId === destCity.id && general.nationId === destCity.nationId); + const distance = options.map ? (searchDistance(options.map, base.general.cityId, 5)[destCity.id] ?? 99) : 99; + return { + ...base, + destCity, + destNation, + destGenerals, + distance, + year: options.world.currentYear, + startYear: options.scenarioMeta?.startYear ?? options.world.currentYear, + }; +}; + +export const strategyActionContextBuilder: ActionContextBuilder = buildStrategyActionContext; diff --git a/packages/logic/test/bestGeneralSabotageAudit.test.ts b/packages/logic/test/bestGeneralSabotageAudit.test.ts index 96bf4063..1e3dc2ab 100644 --- a/packages/logic/test/bestGeneralSabotageAudit.test.ts +++ b/packages/logic/test/bestGeneralSabotageAudit.test.ts @@ -2,7 +2,17 @@ import { describe, expect, it } from 'vitest'; import type { City, General, Nation } from '../src/domain/entities.js'; import { commandSpec as fireSpec } from '../src/actions/turn/general/che_화계.js'; +import { commandSpec as agitateSpec } from '../src/actions/turn/general/che_선동.js'; +import { commandSpec as destroySpec } from '../src/actions/turn/general/che_파괴.js'; +import { commandSpec as seizeSpec } from '../src/actions/turn/general/che_탈취.js'; import type { TurnCommandEnv } from '../src/actions/turn/commandEnv.js'; +import type { GeneralTurnCommandSpec } from '../src/actions/turn/general/index.js'; +import { + StrategyActionDefinition, + StrategyCommandResolver, + type StrategyActionConfig, + type StrategyContext, +} from '../src/actions/turn/general/strategyCommand.js'; import type { WorldSnapshot } from '../src/world/types.js'; import { MINIMAL_MAP } from './fixtures/minimalMap.js'; import { InMemoryWorld, TestGameRunner } from './testEnv.js'; @@ -99,52 +109,123 @@ const makeGeneral = (id: number, nationId: number, cityId: number): General => ( }); describe('best-general sabotage audit', () => { - it('repeats real fire-attack turns until one succeeds and increments firenum', async () => { - const attackerNation = makeNation(1); - const defenderNation = makeNation(2); - const attackerCity = makeCity(1, 1); - const defenderCity = makeCity(2, 2); + const strategyCases: Array<[GeneralTurnCommandSpec['key'], GeneralTurnCommandSpec, number]> = [ + ['che_화계', fireSpec, 7], + ['che_선동', agitateSpec, 1], + ['che_파괴', destroySpec, 5], + ['che_탈취', seizeSpec, 3], + ]; + + it('uses the same Ref probability equation through the shared base command', () => { const attacker = makeGeneral(1, 1, 1); const defender = makeGeneral(2, 2, 2); - const snapshot: WorldSnapshot = { - scenarioConfig: { environment: { mapName: 'minimal_map', unitSet: 'default' } } as never, - scenarioMeta: { startYear: 180 } as never, - map: MINIMAL_MAP, - unitSet: { id: 'default', name: 'default', crewTypes: [] }, - nations: [attackerNation, defenderNation], - cities: [attackerCity, defenderCity], - generals: [attacker, defender], - troops: [], - diplomacy: [], - events: [], - initialEvents: [], - }; - const world = new InMemoryWorld(snapshot); - const runner = new TestGameRunner(world, 180, 1, 'best-general-sabotage-audit-2'); - const fire = fireSpec.createDefinition(commandEnv); - let attempts = 0; + const sourceCity = makeCity(1, 1); + const destCity = makeCity(2, 2); + const context = { + general: attacker, + city: sourceCity, + nation: makeNation(1), + destCity, + destNation: makeNation(2), + destGenerals: [defender], + distance: 1, + } as StrategyContext; + const configs: StrategyActionConfig[] = [ + { + key: 'che_화계', + name: '화계', + statKey: 'intelligence', + statExpKey: 'intel_exp', + damageMode: 'fire', + injuryGeneral: true, + }, + { + key: 'che_선동', + name: '선동', + statKey: 'leadership', + statExpKey: 'leadership_exp', + damageMode: 'agitate', + injuryGeneral: true, + }, + { + key: 'che_파괴', + name: '파괴', + statKey: 'strength', + statExpKey: 'strength_exp', + damageMode: 'destroy', + injuryGeneral: true, + }, + { + key: 'che_탈취', + name: '탈취', + statKey: 'strength', + statExpKey: 'strength_exp', + damageMode: 'seize', + injuryGeneral: false, + }, + ]; - while ((world.getGeneral(attacker.id)?.meta.firenum ?? 0) === 0 && attempts < 20) { - attempts += 1; - await runner.runTurn([ - { - generalId: attacker.id, - commandKey: 'che_화계', - resolver: fire, - args: { destCityId: defenderCity.id }, - context: { - destCity: world.getCity(defenderCity.id), - destNation: defenderNation, - destGenerals: [world.getGeneral(defender.id)], - distance: 1, - env: commandEnv, - map: MINIMAL_MAP, - }, - }, - ]); + for (const config of configs) { + const probability = new StrategyCommandResolver([], commandEnv, config).getProbability(context); + + expect(probability).toMatchObject({ distance: 1 }); + expect(probability.success).toBeCloseTo(0.325, 12); + } + for (const [, spec] of strategyCases) { + expect(spec.createDefinition(commandEnv)).toBeInstanceOf(StrategyActionDefinition); + expect(spec.category).toBe('계략'); } - - expect(attempts).toBe(3); - expect(world.getGeneral(attacker.id)?.meta.firenum).toBe(1); }); + + it.each(strategyCases)( + '%s repeats real general turns until success and increments firenum', + async (key, spec, expectedAttempts) => { + const attackerNation = makeNation(1); + const defenderNation = makeNation(2); + const attackerCity = makeCity(1, 1); + const defenderCity = makeCity(2, 2); + const attacker = makeGeneral(1, 1, 1); + const defender = makeGeneral(2, 2, 2); + const snapshot: WorldSnapshot = { + scenarioConfig: { environment: { mapName: 'minimal_map', unitSet: 'default' } } as never, + scenarioMeta: { startYear: 180 } as never, + map: MINIMAL_MAP, + unitSet: { id: 'default', name: 'default', crewTypes: [] }, + nations: [attackerNation, defenderNation], + cities: [attackerCity, defenderCity], + generals: [attacker, defender], + troops: [], + diplomacy: [], + events: [], + initialEvents: [], + }; + const world = new InMemoryWorld(snapshot); + const runner = new TestGameRunner(world, 180, 1, `best-general-sabotage-audit-${key}`); + const strategy = spec.createDefinition(commandEnv); + let attempts = 0; + + while ((world.getGeneral(attacker.id)?.meta.firenum ?? 0) === 0 && attempts < 20) { + attempts += 1; + await runner.runTurn([ + { + generalId: attacker.id, + commandKey: key, + resolver: strategy, + args: { destCityId: defenderCity.id }, + context: { + destCity: world.getCity(defenderCity.id), + destNation: defenderNation, + destGenerals: [world.getGeneral(defender.id)], + distance: 1, + env: commandEnv, + map: MINIMAL_MAP, + }, + }, + ]); + } + + expect(attempts).toBe(expectedAttempts); + expect(world.getGeneral(attacker.id)?.meta.firenum).toBe(1); + } + ); }); diff --git a/resources/turn-commands/default.json b/resources/turn-commands/default.json index 74cc8411..59282f79 100644 --- a/resources/turn-commands/default.json +++ b/resources/turn-commands/default.json @@ -23,6 +23,9 @@ "che_치안강화", "che_수비강화", "che_성벽보수", + "che_선동", + "che_탈취", + "che_파괴", "che_화계", "che_집합", "che_인재탐색", diff --git a/tools/compare-command-logs.ignore.json b/tools/compare-command-logs.ignore.json index 3390f817..464640a3 100644 --- a/tools/compare-command-logs.ignore.json +++ b/tools/compare-command-logs.ignore.json @@ -33,10 +33,7 @@ "regex": [] }, "General/che_인재탐색": { - "templates": [ - "${}${}는 인재를 ${}하였습니다!", - "${}${}는 인재를 발견하였습니다!" - ], + "templates": ["${}${}는 인재를 ${}하였습니다!", "${}${}는 인재를 발견하였습니다!"], "regex": [] }, "General/che_기술연구": { @@ -56,6 +53,10 @@ "templates": ["${}에 선동${} 실패했습니다."], "regex": [] }, + "General/che_화계": { + "templates": ["${}에 ${}${} 실패했습니다.", "${}${} 사용!"], + "regex": [] + }, "General/che_은퇴": { "templates": ["나이가 들어 은퇴하고 자손에게 자리를 물려줍니다."], "regex": [] diff --git a/tools/integration-tests/src/turn-differential/coreCommandTrace.ts b/tools/integration-tests/src/turn-differential/coreCommandTrace.ts index e6b1935c..31ab3149 100644 --- a/tools/integration-tests/src/turn-differential/coreCommandTrace.ts +++ b/tools/integration-tests/src/turn-differential/coreCommandTrace.ts @@ -1,8 +1,10 @@ import { LiteHashDRBG, RandUtil, type RNG } from '@sammo-ts/common'; +import { readLegacyStoredFloat } from '@sammo-ts/logic/compat/legacyFloat.js'; import { GENERAL_TURN_COMMAND_KEYS, NATION_TURN_COMMAND_KEYS, normalizeScenarioEffect, + readLegacyCityTrust, type MapDefinition, type Nation, type TurnCommandProfile, @@ -683,7 +685,10 @@ const projectWorld = ( conflict: city.conflict ?? {}, state: city.state, term: readNumber(city.meta, 'term'), - trust: readNumber(city.meta, 'trust'), + // The reference snapshot observes MariaDB FLOAT through its text + // protocol. Project the in-memory binary32 value at that same + // read boundary before comparing state deltas. + trust: readLegacyCityTrust(readNumber(city.meta, 'trust')), trade: readNumber(city.meta, 'trade'), officerSet: readNumber(city.meta, 'officer_set'), })), @@ -696,7 +701,7 @@ const projectWorld = ( capitalCityId: nation.capitalCityId, gold: toDatabaseInt(nation.gold), rice: toDatabaseInt(nation.rice), - tech: readNumber(nation.meta, 'tech'), + tech: readLegacyStoredFloat(readNumber(nation.meta, 'tech')), level: nation.level, typeCode: nation.typeCode, generalCount: world.listGenerals().filter((general) => general.nationId === nation.id).length, diff --git a/tools/integration-tests/test/turnCommandGeneralMatrix.integration.test.ts b/tools/integration-tests/test/turnCommandGeneralMatrix.integration.test.ts index 401403f2..fcadabed 100644 --- a/tools/integration-tests/test/turnCommandGeneralMatrix.integration.test.ts +++ b/tools/integration-tests/test/turnCommandGeneralMatrix.integration.test.ts @@ -2823,6 +2823,142 @@ type SabotageProbabilityClampCase = { boundary: 'zero' | 'max'; }; +const sabotageStatProgressionCases = [ + { action: 'che_화계', stat: 'intelligence', statExp: 'intelExp' }, + { action: 'che_선동', stat: 'leadership', statExp: 'leadershipExp' }, + { action: 'che_파괴', stat: 'strength', statExp: 'strengthExp' }, + { action: 'che_탈취', stat: 'strength', statExp: 'strengthExp' }, +] as const; + +const sabotageSuccessfulEffectCases = sabotageStatProgressionCases.map(({ action, stat }) => ({ action, stat })); + +const sabotageSuccessfulEffectExpected = { + che_화계: { city: { agriculture: 494, commerce: 859, state: 32 } }, + che_선동: { city: { security: 0, trust: 70.1066, state: 32 } }, + che_파괴: { city: { defence: 536, wall: 222, state: 32 } }, + che_탈취: { + city: { state: 32 }, + nation: { gold: 999_341, rice: 999_200 }, + actor: { gold: 100_108, rice: 100_140 }, + }, +} as const; + +integration('general sabotage successful effect matrix', () => { + it.each(sabotageSuccessfulEffectCases)( + '$action executes a real general turn at the 0.5 probability clamp', + async ({ action, stat }) => { + const request = buildRequest( + action, + { destCityID: 70 }, + { [stat]: 100 }, + { + generals: { 2: { [stat]: 10 } }, + cities: { 70: { security: 100, securityMax: 2_000, supplyState: 1 } }, + } + ); + request.setup!.world!.hiddenSeed = 'general-value-0'; + const reference = runReferenceTurnCommandTraceRequest( + workspaceRoot!, + request as unknown as Record + ); + const core = await runCoreTurnCommandTrace(request, reference.before); + + expect(reference.execution.outcome).toMatchObject({ completed: true }); + expect(core.execution.outcome).toMatchObject({ + requestedAction: action, + actionKey: action, + usedFallback: false, + }); + expect(reference.rng[0]).toMatchObject({ + operation: 'nextBits', + arguments: { bits: 1 }, + result: '01', + }); + expect(hasSuccessfulSabotageLog(reference.after.logs)).toBe(true); + expect(hasSuccessfulSabotageLog(core.after.logs)).toBe(true); + expect(core.rng).toEqual(reference.rng); + const findById = (rows: Array>, id: number) => + rows.find((entry) => entry.id === id); + const expected = sabotageSuccessfulEffectExpected[action]; + expect(findById(reference.after.cities, 70)).toMatchObject(expected.city); + if ('nation' in expected) { + expect(findById(reference.after.nations, 2)).toMatchObject(expected.nation); + } + if ('actor' in expected) { + expect(findById(reference.after.generals, 1)).toMatchObject(expected.actor); + } + expect( + compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, { + ignoredPathPatterns: ignoredLifecyclePaths, + }) + ).toEqual([]); + + if (process.env.TURN_DIFFERENTIAL_SABOTAGE_EVIDENCE === '1') { + process.stderr.write( + `${JSON.stringify({ + action, + probability: 0.5, + rng: reference.rng, + reference: { + actorBefore: findById(reference.before.generals, 1), + actorAfter: findById(reference.after.generals, 1), + targetCityBefore: findById(reference.before.cities, 70), + targetCityAfter: findById(reference.after.cities, 70), + targetNationBefore: findById(reference.before.nations, 2), + targetNationAfter: findById(reference.after.nations, 2), + }, + core: { + actorAfter: findById(core.after.generals, 1), + targetCityAfter: findById(core.after.cities, 70), + targetNationAfter: findById(core.after.nations, 2), + }, + })}\n` + ); + } + }, + 120_000 + ); +}); + +integration('general sabotage stat progression matrix', () => { + it.each(sabotageStatProgressionCases)( + '$action inherits the base strategy stat progression tail', + async ({ action, stat, statExp }) => { + const request = buildRequest( + action, + { destCityID: 70 }, + { [stat]: 100, [statExp]: 29 }, + { + generals: { 2: { [stat]: 10 } }, + cities: { 70: { security: 0, securityMax: 2_000 } }, + } + ); + request.setup!.world!.hiddenSeed = 'general-value-0'; + const reference = runReferenceTurnCommandTraceRequest( + workspaceRoot!, + request as unknown as Record + ); + const core = await runCoreTurnCommandTrace(request, reference.before); + + expect(reference.execution.outcome).toMatchObject({ completed: true }); + expect(core.execution.outcome).toMatchObject({ + requestedAction: action, + actionKey: action, + usedFallback: false, + }); + expect(core.rng).toEqual(reference.rng); + expect(reference.after.generals.find((entry) => entry.id === 1)?.[stat]).toBe(101); + expect(core.after.generals.find((entry) => entry.id === 1)?.[stat]).toBe(101); + expect( + compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, { + ignoredPathPatterns: ignoredLifecyclePaths, + }) + ).toEqual([]); + }, + 120_000 + ); +}); + const sabotageProbabilityClampCases: SabotageProbabilityClampCase[] = ( [ ['che_화계', 'intelligence'],