merge: 장수 계략 명령 Ref 호환 이관
This commit is contained in:
@@ -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]));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
@@ -24,6 +24,9 @@
|
||||
"che_치안강화",
|
||||
"che_수비강화",
|
||||
"che_성벽보수",
|
||||
"che_선동",
|
||||
"che_탈취",
|
||||
"che_파괴",
|
||||
"che_화계",
|
||||
"che_집합",
|
||||
"che_인재탐색",
|
||||
|
||||
@@ -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'],
|
||||
|
||||
Reference in New Issue
Block a user