feat: 장수 계략 명령 전체를 Ref 호환 이관

화계에 묶여 있던 공통 확률·난수·비용·성장 처리를 계략 기반 명령으로 분리한다.

선동·탈취·파괴·화계를 기본 프로필과 계략 분류에 등록하고 Ref 실제 실행, 능력치 상승, 모바일 Chromium 검증을 추가한다.
This commit is contained in:
2026-08-15 19:11:14 +00:00
parent 7b1aa3b179
commit bf2f18aec9
15 changed files with 1050 additions and 1042 deletions
+1 -1
View File
@@ -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_해산'],
});
});
@@ -87,6 +87,9 @@ const LEGACY_STAT_CHANGE_GENERAL_ACTIONS = new Set([
'che_견문',
'che_무작위건국',
'che_화계',
'che_선동',
'che_파괴',
'che_탈취',
'che_집합',
'cr_건국',
'che_이동',
@@ -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<string>(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]));
});
});
+47 -1
View File
@@ -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 });
@@ -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();
@@ -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<TriggerState> {
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<typeof ARGS_SCHEMA>;
export type AgitateArgs = StrategyArgs;
export type AgitateResolveContext<TriggerState extends GeneralTriggerState = GeneralTriggerState> =
StrategyResolveContext<TriggerState>;
export class ActionResolver<
TriggerState extends GeneralTriggerState = GeneralTriggerState,
> implements GeneralActionResolver<TriggerState, AgitateArgs> {
readonly key = ACTION_KEY;
private readonly pipeline: GeneralActionPipeline<TriggerState>;
private readonly command: StrategyCommandResolver<TriggerState>;
> extends StrategyActionResolver<TriggerState> {
constructor(env: TurnCommandEnv) {
const modules = env.generalActionModules ?? [];
this.pipeline = new GeneralActionPipeline(modules);
this.command = new StrategyCommandResolver<TriggerState>(modules, {
...env,
statKey: 'leadership',
damageMode: 'agitate',
});
super(env, CONFIG);
}
resolve(context: GeneralActionResolveContext<TriggerState>, args: AgitateArgs): GeneralActionOutcome<TriggerState> {
const ctx = context as AgitateResolveContext<TriggerState>;
const general = ctx.general;
const destCity = ctx.destCity;
if (!destCity) throw new Error('Target city missing');
const effects: GeneralActionEffect<TriggerState>[] = [];
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(
`<G><b>${destCity.name}</b></>에 ${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<TriggerState>,
_args: StrategyArgs,
result: StrategyResult<TriggerState>,
effects: GeneralActionEffect<TriggerState>[]
): 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(`<G><b>${destCityName}</b></>에 ${commandName}${JosaUtil.pick(commandName, '이')} 성공했습니다.`, {
category: LogCategory.ACTION,
format: LogFormat.MONTH,
});
ctx.addLog(
`도시의 치안이 <C>${result.agriDamage}</>, 민심이 <C>${result.commDamage.toFixed(
1
)}</>만큼 감소하고, 장수 <C>${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('<M>계략</>으로 인해 <R>부상</>을 당했습니다.', {
generalId: injured.id,
format: LogFormat.MONTH,
});
}
return { effects };
const destCityName = context.destCity.name;
context.addLog(`<G><b>${destCityName}</b></>의 백성들이 동요하고 있습니다.`, {
scope: LogScope.SYSTEM,
category: LogCategory.SUMMARY,
format: LogFormat.MONTH,
});
context.addLog(
`<G><b>${destCityName}</b></>에 ${CONFIG.name}${JosaUtil.pick(CONFIG.name, '이')} 성공했습니다.`,
{ format: LogFormat.MONTH }
);
context.addLog(
`도시의 치안이 <C>${result.primaryAmount}</>, 민심이 <C>${result.secondaryAmount.toFixed(
1
)}</>만큼 감소하고, 장수 <C>${result.injuryCount}</>명이 부상 당했습니다.`,
{ format: LogFormat.PLAIN }
);
}
}
export class ActionDefinition<
TriggerState extends GeneralTriggerState = GeneralTriggerState,
> implements GeneralActionDefinition<TriggerState, AgitateArgs, GeneralActionResolveContext<TriggerState>> {
public readonly key = ACTION_KEY;
public readonly name = ACTION_NAME;
private readonly resolver: ActionResolver<TriggerState>;
> extends StrategyActionDefinition<TriggerState> {
constructor(env: TurnCommandEnv) {
this.resolver = new ActionResolver<TriggerState>(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<TriggerState>, args: AgitateArgs): GeneralActionOutcome<TriggerState> {
return this.resolver.resolve(context, args);
super(env, CONFIG, new ActionResolver<TriggerState>(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),
};
@@ -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<TriggerState> {
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<typeof ARGS_SCHEMA>;
export type SeizeArgs = StrategyArgs;
export type SeizeResolveContext<TriggerState extends GeneralTriggerState = GeneralTriggerState> =
StrategyResolveContext<TriggerState>;
export class ActionResolver<
TriggerState extends GeneralTriggerState = GeneralTriggerState,
> implements GeneralActionResolver<TriggerState, SeizeArgs> {
readonly key = ACTION_KEY;
private readonly pipeline: GeneralActionPipeline<TriggerState>;
private readonly command: StrategyCommandResolver<TriggerState>;
> extends StrategyActionResolver<TriggerState> {
constructor(env: TurnCommandEnv) {
const modules = env.generalActionModules ?? [];
this.pipeline = new GeneralActionPipeline(modules);
this.command = new StrategyCommandResolver<TriggerState>(modules, {
...env,
statKey: 'strength',
damageMode: 'seize',
injuryGeneral: false,
});
super(env, CONFIG);
}
resolve(context: GeneralActionResolveContext<TriggerState>, args: SeizeArgs): GeneralActionOutcome<TriggerState> {
const ctx = context as SeizeResolveContext<TriggerState>;
const general = ctx.general;
const nation = ctx.nation; // Own nation
const destCity = ctx.destCity;
const destNation = ctx.destNation;
protected resolveSuccess(
context: StrategyResolveContext<TriggerState>,
args: StrategyArgs,
result: StrategyResult<TriggerState>,
effects: GeneralActionEffect<TriggerState>[]
): 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<TriggerState>[] = [];
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(
`<G><b>${destCity.name}</b></>에 ${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(`<G><b>${destCityName}</b></>에 ${commandName}${JosaUtil.pick(commandName, '이')} 성공했습니다.`, {
category: LogCategory.ACTION,
context.addLog(`<G><b>${destCityName}</b></>에서 금과 쌀을 도둑맞았습니다.`, {
scope: LogScope.SYSTEM,
category: LogCategory.SUMMARY,
format: LogFormat.MONTH,
});
ctx.addLog(`금<C>${stolenGold}</> 쌀<C>${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(
`<G><b>${destCityName}</b></>에 ${CONFIG.name}${JosaUtil.pick(CONFIG.name, '이')} 성공했습니다.`,
{ format: LogFormat.MONTH }
);
context.addLog(
`금<C>${Math.round(stolenGold).toLocaleString('en-US')}</> 쌀<C>${Math.round(stolenRice).toLocaleString(
'en-US'
)}</>을 획득했습니다.`,
{ format: LogFormat.PLAIN }
);
}
}
export class ActionDefinition<
TriggerState extends GeneralTriggerState = GeneralTriggerState,
> implements GeneralActionDefinition<TriggerState, SeizeArgs, GeneralActionResolveContext<TriggerState>> {
public readonly key = ACTION_KEY;
public readonly name = ACTION_NAME;
private readonly resolver: ActionResolver<TriggerState>;
> extends StrategyActionDefinition<TriggerState> {
constructor(env: TurnCommandEnv) {
this.resolver = new ActionResolver<TriggerState>(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<TriggerState>, args: SeizeArgs): GeneralActionOutcome<TriggerState> {
return this.resolver.resolve(context, args);
super(env, CONFIG, new ActionResolver<TriggerState>(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),
};
@@ -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<TriggerState> {
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<typeof ARGS_SCHEMA>;
export type DestroyArgs = StrategyArgs;
export type DestroyResolveContext<TriggerState extends GeneralTriggerState = GeneralTriggerState> =
StrategyResolveContext<TriggerState>;
export class ActionResolver<
TriggerState extends GeneralTriggerState = GeneralTriggerState,
> implements GeneralActionResolver<TriggerState, DestroyArgs> {
readonly key = ACTION_KEY;
private readonly pipeline: GeneralActionPipeline<TriggerState>;
private readonly command: StrategyCommandResolver<TriggerState>;
> extends StrategyActionResolver<TriggerState> {
constructor(env: TurnCommandEnv) {
const modules = env.generalActionModules ?? [];
this.pipeline = new GeneralActionPipeline(modules);
this.command = new StrategyCommandResolver<TriggerState>(modules, {
...env,
statKey: 'strength',
damageMode: 'destroy',
});
super(env, CONFIG);
}
resolve(context: GeneralActionResolveContext<TriggerState>, args: DestroyArgs): GeneralActionOutcome<TriggerState> {
const ctx = context as DestroyResolveContext<TriggerState>;
const general = ctx.general;
const destCity = ctx.destCity;
if (!destCity) throw new Error('Target city missing');
const effects: GeneralActionEffect<TriggerState>[] = [];
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(
`<G><b>${destCity.name}</b></>에 ${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(`<G><b>${destCityName}</b></>에 ${commandName}${JosaUtil.pick(commandName, '이')} 성공했습니다.`, {
category: LogCategory.ACTION,
format: LogFormat.MONTH,
});
ctx.addLog(
`도시의 수비가 <C>${result.agriDamage}</>, 성벽이 <C>${result.commDamage}</>만큼 감소하고, 장수 <C>${result.injuryCount}</>명이 부상 당했습니다.`,
{
category: LogCategory.ACTION,
format: LogFormat.PLAIN,
}
);
// City Update
protected resolveSuccess(
context: StrategyResolveContext<TriggerState>,
_args: StrategyArgs,
result: StrategyResult<TriggerState>,
effects: GeneralActionEffect<TriggerState>[]
): 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('<M>계략</>으로 인해 <R>부상</>을 당했습니다.', {
generalId: injured.id,
format: LogFormat.MONTH,
});
}
return { effects };
const destCityName = context.destCity.name;
context.addLog(`누군가가 <G><b>${destCityName}</b></>의 성벽을 허물었습니다.`, {
scope: LogScope.SYSTEM,
category: LogCategory.SUMMARY,
format: LogFormat.MONTH,
});
context.addLog(
`<G><b>${destCityName}</b></>에 ${CONFIG.name}${JosaUtil.pick(CONFIG.name, '이')} 성공했습니다.`,
{ format: LogFormat.MONTH }
);
context.addLog(
`도시의 수비가 <C>${result.primaryAmount}</>, 성벽이 <C>${result.secondaryAmount}</>만큼 감소하고, 장수 <C>${result.injuryCount}</>명이 부상 당했습니다.`,
{ format: LogFormat.PLAIN }
);
}
}
export class ActionDefinition<
TriggerState extends GeneralTriggerState = GeneralTriggerState,
> implements GeneralActionDefinition<TriggerState, DestroyArgs, GeneralActionResolveContext<TriggerState>> {
public readonly key = ACTION_KEY;
public readonly name = ACTION_NAME;
private readonly resolver: ActionResolver<TriggerState>;
> extends StrategyActionDefinition<TriggerState> {
constructor(env: TurnCommandEnv) {
this.resolver = new ActionResolver<TriggerState>(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<TriggerState>, args: DestroyArgs): GeneralActionOutcome<TriggerState> {
return this.resolver.resolve(context, args);
super(env, CONFIG, new ActionResolver<TriggerState>(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),
};
@@ -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<TriggerState> {
general: General<TriggerState>;
city: City;
nation?: Nation | null;
destCity: City;
destNation?: Nation | null;
destGenerals: General<TriggerState>[];
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<TriggerState> {
destCity: City;
destNation?: Nation | null;
destGenerals: General<TriggerState>[];
distance?: number;
}
export interface FireAttackResult<TriggerState extends GeneralTriggerState = GeneralTriggerState> {
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<General<TriggerState>>;
}>;
}
const ACTION_NAME = '화계';
const ACTION_KEY = '계략';
const ARGS_SCHEMA = z.object({
destCityId: z.number(),
});
export type FireAttackArgs = z.infer<typeof ARGS_SCHEMA>;
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<TriggerState extends GeneralTriggerState = GeneralTriggerState> {
private readonly pipeline: GeneralActionPipeline<TriggerState>;
private readonly env: FireAttackEnvironment;
private readonly statKey: 'leadership' | 'strength' | 'intelligence';
constructor(
modules: ReadonlyArray<GeneralActionModule<TriggerState> | 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<TriggerState>): 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<TriggerState>): 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<TriggerState>, rng: RandomGenerator): FireAttackResult<TriggerState> {
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<General<TriggerState>>;
}> = [];
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<TriggerState extends GeneralTriggerState = GeneralTriggerState> =
StrategyResolveContext<TriggerState>;
export class ActionResolver<
TriggerState extends GeneralTriggerState = GeneralTriggerState,
> implements GeneralActionResolver<TriggerState, FireAttackArgs> {
readonly key = 'che_화계';
private readonly command: CommandResolver<TriggerState>;
private readonly pipeline: GeneralActionPipeline<TriggerState>;
constructor(
modules: ReadonlyArray<GeneralActionModule<TriggerState> | null | undefined>,
env: FireAttackEnvironment
) {
this.command = new CommandResolver(modules, env);
this.pipeline = new GeneralActionPipeline(modules);
> extends StrategyActionResolver<TriggerState> {
constructor(env: TurnCommandEnv) {
super(env, CONFIG);
}
resolve(
context: FireAttackResolveContext<TriggerState>,
_args: FireAttackArgs
): GeneralActionOutcome<TriggerState> {
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<GeneralActionEffect<TriggerState>> = [];
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(
`<G><b>${context.destCity.name}</b></>에 ${commandName}${JosaUtil.pick(commandName, '이')} 실패했습니다.`,
{
format: LogFormat.MONTH,
}
);
return { effects: [] };
}
// 타겟 도시는 Draft가 아니므로 Effect 반환
protected resolveSuccess(
context: StrategyResolveContext<TriggerState>,
_args: StrategyArgs,
result: StrategyResult<TriggerState>,
effects: GeneralActionEffect<TriggerState>[]
): 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(
`<G><b>${context.destCity.name}</b></>에 ${commandName}${JosaUtil.pick(commandName, '이')} 성공했습니다.`,
{
format: LogFormat.MONTH,
}
`<G><b>${destCityName}</b></>에 ${CONFIG.name}${JosaUtil.pick(CONFIG.name, '이')} 성공했습니다.`,
{ format: LogFormat.MONTH }
);
context.addLog(
`도시의 농업이 <C>${result.agriDamage}</>, 상업이 <C>${result.commDamage}</>만큼 감소하고, 장수 <C>${result.injuryCount}</>명이 부상 당했습니다.`,
{
format: LogFormat.PLAIN,
}
`도시의 농업이 <C>${result.primaryAmount}</>, 상업이 <C>${result.secondaryAmount}</>만큼 감소하고, 장수 <C>${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(`<C>${itemCode}</>${JosaUtil.pick(itemCode, '을')} 사용!`, {
format: LogFormat.PLAIN,
});
}
for (const injured of result.injuredGenerals) {
// 타겟 장수는 Draft가 아니므로 Effect 반환
effects.push(createGeneralPatchEffect(injured.patch, injured.id));
context.addLog('<M>계략</>으로 인해 <R>부상</>을 당했습니다.', {
generalId: injured.id,
format: LogFormat.MONTH,
});
}
return { effects };
}
}
export class ActionDefinition<
TriggerState extends GeneralTriggerState = GeneralTriggerState,
> implements GeneralActionDefinition<TriggerState, FireAttackArgs, FireAttackResolveContext<TriggerState>> {
public readonly key = 'che_화계';
public readonly name = ACTION_NAME;
private readonly command: CommandResolver<TriggerState>;
private readonly resolver: ActionResolver<TriggerState>;
constructor(
modules: ReadonlyArray<GeneralActionModule<TriggerState> | 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<TriggerState>, args: FireAttackArgs): GeneralActionOutcome<TriggerState> {
return this.resolver.resolve(context, args);
> extends StrategyActionDefinition<TriggerState> {
constructor(env: TurnCommandEnv) {
super(env, CONFIG, new ActionResolver<TriggerState>(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),
};
@@ -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<typeof STRATEGY_ARGS_SCHEMA>;
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<TriggerState> {
general: General<TriggerState>;
city: City;
nation?: Nation | null;
destCity: City;
destNation?: Nation | null;
destGenerals: General<TriggerState>[];
distance?: number;
}
export interface StrategyResolveContext<
TriggerState extends GeneralTriggerState = GeneralTriggerState,
> extends GeneralActionResolveContext<TriggerState> {
destCity: City;
destNation?: Nation | null;
destGenerals: General<TriggerState>[];
distance?: number;
year?: number;
startYear?: number;
}
export interface StrategyProbability {
attack: number;
defence: number;
distance: number;
success: number;
}
export interface StrategyResult<TriggerState extends GeneralTriggerState = GeneralTriggerState> {
success: boolean;
probability: StrategyProbability;
costGold: number;
costRice: number;
exp: number;
dedication: number;
primaryAmount: number;
secondaryAmount: number;
injuryCount: number;
injuredGenerals: Array<{
id: number;
patch: Partial<General<TriggerState>>;
}>;
}
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<TriggerState extends GeneralTriggerState = GeneralTriggerState> {
private readonly pipeline: GeneralActionPipeline<TriggerState>;
constructor(
modules: ReadonlyArray<GeneralActionModule<TriggerState> | 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<TriggerState>): 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<TriggerState>, rng: RandomGenerator): StrategyResult<TriggerState> {
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<General<TriggerState>>;
}> = [];
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<TriggerState, StrategyArgs> {
public readonly key: StrategyActionConfig['key'];
protected readonly pipeline: GeneralActionPipeline<TriggerState>;
private readonly command: StrategyCommandResolver<TriggerState>;
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<TriggerState>(modules, env, config);
}
protected abstract resolveSuccess(
context: StrategyResolveContext<TriggerState>,
args: StrategyArgs,
result: StrategyResult<TriggerState>,
effects: GeneralActionEffect<TriggerState>[]
): void;
resolve(
context: GeneralActionResolveContext<TriggerState>,
args: StrategyArgs
): GeneralActionOutcome<TriggerState> {
const strategyContext = context as StrategyResolveContext<TriggerState>;
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(
`<G><b>${destCity.name}</b></>에 ${this.config.name}${JosaUtil.pick(this.config.name, '이')} 실패했습니다.`,
{ format: LogFormat.MONTH }
);
return { effects: [] };
}
general.meta = addMetaNumber(general.meta, 'firenum', 1);
const effects: GeneralActionEffect<TriggerState>[] = [];
// Ref의 SabotageInjury()는 대상 도시 효과/성공 로그보다 먼저 저장된다.
for (const injured of result.injuredGenerals) {
effects.push(createGeneralPatchEffect(injured.patch, injured.id));
strategyContext.addLog('<M>계략</>으로 인해 <R>부상</>을 당했습니다.', {
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(`<C>${itemName}</>${JosaUtil.pick(itemRawName, '을')} 사용!`, {
format: LogFormat.PLAIN,
});
}
return { effects };
}
}
export class StrategyActionDefinition<
TriggerState extends GeneralTriggerState = GeneralTriggerState,
> implements GeneralActionDefinition<TriggerState, StrategyArgs, StrategyResolveContext<TriggerState>> {
public readonly key: StrategyActionConfig['key'];
public readonly name: StrategyActionConfig['name'];
private readonly command: StrategyCommandResolver<TriggerState>;
protected constructor(
env: TurnCommandEnv,
config: StrategyActionConfig,
private readonly resolver: StrategyActionResolver<TriggerState>
) {
this.key = config.key;
this.name = config.name;
this.command = new StrategyCommandResolver<TriggerState>(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<TriggerState>, args: StrategyArgs): GeneralActionOutcome<TriggerState> {
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;
@@ -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);
}
);
});
+3
View File
@@ -23,6 +23,9 @@
"che_치안강화",
"che_수비강화",
"che_성벽보수",
"che_선동",
"che_탈취",
"che_파괴",
"che_화계",
"che_집합",
"che_인재탐색",
+5 -4
View File
@@ -33,10 +33,7 @@
"regex": []
},
"General/che_인재탐색": {
"templates": [
"<Y>${}</>${}는 <C>인재</>를 ${}하였습니다!",
"<Y>${}</>${}는 <C>인재</>를 발견하였습니다!"
],
"templates": ["<Y>${}</>${}는 <C>인재</>를 ${}하였습니다!", "<Y>${}</>${}는 <C>인재</>를 발견하였습니다!"],
"regex": []
},
"General/che_기술연구": {
@@ -56,6 +53,10 @@
"templates": ["<G>${}</>에 선동${} 실패했습니다."],
"regex": []
},
"General/che_화계": {
"templates": ["<G>${}</>에 ${}${} 실패했습니다.", "<C>${}</>${} 사용!"],
"regex": []
},
"General/che_은퇴": {
"templates": ["나이가 들어 <R>은퇴</>하고 자손에게 자리를 물려줍니다."],
"regex": []
@@ -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,
@@ -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<string, unknown>
);
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<Record<string, unknown>>, 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<string, unknown>
);
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'],