fix: match scenario 2601 monthly seed progression

This commit is contained in:
2026-08-03 18:56:23 +00:00
parent 2f17584075
commit 58b9a230a7
92 changed files with 3696 additions and 587 deletions
+13
View File
@@ -107,6 +107,11 @@ export interface GeneralActionOutcome<TriggerState extends GeneralTriggerState =
effects: GeneralActionEffect<TriggerState>[];
completed?: boolean;
deletedTroopIds?: number[];
reservedGeneralTurnPlans?: Array<{
generalId: number;
joinTurn: number;
destNationId: number;
}>;
alternative?: {
commandKey: string;
args: unknown;
@@ -148,6 +153,11 @@ export interface GeneralActionResolution {
args: unknown;
};
deletedTroopIds?: number[];
reservedGeneralTurnPlans?: Array<{
generalId: number;
joinTurn: number;
destNationId: number;
}>;
}
export const createGeneralPatchEffect = <TriggerState extends GeneralTriggerState = GeneralTriggerState>(
@@ -391,6 +401,9 @@ export const resolveGeneralAction = <TriggerState extends GeneralTriggerState =
effects: pendingEffects,
...(outcome?.alternative ? { alternative: outcome.alternative } : {}),
...(outcome?.deletedTroopIds?.length ? { deletedTroopIds: outcome.deletedTroopIds } : {}),
...(outcome?.reservedGeneralTurnPlans?.length
? { reservedGeneralTurnPlans: outcome.reservedGeneralTurnPlans }
: {}),
};
if (nextWorld.city) {
resolution.city = nextWorld.city as City;
@@ -158,6 +158,8 @@ const DEFAULT_AFTER_CONFIG = {
techLevelIncYear: 5,
initialAllowedTechLevel: 1,
defaultCityWall: 1000,
baseGold: 0,
baseRice: 2000,
};
const resolveNumber = (record: Record<string, unknown>, keys: string[], fallback: number): number => {
@@ -205,6 +207,8 @@ export const buildWarConfig = (scenarioConfig: ScenarioConfig, unitSet: UnitSetD
maxAtmosByCommand: resolveNumber(constValues, ['maxAtmosByCommand'], DEFAULT_WAR_CONFIG.maxAtmosByCommand),
maxTrainByWar: resolveNumber(constValues, ['maxTrainByWar'], DEFAULT_WAR_CONFIG.maxTrainByWar),
maxAtmosByWar: resolveNumber(constValues, ['maxAtmosByWar'], DEFAULT_WAR_CONFIG.maxAtmosByWar),
maxGeneralStat: resolveNumber(constValues, ['maxLevel'], 255),
statUpgradeLimit: resolveNumber(constValues, ['upgradeLimit'], 30),
castleCrewTypeId,
armTypes: {
footman: 1,
@@ -233,9 +237,11 @@ export const buildWarAftermathConfig = (
),
maxTechLevel: resolveNumber(constValues, ['maxTechLevel'], 0),
defaultCityWall: resolveNumber(constValues, ['defaultCityWall'], DEFAULT_AFTER_CONFIG.defaultCityWall),
baseGold: resolveNumber(constValues, ['baseGold', 'basegold'], 0),
baseRice: resolveNumber(constValues, ['baseRice', 'baserice'], 0),
baseGold: resolveNumber(constValues, ['baseGold', 'basegold'], DEFAULT_AFTER_CONFIG.baseGold),
baseRice: resolveNumber(constValues, ['baseRice', 'baserice'], DEFAULT_AFTER_CONFIG.baseRice),
castleCrewTypeId,
joinMode: 'full',
joinRuinedNpcProbability: resolveNumber(constValues, ['joinRuinedNPCProp'], 0.1),
};
};
@@ -18,6 +18,7 @@ import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js'
import type { ActionContextBuilder, ActionContextBase } from '@sammo-ts/logic/actions/turn/actionContext.js';
import { tryApplyUniqueLottery } from '@sammo-ts/logic/rewards/uniqueLottery.js';
import type { GeneralTurnCommandSpec } from './index.js';
import { GeneralActionPipeline } from '@sammo-ts/logic/actionModules/general.js';
import { getLegacyStringWidth } from '@sammo-ts/logic/troop/management.js';
export interface UprisingArgs {}
@@ -30,8 +31,12 @@ export interface UprisingContext extends ActionContextBase {
}
const ACTION_NAME = '거병';
const formatHourMinute = (date: Date): string =>
`${String(date.getUTCHours()).padStart(2, '0')}:${String(date.getUTCMinutes()).padStart(2, '0')}`;
const formatHourMinute = (date: unknown): string => {
if (!(date instanceof Date) || Number.isNaN(date.getTime())) {
return '00:00';
}
return `${String(date.getUTCHours()).padStart(2, '0')}:${String(date.getUTCMinutes()).padStart(2, '0')}`;
};
const truncateLegacyWidth = (value: string, maxWidth: number): string => {
let result = '';
@@ -52,6 +57,11 @@ export class ActionDefinition<
> implements GeneralActionDefinition<TriggerState, UprisingArgs> {
public readonly key = 'che_거병';
public readonly name = ACTION_NAME;
private readonly pipeline: GeneralActionPipeline<TriggerState>;
constructor(env: TurnCommandEnv) {
this.pipeline = new GeneralActionPipeline(env.generalActionModules ?? []);
}
getInheritanceActiveActionAmount(): number {
return 1;
}
@@ -89,15 +99,6 @@ export class ActionDefinition<
nationName = '㉥' + nationName;
}
const npcNationPolicy =
general.npcState >= 2
? {
values: {
minNPCRecruitCityPopulation: 0,
},
}
: undefined;
const newNation: Nation = {
id: newNationId,
name: nationName,
@@ -116,7 +117,6 @@ export class ActionDefinition<
surlimit: 72,
secretlimit: uprisingCtx.scenarioId >= 1000 ? 1 : 3,
gennum: 1,
...(npcNationPolicy ? { npc_nation_policy: npcNationPolicy } : {}),
},
};
@@ -158,8 +158,8 @@ export class ActionDefinition<
createGeneralPatchEffect<TriggerState>({
nationId: newNationId,
officerLevel: 12,
experience: (general.experience || 0) + 100,
dedication: (general.dedication || 0) + 100,
experience: (general.experience || 0) + this.pipeline.onCalcStat(context, 'experience', 100),
dedication: (general.dedication || 0) + this.pipeline.onCalcStat(context, 'dedication', 100),
meta: {
...general.meta,
belong: 1,
@@ -200,5 +200,5 @@ export const commandSpec: GeneralTurnCommandSpec = {
category: '전략',
reqArg: false,
createDefinition: (_env: TurnCommandEnv) => new ActionDefinition(),
createDefinition: (env: TurnCommandEnv) => new ActionDefinition(env),
};
@@ -23,19 +23,16 @@ import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js'
import type { ActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js';
import { resolveInitYearMonth } from '@sammo-ts/logic/actions/turn/actionContextHelpers.js';
import { tryApplyUniqueLottery } from '@sammo-ts/logic/rewards/uniqueLottery.js';
import { GeneralActionPipeline } from '@sammo-ts/logic/actionModules/general.js';
import type { GeneralTurnCommandSpec } from './index.js';
import { parseArgsWithSchema } from '../parseArgs.js';
import { JosaUtil } from '@sammo-ts/common';
import {
FOUNDING_ARGS_SCHEMA,
getNationTypeDisplayName,
NATION_COLORS,
type FoundingArgs,
} from './foundingShared.js';
import { FOUNDING_ARGS_SCHEMA, getNationTypeDisplayName, NATION_COLORS, type FoundingArgs } from './foundingShared.js';
const ACTION_NAME = '건국';
interface FoundingResolveContext<TriggerState extends GeneralTriggerState = GeneralTriggerState>
extends GeneralActionResolveContext<TriggerState> {
interface FoundingResolveContext<
TriggerState extends GeneralTriggerState = GeneralTriggerState,
> extends GeneralActionResolveContext<TriggerState> {
currentYearMonth?: number;
initYearMonth?: number;
}
@@ -45,6 +42,12 @@ export class ActionDefinition<
> implements GeneralActionDefinition<TriggerState, FoundingArgs> {
public readonly key = 'che_건국';
public readonly name = ACTION_NAME;
private readonly pipeline: GeneralActionPipeline<TriggerState>;
constructor(env: TurnCommandEnv) {
this.pipeline = new GeneralActionPipeline(env.generalActionModules ?? []);
}
getInheritanceActiveActionAmount(): number {
return 1;
}
@@ -74,10 +77,7 @@ export class ActionDefinition<
];
}
resolve(
context: FoundingResolveContext<TriggerState>,
args: FoundingArgs
): GeneralActionOutcome<TriggerState> {
resolve(context: FoundingResolveContext<TriggerState>, args: FoundingArgs): GeneralActionOutcome<TriggerState> {
const general = context.general;
const nation = context.nation!;
const cityId = general.cityId!;
@@ -119,14 +119,11 @@ export class ActionDefinition<
scope: LogScope.GENERAL,
format: LogFormat.YEAR_MONTH,
});
context.addLog(
`<Y>${general.name}</>${josaGeneralYi} <D><b>${args.nationName}</b></>${josaNationUl} 건국`,
{
category: LogCategory.HISTORY,
scope: LogScope.NATION,
format: LogFormat.YEAR_MONTH,
}
);
context.addLog(`<Y>${general.name}</>${josaGeneralYi} <D><b>${args.nationName}</b></>${josaNationUl} 건국`, {
category: LogCategory.HISTORY,
scope: LogScope.NATION,
format: LogFormat.YEAR_MONTH,
});
tryApplyUniqueLottery(context, {
acquireType: '건국',
@@ -157,8 +154,8 @@ export class ActionDefinition<
cityId
),
createGeneralPatchEffect<TriggerState>({
experience: (general.experience || 0) + 1000,
dedication: (general.dedication || 0) + 1000,
experience: (general.experience || 0) + this.pipeline.onCalcStat(context, 'experience', 1000),
dedication: (general.dedication || 0) + this.pipeline.onCalcStat(context, 'dedication', 1000),
}),
];
@@ -186,5 +183,5 @@ export const commandSpec: GeneralTurnCommandSpec = {
colorType: 'number',
},
argsSchema: FOUNDING_ARGS_SCHEMA,
createDefinition: (_env: TurnCommandEnv) => new ActionDefinition(),
createDefinition: (env: TurnCommandEnv) => new ActionDefinition(env),
};
@@ -7,6 +7,7 @@ import { defaultActionContextBuilder } from '@sammo-ts/logic/actions/turn/action
import { tryApplyUniqueLottery } from '@sammo-ts/logic/rewards/uniqueLottery.js';
import type { GeneralTurnCommandSpec } from './index.js';
import { increaseMetaNumber } from '@sammo-ts/logic/war/utils.js';
import { GeneralActionPipeline } from '@sammo-ts/logic/actionModules/general.js';
export interface SightseeingArgs {}
@@ -172,6 +173,11 @@ export class ActionDefinition<
> implements GeneralActionDefinition<TriggerState, SightseeingArgs> {
public readonly key = 'che_견문';
public readonly name = ACTION_NAME;
private readonly pipeline: GeneralActionPipeline<TriggerState>;
public constructor(env: TurnCommandEnv) {
this.pipeline = new GeneralActionPipeline(env.generalActionModules ?? []);
}
parseArgs(_raw: unknown): SightseeingArgs | null {
void _raw;
@@ -231,7 +237,7 @@ export class ActionDefinition<
general.injury = Math.min(80, general.injury + delta);
}
general.experience += exp;
general.experience += this.pipeline.onCalcStat(context, 'experience', exp);
context.addLog(message);
tryApplyUniqueLottery(context, { acquireType: '아이템', reason: ACTION_NAME });
@@ -248,5 +254,5 @@ export const commandSpec: GeneralTurnCommandSpec = {
category: '개인',
reqArg: false,
createDefinition: (_env: TurnCommandEnv) => new ActionDefinition(),
createDefinition: (env: TurnCommandEnv) => new ActionDefinition(env),
};
@@ -17,10 +17,12 @@ import { tryApplyUniqueLottery } from '@sammo-ts/logic/rewards/uniqueLottery.js'
import type { GeneralTurnCommandSpec } from './index.js';
import { parseArgsWithSchema } from '../parseArgs.js';
import { normalizeResourceActionAmount } from '../resourceAmount.js';
import { GeneralActionPipeline } from '@sammo-ts/logic/actionModules/general.js';
export interface TradeEnvironment {
exchangeFee?: number;
maxResourceActionAmount?: number;
generalActionModules?: TurnCommandEnv['generalActionModules'];
}
const ACTION_NAME = '군량매매';
@@ -37,9 +39,11 @@ export class ActionDefinition<
public readonly key = 'che_군량매매';
public readonly name = ACTION_NAME;
private readonly env: TradeEnvironment;
private readonly pipeline: GeneralActionPipeline<TriggerState>;
constructor(env: TradeEnvironment = {}) {
this.env = env;
this.pipeline = new GeneralActionPipeline(env.generalActionModules ?? []);
}
parseArgs(raw: unknown): TradeArgs | null {
@@ -126,8 +130,8 @@ export class ActionDefinition<
}
// 경험치 및 명성 증가
general.experience += 30;
general.dedication += 50;
general.experience += this.pipeline.onCalcStat(context, 'experience', 30);
general.dedication += this.pipeline.onCalcStat(context, 'dedication', 50);
const weightedStats = [
['leadership_exp', general.stats.leadership],
['strength_exp', general.stats.strength],
@@ -167,5 +171,6 @@ export const commandSpec: GeneralTurnCommandSpec = {
createDefinition: (env: TurnCommandEnv) =>
new ActionDefinition({
maxResourceActionAmount: env.maxResourceActionAmount,
generalActionModules: env.generalActionModules,
}),
};
@@ -22,6 +22,7 @@ import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js'
import type { ActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js';
import { tryApplyUniqueLottery } from '@sammo-ts/logic/rewards/uniqueLottery.js';
import type { GeneralTurnCommandSpec } from './index.js';
import { GeneralActionPipeline } from '@sammo-ts/logic/actionModules/general.js';
export interface ReturnArgs {}
@@ -41,6 +42,11 @@ export class ActionResolver<
TriggerState extends GeneralTriggerState = GeneralTriggerState,
> implements GeneralActionResolver<TriggerState, ReturnArgs> {
readonly key = ACTION_KEY;
private readonly pipeline: GeneralActionPipeline<TriggerState>;
constructor(generalActionModules?: TurnCommandEnv['generalActionModules']) {
this.pipeline = new GeneralActionPipeline(generalActionModules ?? []);
}
resolve(context: ReturnResolveContext<TriggerState>, _args: ReturnArgs): GeneralActionOutcome<TriggerState> {
const general = context.general;
@@ -116,8 +122,8 @@ export class ActionResolver<
// We can just use standard MONTH format for now.
});
const exp = 70;
const ded = 100;
const exp = this.pipeline.onCalcStat(context, 'experience', 70);
const ded = this.pipeline.onCalcStat(context, 'dedication', 100);
tryApplyUniqueLottery(context, { acquireType: '아이템', reason: ACTION_NAME });
@@ -159,8 +165,8 @@ export class ActionDefinition<
public readonly name = ACTION_NAME;
private readonly resolver: ActionResolver<TriggerState>;
constructor() {
this.resolver = new ActionResolver();
constructor(env: Pick<TurnCommandEnv, 'generalActionModules'> = {}) {
this.resolver = new ActionResolver(env.generalActionModules);
}
parseArgs(_raw: unknown): ReturnArgs | null {
@@ -192,5 +198,5 @@ export const commandSpec: GeneralTurnCommandSpec = {
category: '군사',
reqArg: false,
createDefinition: (_env: TurnCommandEnv) => new ActionDefinition(),
createDefinition: (env: TurnCommandEnv) => new ActionDefinition(env),
};
@@ -27,6 +27,11 @@ import {
} from './che_상업투자.js';
import { JosaUtil } from '@sammo-ts/common';
import { clamp } from 'es-toolkit';
import {
addLegacyStoredFloat,
readLegacyStoredFloat,
toLegacyStoredFloat,
} from '@sammo-ts/logic/compat/legacyFloat.js';
export interface TechResearchArgs {}
@@ -55,7 +60,17 @@ const readTech = (nation: Nation): number => {
};
// 레거시 nation.tech는 MariaDB FLOAT이며 다음 명령 재조회 시 6자리 유효숫자로 양자화된다.
const toLegacyStoredTech = (value: number): number => Number(Math.fround(value).toPrecision(6));
// Ref stores tech in a MariaDB FLOAT column. FLOAT applies binary32
// quantization on write; its six-significant-digit text rendering happens
// only when the value is read, not on every update.
export const toLegacyStoredTech = toLegacyStoredFloat;
// mysqli renders a MariaDB FLOAT with six significant decimal digits before
// PHP performs the next command's arithmetic. Model that read boundary, then
// model the binary32 write boundary separately.
export const readLegacyStoredTech = readLegacyStoredFloat;
export const addLegacyStoredTech = addLegacyStoredFloat;
export class ActionDefinition<
TriggerState extends GeneralTriggerState = GeneralTriggerState,
@@ -107,10 +122,21 @@ export class ActionDefinition<
techScore /= 4;
}
const generalCount = Math.max(context.nationGeneralCount, this.env.initialNationGenLimit);
if (
(process.env.CORE_AI_TRACE_GENERAL_IDS?.split(',') ?? []).includes(String(context.general.id)) ||
(process.env.CORE_AI_TRACE_NATION_IDS?.split(',') ?? []).includes(String(context.nation.id))
) {
process.stdout.write(
`AI_ACTION_PATCH_TRACE ${JSON.stringify({ engine: 'core-tech', generalId: context.general.id, nationId: context.nation.id, currentTech, techScore, nationGeneralCount: context.nationGeneralCount, generalCount, delta: techScore / generalCount })}\n`
);
}
context.nation.meta = {
...context.nation.meta,
tech: toLegacyStoredTech(
currentTech + techScore / Math.max(context.nationGeneralCount, this.env.initialNationGenLimit)
tech: addLegacyStoredTech(
currentTech,
techScore / generalCount
),
};
context.general.gold = Math.max(0, context.general.gold - result.costGold);
@@ -15,6 +15,8 @@ import { tryApplyUniqueLottery } from '@sammo-ts/logic/rewards/uniqueLottery.js'
import type { GeneralTurnCommandSpec } from './index.js';
import type { ActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js';
import { resolveStartYear } from '@sammo-ts/logic/actions/turn/actionContextHelpers.js';
import { GeneralActionPipeline } from '@sammo-ts/logic/actionModules/general.js';
import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js';
export interface RandomAppointmentArgs {}
@@ -36,6 +38,8 @@ export interface RandomAppointmentResolveContext<
}
const ACTION_NAME = '무작위 국가로 임관';
const LEGACY_INITIAL_NATION_GEN_LIMIT = 10;
const LEGACY_DEFAULT_MAX_GENERAL = 500;
const TALK_LIST = [
'어쩌다 보니',
@@ -138,6 +142,11 @@ export class ActionDefinition<
> {
public readonly key = 'che_랜덤임관';
public readonly name = ACTION_NAME;
private readonly pipeline: GeneralActionPipeline<TriggerState>;
constructor(env: TurnCommandEnv) {
this.pipeline = new GeneralActionPipeline(env.generalActionModules ?? []);
}
getInheritanceActiveActionAmount(): number {
return 1;
}
@@ -236,7 +245,7 @@ export class ActionDefinition<
nationId: destNation.id,
officerLevel: 1,
cityId: destCityId,
experience: general.experience + expGain,
experience: general.experience + this.pipeline.onCalcStat(context, 'experience', expGain),
meta,
}),
createNationPatchEffect(
@@ -294,8 +303,16 @@ export const actionContextBuilder: ActionContextBuilder = (base, options) => {
const constValues = asRecord(options.scenarioConfig.const);
const worldMeta = asRecord(options.world.meta);
const initialNationGenLimit = resolveNumber(constValues, ['initialNationGenLimit'], 0);
const defaultMaxGeneral = resolveNumber(constValues, ['defaultMaxGeneral', 'maxGeneral'], 0);
const initialNationGenLimit = resolveNumber(
constValues,
['initialNationGenLimit'],
LEGACY_INITIAL_NATION_GEN_LIMIT
);
const defaultMaxGeneral = resolveNumber(
constValues,
['defaultMaxGeneral', 'maxGeneral'],
LEGACY_DEFAULT_MAX_GENERAL
);
const genLimit = relYear < 3 && initialNationGenLimit > 0 ? initialNationGenLimit : defaultMaxGeneral;
const generals = worldRef.listGenerals();
@@ -314,7 +331,11 @@ export const actionContextBuilder: ActionContextBuilder = (base, options) => {
}
}
const nations = worldRef.listNations();
// Ref's GROUP BY result is observed in ascending nation id order. The
// weighted draw is order-sensitive, while newly founded Core nations can
// remain in action/creation order, so make the legacy candidate order
// explicit before consuming the command RNG.
const nations = [...worldRef.listNations()].sort((left, right) => left.id - right.id);
const candidateNations: Array<CandidateNation> = [];
for (const nation of nations) {
@@ -373,5 +394,5 @@ export const commandSpec: GeneralTurnCommandSpec = {
key: 'che_랜덤임관',
category: '전략',
reqArg: false,
createDefinition: () => new ActionDefinition(),
createDefinition: (env: TurnCommandEnv) => new ActionDefinition(env),
};
@@ -25,6 +25,8 @@ interface ProcureContext<
const ACTION_NAME = '물자조달';
const ACTION_KEY = 'che_물자조달';
export const roundLegacyAccumulatedInteger = (current: number, delta: number): number => Math.round(current + delta);
export class ActionResolver<
TriggerState extends GeneralTriggerState = GeneralTriggerState,
> implements GeneralActionResolver<TriggerState, ProcureArgs> {
@@ -96,13 +98,17 @@ export class ActionResolver<
score = Math.round(score);
// 6. Calculate Exp/Dedication
const exp = (score * 0.7) / 3;
const ded = (score * 1.0) / 3;
const exp = this.pipeline.onCalcStat(context, 'experience', (score * 0.7) / 3);
const ded = this.pipeline.onCalcStat(context, 'dedication', (score * 1.0) / 3);
// 7. Update General
// 레거시는 부동소수점 증가분을 INT column에 저장할 때 반올림한다.
const nextExp = general.experience + Math.round(exp);
const nextDed = general.dedication + Math.round(ded);
// Ref adds the floating delta to the current value first and MariaDB
// rounds the accumulated value when it writes the INT column. Rounding
// the delta separately changes cancellation cases such as
// 4554 + (45 * 0.7 / 3): the delta is 10.499999999999998, while the
// accumulated binary value is exactly 4564.5 and persists as 4565.
const nextExp = roundLegacyAccumulatedInteger(general.experience, exp);
const nextDed = roundLegacyAccumulatedInteger(general.dedication, ded);
let appliedScore = score;
if (context.city && [1, 3].includes(context.city.frontState)) {
@@ -17,6 +17,7 @@ import { tryApplyUniqueLottery } from '@sammo-ts/logic/rewards/uniqueLottery.js'
import type { GeneralTurnCommandSpec } from './index.js';
import { clamp } from 'es-toolkit';
import { GeneralActionPipeline } from '@sammo-ts/logic/actionModules/general.js';
import { applyLegacyInjury, finalizeLegacyStat } from './legacyGeneralStat.js';
export interface BoostMoraleArgs {}
@@ -85,8 +86,10 @@ export class ActionDefinition<
? this.env.maxAtmosByCommand
: DEFAULT_MAX_ATMOS;
const delta = this.env.atmosDelta && this.env.atmosDelta > 0 ? this.env.atmosDelta : DEFAULT_ATMOS_DELTA;
const leadership = this.pipeline.onCalcStat(context, 'leadership', general.stats.leadership);
const score = Math.round((leadership * 100 * delta) / general.crew);
const leadership = finalizeLegacyStat(
this.pipeline.onCalcStat(context, 'leadership', applyLegacyInjury(general.stats.leadership, general.injury))
);
const score = Math.round(((leadership * 100) / general.crew) * delta);
const nextAtmos = clamp(general.atmos + score, 0, maxAtmos);
const applied = nextAtmos - general.atmos;
const costGold = this.env.costGold ?? Math.round(general.crew / 100);
@@ -95,15 +98,20 @@ export class ActionDefinition<
general.atmos = nextAtmos;
general.train = trainSideEffect;
general.gold = Math.max(0, general.gold - costGold);
general.experience += 100;
general.dedication += 70;
general.experience += this.pipeline.onCalcStat(context, 'experience', 100);
general.dedication += this.pipeline.onCalcStat(context, 'dedication', 70);
const leadershipExp = typeof general.meta.leadership_exp === 'number' ? general.meta.leadership_exp : 0;
general.meta.leadership_exp = leadershipExp + 1;
const crewType = this.env.unitSet?.crewTypes?.find((entry) => entry.id === general.crewTypeId);
if (crewType) {
const dexKey = `dex${crewType.armType}`;
const dex = typeof general.meta[dexKey] === 'number' ? general.meta[dexKey] : 0;
general.meta[dexKey] = dex + applied;
const armType = crewType.armType === 0 ? 5 : crewType.armType;
if (armType >= 0) {
const dexKey = `dex${armType}`;
const dex = typeof general.meta[dexKey] === 'number' ? general.meta[dexKey] : 0;
const typeMultiplier = armType === 4 || armType === 5 ? 0.9 : 1;
general.meta[dexKey] =
dex + this.pipeline.onCalcStat(context, 'addDex', applied * typeMultiplier, { armType });
}
}
context.addLog(`사기치가 <C>${applied}</> 상승했습니다.`);
@@ -58,6 +58,7 @@ export interface InvestmentConfig {
useCityTrust?: boolean;
scaleSuccessByTrust?: boolean;
roundCriticalScore?: boolean;
costMultiplier?: number;
}
export interface InvestmentEnvironment {
@@ -181,7 +182,7 @@ export class CommandResolver<TriggerState extends GeneralTriggerState = GeneralT
gold: number;
rice: number;
} {
const baseGold = this.env.develCost;
const baseGold = this.env.develCost * (this.config.costMultiplier ?? 1);
const gold = Math.round(this.pipeline.onCalcDomestic(context, this.config.actionKey, 'cost', baseGold));
return { gold, rice: 0 };
}
@@ -300,8 +301,8 @@ export class CommandResolver<TriggerState extends GeneralTriggerState = GeneralT
appliedFrontDebuff = true;
}
const exp = rewardScore * 0.7;
const dedication = rewardScore;
const exp = this.pipeline.onCalcStat(context, 'experience', rewardScore * 0.7);
const dedication = this.pipeline.onCalcStat(context, 'dedication', rewardScore);
return {
pick,
@@ -20,6 +20,7 @@ import type {
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';
@@ -46,10 +47,6 @@ const ARGS_SCHEMA = z.object({
});
export type AgitateArgs = z.infer<typeof ARGS_SCHEMA>;
// 레거시 city.trust는 MariaDB FLOAT이며 다음 명령에서 6자리 유효숫자로
// 재조회된다. 메모리 상태도 같은 persistence 경계로 정규화한다.
const toLegacyStoredTrust = (value: number): number => Number(value.toPrecision(6));
export class ActionResolver<
TriggerState extends GeneralTriggerState = GeneralTriggerState,
> implements GeneralActionResolver<TriggerState, AgitateArgs> {
@@ -98,8 +95,9 @@ export class ActionResolver<
}
general.meta.firenum = (typeof general.meta.firenum === 'number' ? general.meta.firenum : 0) + 1;
const newSecu = Math.max(0, destCity.security - result.agriDamage);
const currentTrust = typeof destCity.meta.trust === 'number' ? destCity.meta.trust : 50;
const newTrust = toLegacyStoredTrust(Math.max(0, currentTrust - result.commDamage));
const currentTrust =
typeof destCity.meta.trust === 'number' ? readLegacyCityTrust(destCity.meta.trust) : 50;
const newTrust = storeLegacyCityTrust(Math.max(0, currentTrust - result.commDamage));
// Log
const commandName = ACTION_NAME;
@@ -43,8 +43,8 @@ export class ActionDefinition<
const crewUp = this.pipeline.onCalcDomestic(context, '징집인구', 'score', general.crew);
general.crew = 0;
city.population += Math.trunc(crewUp);
general.experience += 70;
general.dedication += 100;
general.experience += this.pipeline.onCalcStat(context, 'experience', 70);
general.dedication += this.pipeline.onCalcStat(context, 'dedication', 100);
context.addLog(`병사들을 <R>소집해제</>하였습니다.`);
@@ -5,6 +5,7 @@ import type { GeneralActionOutcome, GeneralActionResolveContext } from '@sammo-t
import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js';
import { defaultActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js';
import type { GeneralTurnCommandSpec } from './index.js';
import { GeneralActionPipeline } from '@sammo-ts/logic/actionModules/general.js';
export interface RecoveryArgs {}
@@ -21,9 +22,11 @@ export class ActionDefinition<
public readonly key = 'che_요양';
public readonly name = ACTION_NAME;
private readonly env: RecoveryEnvironment;
private readonly pipeline: GeneralActionPipeline<TriggerState>;
constructor(env: RecoveryEnvironment = {}) {
constructor(env: RecoveryEnvironment & Partial<Pick<TurnCommandEnv, 'generalActionModules'>> = {}) {
this.env = env;
this.pipeline = new GeneralActionPipeline(env.generalActionModules ?? []);
}
parseArgs(_raw: unknown): RecoveryArgs | null {
@@ -46,8 +49,8 @@ export class ActionDefinition<
// 직접 수정 (Immer Draft)
general.injury = nextInjury;
general.gold = Math.max(0, general.gold - costGold);
general.experience += 10;
general.dedication += 7;
general.experience += this.pipeline.onCalcStat(context, 'experience', 10);
general.dedication += this.pipeline.onCalcStat(context, 'dedication', 7);
context.addLog(`건강 회복을 위해 요양합니다.`);
@@ -63,5 +66,5 @@ export const commandSpec: GeneralTurnCommandSpec = {
category: '개인',
reqArg: false,
createDefinition: (_env: TurnCommandEnv) => new ActionDefinition(),
createDefinition: (env: TurnCommandEnv) => new ActionDefinition(env),
};
@@ -24,6 +24,7 @@ import type { GeneralTurnCommandSpec } from './index.js';
import type { MapDefinition } from '@sammo-ts/logic/world/types.js';
import { parseArgsWithSchema } from '../parseArgs.js';
import { formatDestCityConstraintFailure } from '../constraintFailure.js';
import { GeneralActionPipeline } from '@sammo-ts/logic/actionModules/general.js';
export interface MoveResolveContext<
TriggerState extends GeneralTriggerState = GeneralTriggerState,
@@ -44,6 +45,11 @@ export class ActionResolver<
TriggerState extends GeneralTriggerState = GeneralTriggerState,
> implements GeneralActionResolver<TriggerState, MoveArgs> {
readonly key = ACTION_KEY;
private readonly pipeline: GeneralActionPipeline<TriggerState>;
constructor(env: TurnCommandEnv) {
this.pipeline = new GeneralActionPipeline(env.generalActionModules ?? []);
}
resolve(context: MoveResolveContext<TriggerState>, args: MoveArgs): GeneralActionOutcome<TriggerState> {
const general = context.general;
@@ -89,7 +95,7 @@ export class ActionResolver<
if (isSelf) {
nextGold = Math.max(0, nextGold - cost);
nextAtmos = Math.max(20, nextAtmos - 5);
nextExp += 50;
nextExp += this.pipeline.onCalcStat(context, 'experience', 50);
nextLeadershipExp += 1;
}
@@ -123,8 +129,8 @@ export class ActionDefinition<
public readonly name = ACTION_NAME;
private readonly resolver: ActionResolver<TriggerState>;
constructor() {
this.resolver = new ActionResolver();
constructor(env: TurnCommandEnv) {
this.resolver = new ActionResolver(env);
}
parseArgs(raw: unknown): MoveArgs | null {
@@ -171,7 +177,10 @@ export const actionContextBuilder: ActionContextBuilder = (base, options) => {
return {
...base,
map: options.map,
develCost: options.scenarioConfig.const.develCost as number | undefined,
develCost:
typeof options.world.meta?.develcost === 'number'
? options.world.meta.develcost
: (options.scenarioConfig.const.develCost as number | undefined),
moveGenerals: options.worldRef?.listGenerals() ?? [],
};
};
@@ -182,5 +191,5 @@ export const commandSpec: GeneralTurnCommandSpec = {
reqArg: true,
availabilityArgs: { destCityId: 0 },
argsSchema: ARGS_SCHEMA,
createDefinition: (_env: TurnCommandEnv) => new ActionDefinition(),
createDefinition: (env: TurnCommandEnv) => new ActionDefinition(env),
};
@@ -51,11 +51,14 @@ export interface TalentScoutResolveContext<
> extends GeneralActionResolveContext<TriggerState> {
currentYear: number;
currentMonth: number;
retirementYear: number;
worldSummary: TalentScoutWorldSummary;
generalPool?: TalentScoutCandidate[];
cityPool?: City[];
existingGeneralNames: string[];
createGeneralId: () => number;
turnTermMinutes: number;
turnTimeBase: Date;
}
export interface TalentScoutEnvironment {
@@ -97,6 +100,17 @@ const DEFAULT_MAX_AGE = 25;
const DEFAULT_DEATH_MIN = 10;
const DEFAULT_DEATH_MAX = 50;
export const normalizeLegacyGeneratedDex = (
values: readonly [number, number, number, number, number]
): [number, number, number, number, number] =>
values.map((value) => Math.trunc(value)) as [number, number, number, number, number];
export const resolveLegacySpecialityAge = (
retirementYear: number,
age: number,
divisor: number
): number => Math.round((retirementYear - age) / divisor) + age;
const addMetaValue = (
meta: Record<string, TriggerValue>,
key: string,
@@ -181,6 +195,32 @@ const legacyChoiceIndex = (rng: RandomGenerator, length: number): number => {
const legacyChoice = <T>(rng: RandomGenerator, values: readonly T[]): T =>
values[legacyChoiceIndex(rng, values.length)]!;
const NPC_NAME_PREFIXES = ['', 'ⓝ', 'ⓝ', 'ⓜ', 'ⓖ', '㉥', 'ⓤ', 'ⓞ'] as const;
const NPC_STATE_NAME_PREFIXES: Readonly<Record<number, string>> = {
0: '',
1: 'ⓝ',
2: 'ⓝ',
3: 'ⓜ',
4: 'ⓖ',
5: '㉥',
6: 'ⓤ',
9: 'ⓞ',
};
const STORED_NAME_PREFIXES = new Set(Object.values(NPC_STATE_NAME_PREFIXES).filter(Boolean));
const restoreLegacyStoredName = (general: Pick<General, 'name' | 'npcState'>): string => {
if (STORED_NAME_PREFIXES.has(general.name[0] ?? '')) {
return general.name;
}
return `${NPC_STATE_NAME_PREFIXES[general.npcState] ?? ''}${general.name}`;
};
const countLegacyNameDuplicates = (names: readonly string[], candidate: string): number =>
NPC_NAME_PREFIXES.reduce(
(total, prefix) => total + names.filter((name) => name.startsWith(`${prefix}${candidate}`)).length,
0
);
const resolveCandidate = (
context: TalentScoutResolveContext,
rng: RandomGenerator,
@@ -261,6 +301,14 @@ export class CommandResolver<TriggerState extends GeneralTriggerState = GeneralT
);
return this.pipeline.onCalcDomestic(context, ACTION_KEY, 'probability', base);
}
adjustExperience(context: TalentScoutResolveContext<TriggerState>, value: number): number {
return this.pipeline.onCalcStat(context, 'experience', value);
}
adjustDedication(context: TalentScoutResolveContext<TriggerState>, value: number): number {
return this.pipeline.onCalcStat(context, 'dedication', value);
}
}
// 인재탐색 실행 결과를 계산한다.
@@ -297,8 +345,8 @@ export class ActionResolver<
// 직접 수정 (Immer Draft)
general.gold = nextGold;
general.rice = nextRice;
general.experience += expGain;
general.dedication += dedGain;
general.experience += this.command.adjustExperience(context, expGain);
general.dedication += this.command.adjustDedication(context, dedGain);
if (!found) {
const statKey = pickStatExpKey(context.rng, general);
@@ -328,10 +376,23 @@ export class ActionResolver<
const firstNames = this.env.randomGeneralFirstNames ?? ['가'];
const middleNames = this.env.randomGeneralMiddleNames ?? [''];
const lastNames = this.env.randomGeneralLastNames ?? ['가'];
const generatedName = `${legacyChoice(context.rng, firstNames)}${legacyChoice(
context.rng,
middleNames
)}${legacyChoice(context.rng, lastNames)}`;
let generatedName: string;
let duplicateLoopCount = 0;
while (true) {
generatedName = `${legacyChoice(context.rng, firstNames)}${legacyChoice(
context.rng,
middleNames
)}${legacyChoice(context.rng, lastNames)}`;
const duplicateCount = countLegacyNameDuplicates(context.existingGeneralNames, generatedName);
if (duplicateCount === 0) {
break;
}
if (duplicateLoopCount >= 99 || duplicateCount < 2) {
generatedName += duplicateCount + 1;
break;
}
duplicateLoopCount += 1;
}
const newGeneralId = context.createGeneralId();
const resolvedCandidate: TalentScoutCandidate = candidate ?? { name: generatedName };
const affinity = randomRangeInt(context.rng, 1, 150);
@@ -369,6 +430,10 @@ export class ActionResolver<
} else {
dex = [dexTotal / 4, dexTotal / 4, dexTotal / 4, dexTotal / 4, averageDex[4]];
}
// Ref passes the averages into GeneralBuilder::setDex(int ...), so PHP
// truncates every component before persistence. Core's common integer
// normalizer rounds instead, which changes exact half-point cases.
dex = normalizeLegacyGeneratedDex(dex);
const personality =
resolvedCandidate.personality ?? legacyChoice(context.rng, this.env.availablePersonalities ?? ['che_안전']);
const name = this.env.decorateName
@@ -377,6 +442,9 @@ export class ActionResolver<
const cityId = resolveSpawnCityId(context, context.rng, this.env);
const turnSecond = randomRangeInt(context.rng, 0, context.turnTermMinutes * 60 - 1);
const turnFraction = randomRangeInt(context.rng, 0, 999_999);
const turnTime = new Date(
context.turnTimeBase.getTime() + turnSecond * 1_000 + Math.floor(turnFraction / 1_000)
);
const killturn =
(deathYear - context.currentYear) * 12 + randomRangeInt(context.rng, 0, 11) + context.currentMonth - 1;
const meta: GeneralMeta = {
@@ -386,6 +454,16 @@ export class ActionResolver<
affinity,
birthYear,
deathYear,
specage: resolveLegacySpecialityAge(
context.retirementYear,
age,
12
),
specage2: resolveLegacySpecialityAge(
context.retirementYear,
age,
6
),
dex1: dex[0],
dex2: dex[1],
dex3: dex[2],
@@ -397,27 +475,33 @@ export class ActionResolver<
addMetaValue(meta, 'picture', resolvedCandidate.picture ?? null);
addMetaValue(meta, 'text', resolvedCandidate.text ?? null);
const newGeneral = buildRecruitmentGeneral<TriggerState>({
id: newGeneralId,
name,
nationId: 0,
cityId,
stats,
officerLevel: 0,
age,
npcState: NPC_TYPE,
gold: this.env.defaultNpcGold,
rice: this.env.defaultNpcRice,
experience: age * 100,
dedication: age * 100,
crewTypeId: this.env.defaultCrewTypeId,
role: {
personality,
specialDomestic: null,
specialWar: null,
},
meta,
});
const newGeneral = {
...buildRecruitmentGeneral<TriggerState>({
id: newGeneralId,
name,
nationId: 0,
cityId,
stats,
officerLevel: 0,
age,
npcState: NPC_TYPE,
gold: this.env.defaultNpcGold,
rice: this.env.defaultNpcRice,
experience: age * 100,
dedication: age * 100,
crewTypeId: this.env.defaultCrewTypeId,
role: {
personality,
specialDomestic: null,
specialWar: null,
},
meta,
}),
turnTime,
bornYear: birthYear,
deadYear: deathYear,
affinity,
};
const recruitVerb = '발견';
const nameRa = JosaUtil.pick(name, '라');
@@ -495,6 +579,10 @@ export const actionContextBuilder: ActionContextBuilder = (base, options) => ({
...base,
currentYear: options.world.currentYear,
currentMonth: options.world.currentMonth,
retirementYear:
typeof options.scenarioConfig.const.retirementYear === 'number'
? options.scenarioConfig.const.retirementYear
: 80,
worldSummary: {
...buildWorldSummary(options.worldRef),
averageDex: (() => {
@@ -511,9 +599,18 @@ export const actionContextBuilder: ActionContextBuilder = (base, options) => ({
) as [number, number, number, number, number];
})(),
},
cityPool: options.worldRef?.listCities() ?? [],
// Legacy CityHelper::getAllCities() is consumed in primary-key order.
cityPool: [...(options.worldRef?.listCities() ?? [])].sort((left, right) => left.id - right.id),
// Core keeps the initial scenario's display prefix separate from `name`,
// while Ref persists it in `general.name`. Reconstruct it before executing
// AbsGeneralPool::checkDuplicatedCnt semantics; the duplicated ⓝ prefix in
// GeneralBuilder::$prefixList intentionally counts NPC matches twice.
existingGeneralNames: options.worldRef?.listGenerals().map(restoreLegacyStoredName) ?? [],
createGeneralId: options.createGeneralId,
turnTermMinutes: Math.max(1, Math.round(options.world.tickSeconds / 60)),
// GeneralBuilder::build() derives a new NPC turn from gameStor.turntime,
// not from the scout's own reserved-turn timestamp.
turnTimeBase: options.world.lastTurnTime ?? base.general.turnTime,
});
export const commandSpec: GeneralTurnCommandSpec = {
@@ -39,6 +39,7 @@ const CONFIG: InvestmentConfig = {
frontDebuff: 1,
useCityTrust: false,
scaleSuccessByTrust: false,
costMultiplier: 2,
};
export class ActionDefinition<
@@ -49,11 +50,7 @@ export class ActionDefinition<
private readonly command: CommandResolver<TriggerState>;
constructor(env: TurnCommandEnv) {
this.command = new CommandResolver(
env.generalActionModules ?? [],
{ ...env, develCost: env.develCost * 2 },
CONFIG
);
this.command = new CommandResolver(env.generalActionModules ?? [], env, CONFIG);
}
parseArgs(_raw: unknown): SettlementArgs | null {
@@ -23,6 +23,7 @@ import {
} from './che_상업투자.js';
import { JosaUtil } from '@sammo-ts/common';
import { clamp } from 'es-toolkit';
import { readLegacyCityTrust, storeLegacyCityTrust } from './legacyCityTrust.js';
export interface TrustActionArgs {}
@@ -40,26 +41,21 @@ const CONFIG: InvestmentConfig = {
useCityTrust: false,
scaleSuccessByTrust: false,
roundCriticalScore: false,
costMultiplier: 2,
};
const readTrust = (city: City): number => {
const trust = city.meta.trust;
return typeof trust === 'number' && Number.isFinite(trust) ? trust : DEFAULT_TRUST;
return typeof trust === 'number' && Number.isFinite(trust) ? readLegacyCityTrust(trust) : DEFAULT_TRUST;
};
// 레거시 city.trust는 MariaDB FLOAT이며 다음 명령에서 6자리 유효숫자로
// 재조회된다. 같은 턴의 후속 명령도 그 저장 경계를 보도록 정규화한다.
const toLegacyStoredTrust = (value: number): number => Number(Math.fround(value).toPrecision(6));
const remainCityTrust = (): Constraint => ({
name: 'remainCityTrust',
requires: (ctx) => (ctx.cityId !== undefined ? [{ kind: 'city', id: ctx.cityId }] : []),
test: (ctx, view) => {
const city = ctx.cityId !== undefined ? (view.get({ kind: 'city', id: ctx.cityId }) as City | null) : null;
if (!city) return { kind: 'deny', reason: '도시 정보가 없습니다.' };
return readTrust(city) >= 100
? { kind: 'deny', reason: '주민 선정은 충분합니다.' }
: { kind: 'allow' };
return readTrust(city) >= 100 ? { kind: 'deny', reason: '주민 선정은 충분합니다.' } : { kind: 'allow' };
},
});
@@ -71,11 +67,7 @@ export class ActionDefinition<
private readonly command: CommandResolver<TriggerState>;
constructor(env: TurnCommandEnv) {
this.command = new CommandResolver(
env.generalActionModules ?? [],
{ ...env, develCost: env.develCost * 2 },
CONFIG
);
this.command = new CommandResolver(env.generalActionModules ?? [], env, CONFIG);
}
parseArgs(_raw: unknown): TrustActionArgs | null {
@@ -113,7 +105,7 @@ export class ActionDefinition<
const trustDelta = result.score / 10;
context.city.meta = {
...context.city.meta,
trust: toLegacyStoredTrust(clamp(readTrust(context.city) + trustDelta, 0, 100)),
trust: storeLegacyCityTrust(clamp(readTrust(context.city) + trustDelta, 0, 100)),
};
context.general.rice = Math.max(0, context.general.rice - result.costGold);
context.general.experience += result.exp;
@@ -30,6 +30,8 @@ import {
isCrewTypeAvailable,
} from '@sammo-ts/logic/world/unitSet.js';
import { parseArgsWithSchema } from '../parseArgs.js';
import { readLegacyCityTrust, storeLegacyCityTrust } from './legacyCityTrust.js';
import { applyLegacyInjury, finalizeLegacyStat } from './legacyGeneralStat.js';
export interface RecruitEnvironment {
costOffset?: number;
@@ -58,6 +60,13 @@ const DEFAULT_MIN_POP = 30000;
const DEFAULT_TRUST = 50;
const MIN_CREW = 100;
// PHP round() compensates for the small binary drift around a half boundary.
// Ref then converts the result to int through Util::round().
export const roundLegacyRecruitCost = (value: number): number => {
const corrected = value + Math.sign(value) * Number.EPSILON * Math.max(1, Math.abs(value));
return corrected < 0 ? Math.ceil(corrected - 0.5) : Math.floor(corrected + 0.5);
};
export const ARGS_SCHEMA = z.preprocess(
(raw) => {
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
@@ -114,10 +123,6 @@ const readCityTrust = (city: City, fallback: number): number => {
return typeof trust === 'number' ? trust : fallback;
};
// 레거시 city.trust는 MariaDB FLOAT이며 다음 명령에서 6자리 유효숫자로
// 재조회된다. 메모리 상태도 같은 persistence 경계로 정규화한다.
const toLegacyStoredTrust = (value: number): number => Number(value.toPrecision(6));
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 };
@@ -223,8 +228,9 @@ export class CommandResolver<TriggerState extends GeneralTriggerState = GeneralT
}
resolveLeadership(context: RecruitCalcContext<TriggerState>): number {
const base = context.general.stats.leadership;
return Math.round(this.pipeline.onCalcStat(context, 'leadership', base));
const general = context.general;
const base = applyLegacyInjury(general.stats.leadership, general.injury);
return finalizeLegacyStat(this.pipeline.onCalcStat(context, 'leadership', base));
}
resolveCrewPlan(
@@ -268,7 +274,7 @@ export class CommandResolver<TriggerState extends GeneralTriggerState = GeneralT
crewType ? { armType: crewType.armType } : undefined
);
return {
gold: Math.round(adjustedGold * costOffset),
gold: roundLegacyRecruitCost(adjustedGold * costOffset),
rice: Math.round(adjustedRice),
applied: plan.applied,
requested: plan.requested,
@@ -302,6 +308,14 @@ export class CommandResolver<TriggerState extends GeneralTriggerState = GeneralT
return Math.round(base);
}
getStatGain(
context: RecruitCalcContext<TriggerState>,
statName: 'experience' | 'dedication',
amount: number
): number {
return this.pipeline.onCalcStat(context, statName, amount);
}
getDexGain(
context: RecruitCalcContext<TriggerState>,
armType: number,
@@ -374,9 +388,9 @@ export class ActionResolver<
const costOffset = this.env.costOffset ?? DEFAULT_COST_OFFSET;
const recruitPop = this.command.getRecruitPopulation(context, appliedCrew);
const nextPopulation = Math.max(city.population - recruitPop, 0);
const baseTrust = readCityTrust(city, this.env.defaultTrust ?? DEFAULT_TRUST);
const baseTrust = readLegacyCityTrust(readCityTrust(city, this.env.defaultTrust ?? DEFAULT_TRUST));
const trustLoss = city.population > 0 ? (recruitPop / city.population / costOffset) * 100 : 0;
const nextTrust = toLegacyStoredTrust(Math.max(baseTrust - trustLoss, 0));
const nextTrust = storeLegacyCityTrust(Math.max(baseTrust - trustLoss, 0));
const actionName = this.env.actionName ?? ACTION_NAME;
const [nextCrewTypeId, nextCrew, nextTrain, nextAtmos] =
@@ -405,8 +419,13 @@ export class ActionResolver<
const nextGold = Math.max(0, general.gold - plan.gold);
const nextRice = Math.max(0, general.rice - plan.rice);
const expGain = Math.round(appliedCrew / 100);
const dedGain = Math.round(appliedCrew / 100);
// Ref rounds the raw crew-based reward first and then routes both
// General::addExperience()/addDedication() through onCalcStat(). This
// is observable for every successful recruit once seasonal rice pay
// lets NPCs afford the command (personality fame modifiers are +/-10%).
const baseStatGain = Math.round(appliedCrew / 100);
const expGain = this.command.getStatGain(context, 'experience', baseStatGain);
const dedGain = this.command.getStatGain(context, 'dedication', baseStatGain);
const dexGain = this.command.getDexGain(context, crewType.armType, appliedCrew);
// 직접 수정 (Immer Draft)
@@ -425,6 +444,12 @@ export class ActionResolver<
general.experience += expGain;
general.dedication += dedGain;
general.meta = addMetaNumber(general.meta, 'leadership_exp', 1);
// Ref persists the selected arm category in General.aux. GeneralAI
// reuses it for the next recruitment instead of drawing a new category.
general.meta = {
...general.meta,
armType: crewType.armType,
};
if (dexGain) {
general.meta = addMetaNumber(general.meta, dexGain.key, dexGain.amount);
}
@@ -33,10 +33,12 @@ import { resolveWarBattle } from '@sammo-ts/logic/war/engine.js';
import type { WarActionModule } from '@sammo-ts/logic/war/actions.js';
import type { NationTraitModule } from '@sammo-ts/logic/actionModules/traits/nation/index.js';
import type { GeneralActionModule } from '@sammo-ts/logic/actionModules/general.js';
import { increaseMetaNumber, simpleSerialize } from '@sammo-ts/logic/war/utils.js';
import { GeneralActionPipeline } from '@sammo-ts/logic/actionModules/general.js';
import { simpleSerialize } from '@sammo-ts/logic/war/utils.js';
import type { MapDefinition, UnitSetDefinition } from '@sammo-ts/logic/world/types.js';
import type { ActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js';
import { tryApplyUniqueLottery } from '@sammo-ts/logic/rewards/uniqueLottery.js';
import { buildNationFrontStatePatches } from '../../../diplomacy/frontState.js';
import { formatDestCityConstraintFailure } from '../constraintFailure.js';
import {
buildWarAftermathConfig,
@@ -55,7 +57,7 @@ export interface DispatchResolveContext<
generals: General<TriggerState>[];
unitSet: UnitSetDefinition;
map?: MapDefinition;
diplomacy?: Array<{ fromNationId: number; toNationId: number; state: number }>;
diplomacy?: Array<{ fromNationId: number; toNationId: number; state: number; term: number }>;
time: WarTimeContext;
seedBase: string;
warConfig: WarEngineConfig;
@@ -172,23 +174,19 @@ const pickCandidateCity = (
if (minDist === undefined) {
return null;
}
const candidates: Array<[number, number]> = [];
for (const dist of distances) {
if (dist > minDist + 1) {
break;
}
for (const entry of distanceList.get(dist) ?? []) {
if (entry[1] !== attackerNationId) {
candidates.push(entry);
}
const candidates = (distanceList.get(dist) ?? []).filter(([, nationId]) => nationId !== attackerNationId);
if (candidates.length > 0) {
// Ref breaks at the first distance layer containing an enemy. It
// only considers minDist + 1 when the minDist layer has none.
// RandUtil::choice() still consumes nextInt(0) for one candidate.
const [cityId] = pickLegacyChoice(candidates);
return { cityId, isEnemy: true, minDist };
}
}
if (candidates.length > 0) {
// Legacy RandUtil::choice() consumes nextInt(0) even when there is a
// single candidate. Keep that observable RNG step for seed parity.
const [cityId] = pickLegacyChoice(candidates);
return { cityId, isEnemy: true, minDist };
}
const fallback = distanceList.get(minDist) ?? [];
const friendly = fallback.filter(([, nationId]) => nationId === attackerNationId);
if (friendly.length === 0) {
@@ -257,6 +255,7 @@ export class ActionDefinition<
private readonly warModules: ReadonlyArray<WarActionModule<TriggerState>>;
private readonly nationTraitModules: Map<string, NationTraitModule>;
private readonly generalModules: ReadonlyArray<GeneralActionModule<TriggerState>>;
private readonly generalPipeline: GeneralActionPipeline<TriggerState>;
constructor(
modules: ReadonlyArray<WarActionModule<TriggerState> | null | undefined> = [],
@@ -266,6 +265,7 @@ export class ActionDefinition<
this.warModules = modules.filter(Boolean) as ReadonlyArray<WarActionModule<TriggerState>>;
this.nationTraitModules = new Map(nationTraitModules.map((module) => [module.key, module]));
this.generalModules = generalModules.filter(Boolean) as ReadonlyArray<GeneralActionModule<TriggerState>>;
this.generalPipeline = new GeneralActionPipeline(this.generalModules);
}
parseArgs(raw: unknown): DispatchArgs | null {
@@ -416,7 +416,16 @@ export class ActionDefinition<
const armType = resolveCrewTypeArm(unitSet, context.general.crewTypeId);
if (armType !== null) {
increaseMetaNumber(context.general.meta, `dex${armType}`, context.general.crew / 100);
const typeMultiplier = armType === 4 || armType === 5 ? 0.9 : 1;
const amount = this.generalPipeline.onCalcStat(
context,
'addDex',
(context.general.crew / 100) * typeMultiplier,
{ armType }
);
const dexKey = `dex${armType}`;
const currentDex = context.general.meta[dexKey];
context.general.meta[dexKey] = (typeof currentDex === 'number' ? currentDex : 0) + amount;
}
const cities = context.cities.map(cloneCity);
@@ -441,6 +450,10 @@ export class ActionDefinition<
general.crew > 0 &&
(unitSet.crewTypes?.some((crewType) => crewType.id === general.crewTypeId) ?? false)
);
const traceGeneralIds = new Set(process.env.CORE_AI_TRACE_GENERAL_IDS?.split(',') ?? []);
const shouldTraceWar =
traceGeneralIds.has(String(context.general.id)) ||
defenderGenerals.some((general) => traceGeneralIds.has(String(general.id)));
const battle = resolveWarBattle({
seed,
@@ -461,6 +474,15 @@ export class ActionDefinition<
})),
defenderCity,
defenderNation,
...(shouldTraceWar
? {
trace: (event) => {
process.stdout.write(
`AI_WAR_TRACE ${JSON.stringify({ generalId: context.general.id, event })}\n`
);
},
}
: {}),
});
const aftermath = resolveWarAftermath({
@@ -486,6 +508,36 @@ export class ActionDefinition<
},
});
// Ref ConquerCity() recalculates the fronts of every nation around the
// captured city immediately. Later generals in the same monthly due
// list therefore observe those refreshed values when choosing whether
// to deploy. Preserve that ordering before snapshotting city effects.
let frontStatePatches: Array<{ id: number; frontState: number }> = [];
if (battle.conquered && context.map && context.diplomacy) {
const connections = new Map(
context.map.cities.map((city) => [city.id, city.connections ?? []] as const)
);
const nearbyCityIds = new Set([defenderCity.id, ...(connections.get(defenderCity.id) ?? [])]);
const nearbyNationIds = new Set<number>([aftermath.conquest?.conquerNationId ?? attackerNation.id]);
for (const city of cities) {
if (nearbyCityIds.has(city.id) && city.nationId > 0) {
nearbyNationIds.add(city.nationId);
}
}
frontStatePatches = buildNationFrontStatePatches({
cities,
diplomacy: context.diplomacy,
connections,
nationIds: [...nearbyNationIds],
});
for (const patch of frontStatePatches) {
const city = cities.find((candidate) => candidate.id === patch.id);
if (city) {
city.frontState = patch.frontState;
}
}
}
const effects: Array<GeneralActionEffect<TriggerState>> = [];
for (const entry of battle.logs) {
@@ -541,6 +593,9 @@ export class ActionDefinition<
for (const [id, patch] of cityPatches) {
effects.push(createCityPatchEffect(patch, id));
}
for (const patch of frontStatePatches) {
effects.push(createCityPatchEffect({ frontState: patch.frontState }, patch.id));
}
for (const [id, patch] of nationPatches) {
effects.push(createNationPatchEffect(patch, id));
}
@@ -555,7 +610,12 @@ export class ActionDefinition<
tryApplyUniqueLottery(context, { acquireType: '아이템', reason: ACTION_NAME });
return { effects };
return {
effects,
...(aftermath.conquest?.ruinedNpcJoinPlans.length
? { reservedGeneralTurnPlans: aftermath.conquest.ruinedNpcJoinPlans }
: {}),
};
}
}
@@ -576,6 +636,8 @@ export const actionContextBuilder: ActionContextBuilder = (base, options) => {
const diplomacy = options.worldRef.listDiplomacy();
const warConfig = buildWarConfig(options.scenarioConfig, options.unitSet);
const aftermathConfig = buildWarAftermathConfig(options.scenarioConfig, warConfig.castleCrewTypeId);
const joinModeRaw = options.world.meta?.join_mode ?? options.world.meta?.joinMode;
aftermathConfig.joinMode = joinModeRaw === 'onlyRandom' ? 'onlyRandom' : 'full';
return {
...base,
destCity,
@@ -22,6 +22,7 @@ import { tryApplyUniqueLottery } from '@sammo-ts/logic/rewards/uniqueLottery.js'
import type { GeneralTurnCommandSpec } from './index.js';
import { parseArgsWithSchema } from '../parseArgs.js';
import { normalizeResourceActionAmount } from '../resourceAmount.js';
import { GeneralActionPipeline } from '@sammo-ts/logic/actionModules/general.js';
const ACTION_NAME = '헌납';
const ACTION_KEY = 'che_헌납';
@@ -36,6 +37,8 @@ export class ActionResolver<
> implements GeneralActionResolver<TriggerState, DonateArgs> {
readonly key = ACTION_KEY;
constructor(private readonly pipeline: GeneralActionPipeline<TriggerState> = new GeneralActionPipeline([])) {}
resolve(context: GeneralActionResolveContext<TriggerState>, args: DonateArgs): GeneralActionOutcome<TriggerState> {
const general = context.general;
const nation = context.nation;
@@ -51,8 +54,8 @@ export class ActionResolver<
const realAmount = Math.max(0, Math.min(amount, currentRes));
const exp = 70;
const ded = 100;
const exp = this.pipeline.onCalcStat(context, 'experience', 70);
const ded = this.pipeline.onCalcStat(context, 'dedication', 100);
const amountText = realAmount.toLocaleString();
context.addLog(`${resName} <C>${amountText}</>을 헌납했습니다.`, {
@@ -98,7 +101,9 @@ export class ActionDefinition<
private readonly resolver: ActionResolver<TriggerState>;
constructor(private readonly env: TurnCommandEnv) {
this.resolver = new ActionResolver();
this.resolver = new ActionResolver<TriggerState>(
new GeneralActionPipeline<TriggerState>(env.generalActionModules ?? [])
);
}
parseArgs(raw: unknown): DonateArgs | null {
@@ -13,6 +13,8 @@ import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js'
import { defaultActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js';
import { tryApplyUniqueLottery } from '@sammo-ts/logic/rewards/uniqueLottery.js';
import type { GeneralTurnCommandSpec } from './index.js';
import { GeneralActionPipeline } from '@sammo-ts/logic/actionModules/general.js';
import { applyLegacyInjury, finalizeLegacyStat } from './legacyGeneralStat.js';
export interface TrainingArgs {}
@@ -21,6 +23,7 @@ export interface TrainingEnvironment {
maxTrainByCommand?: number;
costGold?: number;
unitSet?: TurnCommandEnv['unitSet'];
generalActionModules?: TurnCommandEnv['generalActionModules'];
}
const ACTION_NAME = '훈련';
@@ -32,9 +35,11 @@ export class ActionDefinition<
public readonly key = 'che_훈련';
public readonly name = ACTION_NAME;
private readonly env: TrainingEnvironment;
private readonly pipeline: GeneralActionPipeline<TriggerState>;
constructor(env: TrainingEnvironment = {}) {
this.env = env;
this.pipeline = new GeneralActionPipeline(env.generalActionModules ?? []);
}
parseArgs(_raw: unknown): TrainingArgs | null {
@@ -70,10 +75,13 @@ export class ActionDefinition<
? this.env.maxTrainByCommand
: DEFAULT_MAX_TRAIN;
const trainDelta = this.env.trainDelta && this.env.trainDelta > 0 ? this.env.trainDelta : 0;
const leadership = finalizeLegacyStat(
this.pipeline.onCalcStat(context, 'leadership', applyLegacyInjury(general.stats.leadership, general.injury))
);
const score = Math.max(
0,
Math.min(
Math.round((general.stats.leadership * 100 * trainDelta) / Math.max(general.crew, 1)),
Math.round((leadership * 100 * trainDelta) / Math.max(general.crew, 1)),
maxTrain - general.train
)
);
@@ -81,15 +89,23 @@ export class ActionDefinition<
general.train += score;
general.gold = Math.max(0, general.gold - costGold);
general.experience += 100;
general.dedication += 70;
general.experience += this.pipeline.onCalcStat(context, 'experience', 100);
general.dedication += this.pipeline.onCalcStat(context, 'dedication', 70);
const leadershipExp = typeof general.meta.leadership_exp === 'number' ? general.meta.leadership_exp : 0;
general.meta.leadership_exp = leadershipExp + 1;
const crewType = this.env.unitSet?.crewTypes?.find((entry) => entry.id === general.crewTypeId);
if (crewType) {
const dexKey = `dex${crewType.armType}`;
const armType = crewType.armType === 0 ? 5 : crewType.armType;
if (armType < 0) {
context.addLog(`훈련치가 <C>${score.toLocaleString()}</> 상승했습니다.`);
tryApplyUniqueLottery(context, { acquireType: '아이템', reason: ACTION_NAME });
return { effects: [] };
}
const dexKey = `dex${armType}`;
const dex = typeof general.meta[dexKey] === 'number' ? general.meta[dexKey] : 0;
general.meta[dexKey] = dex + score;
const typeMultiplier = armType === 4 || armType === 5 ? 0.9 : 1;
general.meta[dexKey] =
dex + this.pipeline.onCalcStat(context, 'addDex', score * typeMultiplier, { armType });
}
context.addLog(`훈련치가 <C>${score.toLocaleString()}</> 상승했습니다.`);
@@ -112,5 +128,6 @@ export const commandSpec: GeneralTurnCommandSpec = {
trainDelta: env.trainDelta,
maxTrainByCommand: env.maxTrainByCommand,
unitSet: env.unitSet,
generalActionModules: env.generalActionModules,
}),
};
@@ -16,6 +16,7 @@ import { defaultActionContextBuilder } from '@sammo-ts/logic/actions/turn/action
import { tryApplyUniqueLottery } from '@sammo-ts/logic/rewards/uniqueLottery.js';
import type { GeneralTurnCommandSpec } from './index.js';
import { GeneralActionPipeline } from '@sammo-ts/logic/actionModules/general.js';
import { applyLegacyInjury, finalizeLegacyStat } from './legacyGeneralStat.js';
const ACTION_NAME = '맹훈련';
const ACTION_KEY = 'cr_맹훈련';
@@ -71,7 +72,9 @@ export class ActionDefinition<
const maxTrain = this.env.maxTrainByCommand > 0 ? this.env.maxTrainByCommand : 100;
const maxAtmos = this.env.maxAtmosByCommand > 0 ? this.env.maxAtmosByCommand : 100;
const leadership = this.pipeline.onCalcStat(context, 'leadership', general.stats.leadership);
const leadership = finalizeLegacyStat(
this.pipeline.onCalcStat(context, 'leadership', applyLegacyInjury(general.stats.leadership, general.injury))
);
const score = Math.round((leadership * 100 * trainDelta * 2) / (Math.max(general.crew, 1) * 3));
const scoreText = score.toLocaleString('en-US');
@@ -0,0 +1,8 @@
/**
* MariaDB stores city.trust as FLOAT (binary32), while the PHP driver exposes
* the value rounded to six significant decimal digits on the next read.
* Keep those boundaries separate so later SQL expressions use binary32 state.
*/
export const storeLegacyCityTrust = (value: number): number => Math.fround(value);
export const readLegacyCityTrust = (value: number): number => Number(Math.fround(value).toPrecision(6));
@@ -0,0 +1,3 @@
export const applyLegacyInjury = (value: number, injury: number): number => value * ((100 - injury) / 100);
export const finalizeLegacyStat = (value: number): number => Math.trunc(value);