merge: 장수 계략 명령 Ref 호환 이관

This commit is contained in:
2026-08-15 19:15:07 +00:00
15 changed files with 1051 additions and 1052 deletions
+2 -11
View File
@@ -137,17 +137,8 @@ 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_탈취', '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_이동',
@@ -25,11 +25,16 @@ const GENERAL_REF_EDITOR_ACTIONS = [
'che_징병',
'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', () => {
@@ -44,6 +49,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, commandTableResponse:
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('reserves force move, retirement, and resignation from the user command picker', async ({ page }) => {
const specialCommandTable = {
general: [
@@ -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
@@ -24,6 +24,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'],