fix: match scenario 2601 monthly seed progression
This commit is contained in:
@@ -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);
|
||||
@@ -0,0 +1,10 @@
|
||||
// MariaDB FLOAT stores binary32, but its text protocol exposes only six
|
||||
// significant decimal digits. Ref reads that text into PHP before every
|
||||
// command/battle update, so both boundaries are part of the game state.
|
||||
export const toLegacyStoredFloat = (value: number): number => Math.fround(value);
|
||||
|
||||
export const readLegacyStoredFloat = (value: number): number =>
|
||||
Number(Math.fround(value).toPrecision(6));
|
||||
|
||||
export const addLegacyStoredFloat = (current: number, delta: number): number =>
|
||||
toLegacyStoredFloat(readLegacyStoredFloat(current) + delta);
|
||||
@@ -18,7 +18,12 @@ const resolveCityGenerals = <TriggerState extends GeneralTriggerState>(
|
||||
const list = worldView.listGeneralsByCity
|
||||
? worldView.listGeneralsByCity(general.cityId)
|
||||
: worldView.listGenerals().filter((candidate) => candidate.cityId === general.cityId);
|
||||
return list.filter((candidate) => candidate.id !== general.id);
|
||||
// Ref reads patients from the primary-key-backed `general` table. Make
|
||||
// that observed order explicit so the 50% draws stay attached to the
|
||||
// same general even when Core's in-memory insertion order differs.
|
||||
return list
|
||||
.filter((candidate) => candidate.id !== general.id)
|
||||
.sort((left, right) => left.id - right.id);
|
||||
};
|
||||
|
||||
// 의술 특기의 도시 치료 트리거.
|
||||
|
||||
@@ -50,8 +50,11 @@ const findReport = (reports: WarUnitReport[], predicate: (report: WarUnitReport)
|
||||
|
||||
const getDeadCounter = (city: City): number => getMetaNumber(city.meta, META_DEAD, 0);
|
||||
|
||||
const setDeadCounter = (city: City, value: number): void => {
|
||||
city.meta[META_DEAD] = round(value);
|
||||
const increaseDeadCounter = (city: City, delta: number): void => {
|
||||
// Ref binds each `dead + %i` increment as an integer before MariaDB adds
|
||||
// it. Truncate each 40/60 percent split independently; rounding the
|
||||
// accumulated counter changes monthly recovery and war income.
|
||||
city.meta[META_DEAD] = getDeadCounter(city) + Math.trunc(delta);
|
||||
};
|
||||
|
||||
const isSupplyCity = (city: City): boolean => {
|
||||
@@ -122,11 +125,16 @@ const applyNationTechGain = <TriggerState extends GeneralTriggerState>(
|
||||
}
|
||||
|
||||
const divisor = Math.max(config.initialNationGenLimit, total);
|
||||
const tech = getMetaNumber(nation.meta, 'tech', 0) + gain / divisor;
|
||||
// Legacy MySQL FLOAT values are read back at the command boundary with
|
||||
// two-decimal precision. Preserve fractional accumulation without
|
||||
// converting the gain to an integer.
|
||||
nation.meta.tech = Math.round(tech * 100) / 100;
|
||||
const currentTech = getMetaNumber(nation.meta, 'tech', 0);
|
||||
const delta = gain / divisor;
|
||||
// Ref executes `tech + delta` inside MariaDB for battle gains, so the
|
||||
// arithmetic starts from the stored binary32 value without a PHP text read.
|
||||
nation.meta.tech = Math.fround(currentTech + delta);
|
||||
if ((process.env.CORE_WAR_TECH_TRACE_NATION_IDS?.split(',') ?? []).includes(String(nation.id))) {
|
||||
process.stdout.write(
|
||||
`WAR_TECH_TRACE ${JSON.stringify({ engine: 'core', nationId: nation.id, side: context.side, currentTech, baseGain, gain, total, effective, divisor, delta, storedTech: nation.meta.tech, attackerGeneralId: context.attackerReport.id })}\n`
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const resolveConquerNation = (city: City, attackerNationId: number, nations: Nation[]): number => {
|
||||
@@ -247,10 +255,22 @@ const resolveConquerCity = <TriggerState extends GeneralTriggerState>(
|
||||
|
||||
let collapseRewardGold = 0;
|
||||
let collapseRewardRice = 0;
|
||||
const ruinedNpcJoinPlans: ConquerCityOutcome<TriggerState>['ruinedNpcJoinPlans'] = [];
|
||||
|
||||
// 국가 붕괴 시 자원 손실과 포상 정산.
|
||||
if (nationCollapsed && defenderNation) {
|
||||
const defenderGenerals = generals.filter((general) => general.nationId === defenderNationId);
|
||||
const defenderGenerals = generals
|
||||
.filter((general) => general.nationId === defenderNationId)
|
||||
.sort((lhs, rhs) => {
|
||||
// deleteNation() reads the non-lord rows in primary-key order,
|
||||
// then appends the lord object to the returned PHP array.
|
||||
const lhsIsLord = lhs.id === defenderNation.chiefGeneralId;
|
||||
const rhsIsLord = rhs.id === defenderNation.chiefGeneralId;
|
||||
if (lhsIsLord !== rhsIsLord) {
|
||||
return lhsIsLord ? 1 : -1;
|
||||
}
|
||||
return lhs.id - rhs.id;
|
||||
});
|
||||
let totalGoldLoss = 0;
|
||||
let totalRiceLoss = 0;
|
||||
|
||||
@@ -290,6 +310,21 @@ const resolveConquerCity = <TriggerState extends GeneralTriggerState>(
|
||||
);
|
||||
pushLoggers([generalLogger], logs);
|
||||
affectedGenerals.add(general);
|
||||
|
||||
if (config.joinMode !== 'onlyRandom') {
|
||||
// Ref attempts to build/send a scout message after every loss.
|
||||
// Message availability does not affect this draw.
|
||||
rng.nextBool(0.5);
|
||||
|
||||
const eligibleNpc = general.npcState >= 2 && general.npcState <= 8 && general.npcState !== 5;
|
||||
if (eligibleNpc && rng.nextBool(config.joinRuinedNpcProbability ?? 0.1)) {
|
||||
ruinedNpcJoinPlans.push({
|
||||
generalId: general.id,
|
||||
destNationId: attackerNation.id,
|
||||
joinTurn: rng.nextRangeInt(0, 12),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
collapseRewardGold = Math.floor((Math.max(0, defenderNation.gold - config.baseGold) + totalGoldLoss) / 2);
|
||||
@@ -426,6 +461,7 @@ const resolveConquerCity = <TriggerState extends GeneralTriggerState>(
|
||||
nations: Array.from(affectedNations),
|
||||
cities: Array.from(affectedCities),
|
||||
generals: Array.from(affectedGenerals),
|
||||
ruinedNpcJoinPlans,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -447,10 +483,8 @@ export const resolveWarAftermath = <TriggerState extends GeneralTriggerState = G
|
||||
|
||||
// 전투 사망자 누적: 공격/수비 도시로 분배.
|
||||
if (totalDead > 0) {
|
||||
const attackerCityDead = getDeadCounter(input.attackerCity) + totalDead * 0.4;
|
||||
const defenderCityDead = getDeadCounter(input.defenderCity) + totalDead * 0.6;
|
||||
setDeadCounter(input.attackerCity, attackerCityDead);
|
||||
setDeadCounter(input.defenderCity, defenderCityDead);
|
||||
increaseDeadCounter(input.attackerCity, totalDead * 0.4);
|
||||
increaseDeadCounter(input.defenderCity, totalDead * 0.6);
|
||||
affectedCities.add(input.attackerCity);
|
||||
affectedCities.add(input.defenderCity);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { JosaUtil, LiteHashDRBG, RandUtil } from '@sammo-ts/common';
|
||||
|
||||
import type { City, General, GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js';
|
||||
import type { City, GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js';
|
||||
import { compileCrewTypeCatalog, isCrewTypeWarActionRouter } from '@sammo-ts/logic/crewType/catalog.js';
|
||||
import { ActionLogger } from '@sammo-ts/logic/logging/actionLogger.js';
|
||||
import { LogFormat } from '@sammo-ts/logic/logging/types.js';
|
||||
@@ -19,10 +19,7 @@ import type {
|
||||
import { getMetaNumber } from './utils.js';
|
||||
import { WarUnitCity, WarUnitGeneral, type WarUnit } from './units.js';
|
||||
|
||||
const META_FULL_LEADERSHIP = 'fullLeadership';
|
||||
const META_FULL_STRENGTH = 'fullStrength';
|
||||
const META_FULL_INTELLIGENCE = 'fullIntelligence';
|
||||
const META_DEFENCE_TRAIN = 'defenceTrain';
|
||||
const META_DEFENCE_TRAIN = 'defence_train';
|
||||
|
||||
const defaultLoggerFactory = (options: { generalId?: number; nationId?: number }): ActionLogger =>
|
||||
new ActionLogger(options);
|
||||
@@ -101,18 +98,6 @@ const buildBattlePhaseTriggers = (unit: WarUnit, registry: WarTriggerRegistry):
|
||||
return caller;
|
||||
};
|
||||
|
||||
const resolveFullStats = (
|
||||
general: General
|
||||
): {
|
||||
leadership: number;
|
||||
strength: number;
|
||||
intelligence: number;
|
||||
} => ({
|
||||
leadership: getMetaNumber(general.meta, META_FULL_LEADERSHIP, general.stats.leadership),
|
||||
strength: getMetaNumber(general.meta, META_FULL_STRENGTH, general.stats.strength),
|
||||
intelligence: getMetaNumber(general.meta, META_FULL_INTELLIGENCE, general.stats.intelligence),
|
||||
});
|
||||
|
||||
const isSupplyCity = (city: City): boolean => {
|
||||
const supply = city.meta.supply;
|
||||
if (typeof supply === 'boolean') {
|
||||
@@ -153,9 +138,17 @@ export const computeBattleOrder = <TriggerState extends GeneralTriggerState>(
|
||||
return 0;
|
||||
}
|
||||
|
||||
const realStat = general.stats.leadership + general.stats.strength + general.stats.intelligence;
|
||||
const fullStats = resolveFullStats(general);
|
||||
const fullStat = fullStats.leadership + fullStats.strength + fullStats.intelligence;
|
||||
// Ref calls General::getLeadership/Strength/Intel() for every defender at
|
||||
// battle time. That applies injury, stat cross-adjustment, officer/items/
|
||||
// traits and integer conversion through the defender's action pipeline.
|
||||
const realStat =
|
||||
defender.getComputedStat('leadership', general.stats.leadership) +
|
||||
defender.getComputedStat('strength', general.stats.strength) +
|
||||
defender.getComputedStat('intelligence', general.stats.intelligence);
|
||||
const fullStat =
|
||||
defender.getComputedStat('leadership', general.stats.leadership, { withInjury: false }) +
|
||||
defender.getComputedStat('strength', general.stats.strength, { withInjury: false }) +
|
||||
defender.getComputedStat('intelligence', general.stats.intelligence, { withInjury: false });
|
||||
const totalStat = (realStat + fullStat) / 2;
|
||||
|
||||
const totalCrew = (general.crew / 1_000_000) * Math.pow(general.train * general.atmos, 1.5);
|
||||
@@ -330,6 +323,12 @@ export const resolveWarBattle = <TriggerState extends GeneralTriggerState = Gene
|
||||
defenderUnits.push(cityUnit);
|
||||
}
|
||||
|
||||
const summarizeDefenderOrder = (unit: WarUnit<TriggerState>) => ({
|
||||
id: unit instanceof WarUnitGeneral ? unit.getGeneral().id : 0,
|
||||
order: computeBattleOrder<TriggerState>(unit, attackerUnit),
|
||||
});
|
||||
const defenderOrderBeforeSort = defenderUnits.map(summarizeDefenderOrder);
|
||||
|
||||
defenderUnits.sort(
|
||||
(lhs, rhs) =>
|
||||
computeBattleOrder<TriggerState>(rhs, attackerUnit) - computeBattleOrder<TriggerState>(lhs, attackerUnit)
|
||||
@@ -373,6 +372,10 @@ export const resolveWarBattle = <TriggerState extends GeneralTriggerState = Gene
|
||||
details,
|
||||
});
|
||||
};
|
||||
emitTrace('defender_order', defender, {
|
||||
before: defenderOrderBeforeSort,
|
||||
after: defenderUnits.map(summarizeDefenderOrder),
|
||||
});
|
||||
emitTrace('battle_start', defender, { seed: input.seed ?? '' });
|
||||
|
||||
const attackerNationName = (attackerUnit.getNationVar('name') as string | null) ?? 'UNKNOWN';
|
||||
@@ -399,7 +402,10 @@ export const resolveWarBattle = <TriggerState extends GeneralTriggerState = Gene
|
||||
defender = cityUnit;
|
||||
cityUnit.setSiege();
|
||||
|
||||
const defenderRice = input.defenderNation?.rice ?? 0;
|
||||
// Ref builds a virtual neutral nation with 10,000 rice. Treating a
|
||||
// neutral city's missing Nation row as zero skips the entire siege
|
||||
// through the supply-retreat branch and changes every later war.
|
||||
const defenderRice = input.defenderNation?.rice ?? 10_000;
|
||||
if (isSupplyCity(input.defenderCity) && defenderRice <= 0) {
|
||||
attackerUnit.setOppose(defender);
|
||||
defender.setOppose(attackerUnit);
|
||||
|
||||
@@ -25,6 +25,7 @@ export interface WarEngineConfig {
|
||||
maxTrainByWar: number;
|
||||
maxAtmosByWar: number;
|
||||
maxGeneralStat?: number;
|
||||
statUpgradeLimit?: number;
|
||||
castleCrewTypeId: number;
|
||||
armTypes: WarArmTypes;
|
||||
}
|
||||
@@ -138,6 +139,16 @@ export interface WarAftermathConfig {
|
||||
baseGold: number;
|
||||
baseRice: number;
|
||||
castleCrewTypeId: number;
|
||||
/** Legacy admin join mode. Ruined-nation scout/join draws are skipped in onlyRandom mode. */
|
||||
joinMode?: 'full' | 'onlyRandom';
|
||||
/** Probability that an eligible ruined NPC reserves a future appointment. */
|
||||
joinRuinedNpcProbability?: number;
|
||||
}
|
||||
|
||||
export interface RuinedNpcJoinPlan {
|
||||
generalId: number;
|
||||
destNationId: number;
|
||||
joinTurn: number;
|
||||
}
|
||||
|
||||
export interface WarAftermathTechContext {
|
||||
@@ -162,6 +173,7 @@ export interface ConquerCityOutcome<TriggerState extends GeneralTriggerState = G
|
||||
nations: Nation[];
|
||||
cities: City[];
|
||||
generals: General<TriggerState>[];
|
||||
ruinedNpcJoinPlans: RuinedNpcJoinPlan[];
|
||||
}
|
||||
|
||||
export interface WarAftermathInput<TriggerState extends GeneralTriggerState = GeneralTriggerState> {
|
||||
|
||||
@@ -322,7 +322,7 @@ export class WarUnitGeneral<
|
||||
}
|
||||
|
||||
const atmosMultiplier = this.isAttacker() ? 1.1 : 1.05;
|
||||
this.general.atmos = clamp(Math.round(this.general.atmos * atmosMultiplier), 0, this.config.maxAtmosByWar);
|
||||
this.general.atmos = clamp(this.general.atmos * atmosMultiplier, 0, this.config.maxAtmosByWar);
|
||||
|
||||
this.addStatExp(1);
|
||||
}
|
||||
@@ -384,7 +384,10 @@ export class WarUnitGeneral<
|
||||
nextExp *= 0.9;
|
||||
}
|
||||
const adjustedExp = this.actionPipeline.onCalcStat(this.getActionContext(), 'addDex', nextExp, { armType });
|
||||
this.general.meta[key] = base + adjustedExp;
|
||||
// PHP interpolates floats into MeekroDB SQL with precision=14 before
|
||||
// MariaDB rounds the integer dex column. Normalize this accumulated
|
||||
// battle value at the same boundary (for example ...499999999996 -> .5).
|
||||
this.general.meta[key] = Number((base + adjustedExp).toPrecision(14));
|
||||
}
|
||||
|
||||
public calcRiceConsumption(damage: number): number {
|
||||
@@ -529,5 +532,31 @@ export class WarUnitGeneral<
|
||||
this.general.rice = round(this.general.rice);
|
||||
this.general.experience = round(this.general.experience);
|
||||
this.general.dedication = round(this.general.dedication);
|
||||
|
||||
// Ref WarUnitGeneral::finishBattle() runs General::checkStatChange()
|
||||
// before persisting the participant. A battle can therefore consume
|
||||
// one accumulated stat-exp threshold even though che_출병 itself does
|
||||
// not have the generic command progression tail.
|
||||
const limit = this.config.statUpgradeLimit ?? 30;
|
||||
const maxStat = this.config.maxGeneralStat ?? 255;
|
||||
const entries = [
|
||||
['leadership', META_LEADERSHIP_EXP, '통솔'],
|
||||
['strength', META_STRENGTH_EXP, '무력'],
|
||||
['intelligence', META_INTEL_EXP, '지력'],
|
||||
] as const;
|
||||
for (const [statKey, expKey, label] of entries) {
|
||||
const statExp = getMetaNumber(this.general.meta, expKey);
|
||||
if (statExp < 0) {
|
||||
this.general.meta[expKey] = statExp + limit;
|
||||
this.general.stats[statKey] -= 1;
|
||||
this.logger.pushGeneralActionLog(`<R>${label}</>이 <C>1</> 떨어졌습니다!`, LogFormat.PLAIN);
|
||||
} else if (statExp >= limit) {
|
||||
if (this.general.stats[statKey] < maxStat) {
|
||||
this.general.stats[statKey] += 1;
|
||||
this.logger.pushGeneralActionLog(`<S>${label}</>이 <C>1</> 올랐습니다!`, LogFormat.PLAIN);
|
||||
}
|
||||
this.general.meta[expKey] = statExp - limit;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,19 @@ export const clampMin = (value: number, min: number): number => (value < min ? m
|
||||
|
||||
export const clampMax = (value: number, max: number): number => (value > max ? max : value);
|
||||
|
||||
export const round = (value: number): number => Math.round(value);
|
||||
// PHP's round() compensates for small binary floating-point drift around a
|
||||
// half boundary and rounds halves away from zero. War state is persisted to
|
||||
// integer columns through legacy Util::round(), so Math.round() is not enough:
|
||||
// e.g. accumulated siege damage can produce 4159.499999999999, which PHP
|
||||
// rounds to 4160 while Math.round() returns 4159.
|
||||
export const round = (value: number): number => {
|
||||
if (!Number.isFinite(value)) {
|
||||
return Math.round(value);
|
||||
}
|
||||
|
||||
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 getMetaNumber = (meta: Record<string, TriggerValue>, key: string, fallback = 0): number => {
|
||||
const value = meta[key];
|
||||
|
||||
@@ -8,6 +8,11 @@ import type {
|
||||
TriggerValue,
|
||||
} from '@sammo-ts/logic/domain/entities.js';
|
||||
import type { ScenarioDefinition, ScenarioGeneral } from '@sammo-ts/logic/scenario/types.js';
|
||||
import {
|
||||
isDomesticTraitKey,
|
||||
isEventDomesticTraitKey,
|
||||
isWarTraitKey,
|
||||
} from '@sammo-ts/logic/actionModules/traits/index.js';
|
||||
import type {
|
||||
CitySeed,
|
||||
GeneralSeed,
|
||||
@@ -37,6 +42,9 @@ export interface ScenarioBootstrapOptions {
|
||||
* remains deterministic in tests and reset operations.
|
||||
*/
|
||||
hiddenSeed?: string | number;
|
||||
initialYear?: number;
|
||||
initialMonth?: number;
|
||||
turnTermMinutes?: number;
|
||||
}
|
||||
|
||||
export type ScenarioBootstrapWarningCode =
|
||||
@@ -72,11 +80,70 @@ const DEFAULT_GENERAL_GOLD = 1000;
|
||||
const DEFAULT_GENERAL_RICE = 1000;
|
||||
const DEFAULT_CREWTYPE_ID = 1100;
|
||||
const DEFAULT_GENERAL_KILLTURN = 24;
|
||||
const DEFAULT_AVAILABLE_PERSONALITIES = [
|
||||
'che_안전',
|
||||
'che_유지',
|
||||
'che_재간',
|
||||
'che_출세',
|
||||
'che_할거',
|
||||
'che_정복',
|
||||
'che_패권',
|
||||
'che_의협',
|
||||
'che_대의',
|
||||
'che_왕좌',
|
||||
];
|
||||
const DEFAULT_CITY_TRUST = 50;
|
||||
const DEFAULT_CITY_TRADE = 100;
|
||||
const DEFAULT_CITY_SUPPLY_STATE = 1;
|
||||
const DEFAULT_CITY_FRONT_STATE = 0;
|
||||
|
||||
const canonicalizeDomesticTrait = (raw: string | null | undefined): string | null => {
|
||||
if (!raw || raw === 'None') {
|
||||
return null;
|
||||
}
|
||||
if (isDomesticTraitKey(raw) || isEventDomesticTraitKey(raw)) {
|
||||
return raw;
|
||||
}
|
||||
const domesticKey = `che_${raw}`;
|
||||
if (isDomesticTraitKey(domesticKey)) {
|
||||
return domesticKey;
|
||||
}
|
||||
const eventKey = `che_event_${raw}`;
|
||||
if (isEventDomesticTraitKey(eventKey)) {
|
||||
return eventKey;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const canonicalizeWarTrait = (raw: string | null | undefined): string | null => {
|
||||
if (!raw || raw === 'None') {
|
||||
return null;
|
||||
}
|
||||
if (isWarTraitKey(raw)) {
|
||||
return raw;
|
||||
}
|
||||
const warKey = `che_${raw}`;
|
||||
return isWarTraitKey(warKey) ? warKey : null;
|
||||
};
|
||||
|
||||
// Scenario rows expose one legacy speciality column. GeneralBuilder tries the
|
||||
// domestic catalogue first, then the war catalogue, and persists the resolved
|
||||
// class code. Preserve unknown values in the domestic slot so custom scenario
|
||||
// packs retain their prior data even when their module is not installed here.
|
||||
const resolveScenarioTraits = (
|
||||
special: string | null,
|
||||
explicitWar: string | null | undefined
|
||||
): { specialDomestic: string | null; specialWar: string | null } => {
|
||||
const domestic = canonicalizeDomesticTrait(special);
|
||||
const inferredWar = domestic === null ? canonicalizeWarTrait(special) : null;
|
||||
const retainedDomestic = special && special !== 'None' ? special : null;
|
||||
const retainedWar = explicitWar && explicitWar !== 'None' ? explicitWar : null;
|
||||
return {
|
||||
specialDomestic: domestic ?? (inferredWar === null ? retainedDomestic : null),
|
||||
specialWar: canonicalizeWarTrait(explicitWar) ?? inferredWar ?? retainedWar,
|
||||
};
|
||||
};
|
||||
|
||||
const createScenarioMeta = (scenario: ScenarioDefinition): ScenarioMeta => ({
|
||||
title: scenario.title,
|
||||
startYear: scenario.startYear,
|
||||
@@ -93,6 +160,82 @@ const createEmptyTriggerState = (): GeneralTriggerState => ({
|
||||
meta: {},
|
||||
});
|
||||
|
||||
// GameConstBase::$defaultInitialEvents / $defaultEvents. Ref prepends these
|
||||
// rows to every scenario unless ignoreDefaultEvents is enabled; scenario JSON
|
||||
// only contains its additional rows.
|
||||
const LEGACY_DEFAULT_INITIAL_EVENTS: unknown[] = [
|
||||
[true, ['NoticeToHistoryLog', '<S>2년간 거병 및 건국이 가능합니다.</>', 6]],
|
||||
];
|
||||
|
||||
const LEGACY_DEFAULT_EVENTS: unknown[] = [
|
||||
['pre_month', 9_000, true, ['UpdateCitySupply'], ['ProcessWarIncome']],
|
||||
[
|
||||
'month',
|
||||
9_000,
|
||||
['Date', '==', null, 1],
|
||||
['MergeInheritPointRank'],
|
||||
['ProcessSemiAnnual', 'gold'],
|
||||
['ProcessIncome', 'gold'],
|
||||
['ResetOfficerLock'],
|
||||
['RaiseDisaster'],
|
||||
['RandomizeCityTradeRate'],
|
||||
['NewYear'],
|
||||
['AssignGeneralSpeciality'],
|
||||
],
|
||||
['month', 9_000, ['Date', '==', null, 4], ['ResetOfficerLock'], ['RaiseDisaster']],
|
||||
[
|
||||
'month',
|
||||
9_000,
|
||||
['Date', '==', null, 7],
|
||||
['MergeInheritPointRank'],
|
||||
['ProcessSemiAnnual', 'rice'],
|
||||
['ProcessIncome', 'rice'],
|
||||
['ResetOfficerLock'],
|
||||
['RaiseDisaster'],
|
||||
['RandomizeCityTradeRate'],
|
||||
],
|
||||
['month', 9_000, ['Date', '==', null, 10], ['ResetOfficerLock'], ['RaiseDisaster']],
|
||||
[
|
||||
'month',
|
||||
2_000,
|
||||
['DateRelative', '==', 1, 1],
|
||||
['NoticeToHistoryLog', '<S>2년 뒤 출병 제한이 풀립니다.</>', 6],
|
||||
['DeleteEvent'],
|
||||
],
|
||||
[
|
||||
'month',
|
||||
2_000,
|
||||
['DateRelative', '==', 2, 1],
|
||||
['NoticeToHistoryLog', '<S>1년 뒤 출병 제한이 풀립니다.</>', 6],
|
||||
['DeleteEvent'],
|
||||
],
|
||||
[
|
||||
'month',
|
||||
2_000,
|
||||
['DateRelative', '==', 2, 7],
|
||||
['NoticeToHistoryLog', '<S>6개월 뒤 출병 제한이 풀립니다. 병력을 준비해주세요.</>', 6],
|
||||
['DeleteEvent'],
|
||||
],
|
||||
[
|
||||
'month',
|
||||
2_000,
|
||||
['DateRelative', '==', 3, 1],
|
||||
['NoticeToHistoryLog', '<S>출병 제한이 풀렸습니다.</>', 6],
|
||||
['DeleteEvent'],
|
||||
],
|
||||
[
|
||||
'month',
|
||||
2_000,
|
||||
['DateRelative', '==', 4, 1],
|
||||
['NoticeToHistoryLog', '<S>이제부터 하야, 망명시 패널티가 적용됩니다.</>', 6],
|
||||
['AddGlobalBetray', 1, 0],
|
||||
['AddGlobalBetray', 1, 1],
|
||||
['DeleteEvent'],
|
||||
],
|
||||
['month', 1_000, true, ['UpdateNationLevel'], ['ProvideNPCTroopLeader']],
|
||||
['united', 5_000, true, ['MergeInheritPointRank']],
|
||||
];
|
||||
|
||||
const addTriggerMeta = (
|
||||
meta: Record<string, TriggerValue>,
|
||||
key: string,
|
||||
@@ -194,6 +337,13 @@ const resolveAge = (startYear: number | null, birthYear: number): number => {
|
||||
const buildSpecialityAge = (retirementYear: number, age: number, divisor: number): number =>
|
||||
Math.max(Math.round((retirementYear - age) / divisor), 3) + age;
|
||||
|
||||
const resolveBootstrapSpecialityAge = (
|
||||
scenarioStartYear: number | null,
|
||||
birthYear: number,
|
||||
retirementYear: number,
|
||||
divisor: number
|
||||
): number => buildSpecialityAge(retirementYear, resolveAge(scenarioStartYear, birthYear), divisor);
|
||||
|
||||
const ADULT_GENERAL_AGE = 14;
|
||||
|
||||
type GeneralBootstrapDisposition = 'active' | 'delayed' | 'expired';
|
||||
@@ -313,7 +463,8 @@ const buildGeneralSeeds = (
|
||||
defaultCrewTypeId: number,
|
||||
mapCities: MapDefinition['cities'],
|
||||
nationCityIds: Map<number, number[]>,
|
||||
placementRng: RandUtil,
|
||||
installRng: RandUtil,
|
||||
initializedValues: Map<ScenarioGeneral, { affinity: number; personality: string }>,
|
||||
options?: ScenarioBootstrapOptions
|
||||
): {
|
||||
seeds: GeneralSeed[];
|
||||
@@ -340,47 +491,49 @@ const buildGeneralSeeds = (
|
||||
const ownedCityIds = nationId > 0 ? (nationCityIds.get(nationId) ?? []) : [];
|
||||
const candidateCityIds = ownedCityIds.length > 0 ? ownedCityIds : mapCities.map((city) => city.id);
|
||||
if (candidateCityIds.length > 0) {
|
||||
cityId = placementRng.choice(candidateCityIds);
|
||||
cityId = installRng.choice(candidateCityIds);
|
||||
}
|
||||
}
|
||||
const birthYear = resolveBirthYear(row.birthYear, scenario.startYear);
|
||||
const deathYear = resolveDeathYear(row.deathYear, birthYear, scenario.startYear);
|
||||
const deathMonth = resolveScenarioGeneralDeathMonth({
|
||||
scenarioTitle: scenario.title,
|
||||
startYear: scenario.startYear,
|
||||
contextLabel,
|
||||
generalId: id,
|
||||
generalName: row.name,
|
||||
deathYear,
|
||||
});
|
||||
const turnTermMinutes = Math.max(1, Math.floor(options?.turnTermMinutes ?? 60));
|
||||
const initialTurnOffsetMicros =
|
||||
installRng.nextRangeInt(0, turnTermMinutes * 60 - 1) * 1_000_000 + installRng.nextRangeInt(0, 999_999);
|
||||
const deathMonth = installRng.nextRangeInt(1, 12);
|
||||
const officerLevel = resolveOfficerLevel(row.officerLevel, nationId);
|
||||
const age = resolveAge(scenario.startYear, birthYear);
|
||||
const initialYear = options?.initialYear ?? scenario.startYear;
|
||||
const initialMonth = options?.initialMonth ?? 1;
|
||||
const age = resolveAge(initialYear, birthYear);
|
||||
const initialized = initializedValues.get(row);
|
||||
if (!initialized) {
|
||||
throw new Error(`Missing legacy initialization values for general ${row.name}.`);
|
||||
}
|
||||
const stats = {
|
||||
leadership: row.leadership,
|
||||
strength: row.strength,
|
||||
intelligence: row.intelligence,
|
||||
};
|
||||
const { specialDomestic, specialWar } = resolveScenarioTraits(row.special, row.specialWar);
|
||||
|
||||
const seedMeta: Record<string, unknown> = {
|
||||
source: contextLabel,
|
||||
deathMonth,
|
||||
specage: buildSpecialityAge(retirementYear, age, 12),
|
||||
specage2: buildSpecialityAge(retirementYear, age, 6),
|
||||
initialTurnOffsetMicros,
|
||||
// Ref's GeneralBuilder derives speciality ages from the scenario
|
||||
// opening year even when installation stores a pre-opening age.
|
||||
specage: resolveBootstrapSpecialityAge(scenario.startYear, birthYear, retirementYear, 12),
|
||||
specage2: resolveBootstrapSpecialityAge(scenario.startYear, birthYear, retirementYear, 6),
|
||||
};
|
||||
if (row.affinity !== null) {
|
||||
seedMeta.affinity = row.affinity;
|
||||
}
|
||||
if (row.personality !== null) {
|
||||
seedMeta.personality = row.personality;
|
||||
}
|
||||
if (row.special !== null) {
|
||||
seedMeta.special = row.special;
|
||||
seedMeta.affinity = initialized.affinity;
|
||||
seedMeta.personality = initialized.personality;
|
||||
if (specialDomestic !== null) {
|
||||
seedMeta.special = specialDomestic;
|
||||
}
|
||||
if (row.picture !== null) {
|
||||
seedMeta.picture = row.picture;
|
||||
}
|
||||
if (row.specialWar !== null && row.specialWar !== undefined) {
|
||||
seedMeta.specialWar = row.specialWar;
|
||||
if (specialWar !== null) {
|
||||
seedMeta.specialWar = specialWar;
|
||||
}
|
||||
if (row.horse !== null && row.horse !== undefined) {
|
||||
seedMeta.horse = row.horse;
|
||||
@@ -407,10 +560,10 @@ const buildGeneralSeeds = (
|
||||
officerLevel,
|
||||
birthYear,
|
||||
deathYear,
|
||||
affinity: row.affinity,
|
||||
personality: row.personality,
|
||||
special: row.special,
|
||||
specialWar: row.specialWar ?? null,
|
||||
affinity: initialized.affinity,
|
||||
personality: initialized.personality,
|
||||
special: specialDomestic,
|
||||
specialWar,
|
||||
horse: row.horse ?? null,
|
||||
weapon: row.weapon ?? null,
|
||||
book: row.book ?? null,
|
||||
@@ -419,14 +572,16 @@ const buildGeneralSeeds = (
|
||||
npcType,
|
||||
text: row.text,
|
||||
crewTypeId: defaultCrewTypeId,
|
||||
experience: age * 100,
|
||||
dedication: age * 100,
|
||||
meta: seedMeta,
|
||||
};
|
||||
seeds.push(seed);
|
||||
|
||||
const generalMeta: GeneralMeta = {
|
||||
killturn: resolveKillturnFromDeathYear(
|
||||
scenario.startYear,
|
||||
1,
|
||||
initialYear,
|
||||
initialMonth,
|
||||
deathYear,
|
||||
deathMonth,
|
||||
DEFAULT_GENERAL_KILLTURN
|
||||
@@ -434,14 +589,14 @@ const buildGeneralSeeds = (
|
||||
deathMonth,
|
||||
npcType,
|
||||
crewTypeId: defaultCrewTypeId,
|
||||
specage: buildSpecialityAge(retirementYear, age, 12),
|
||||
specage2: buildSpecialityAge(retirementYear, age, 6),
|
||||
specage: resolveBootstrapSpecialityAge(scenario.startYear, birthYear, retirementYear, 12),
|
||||
specage2: resolveBootstrapSpecialityAge(scenario.startYear, birthYear, retirementYear, 6),
|
||||
};
|
||||
addTriggerMeta(generalMeta, 'affinity', row.affinity);
|
||||
addTriggerMeta(generalMeta, 'personality', row.personality ?? undefined);
|
||||
addTriggerMeta(generalMeta, 'special', row.special ?? undefined);
|
||||
addTriggerMeta(generalMeta, 'affinity', initialized.affinity);
|
||||
addTriggerMeta(generalMeta, 'personality', initialized.personality);
|
||||
addTriggerMeta(generalMeta, 'special', specialDomestic ?? undefined);
|
||||
addTriggerMeta(generalMeta, 'picture', row.picture ?? undefined);
|
||||
addTriggerMeta(generalMeta, 'specialWar', row.specialWar ?? undefined);
|
||||
addTriggerMeta(generalMeta, 'specialWar', specialWar ?? undefined);
|
||||
addTriggerMeta(generalMeta, 'horse', row.horse ?? undefined);
|
||||
addTriggerMeta(generalMeta, 'weapon', row.weapon ?? undefined);
|
||||
addTriggerMeta(generalMeta, 'book', row.book ?? undefined);
|
||||
@@ -456,13 +611,13 @@ const buildGeneralSeeds = (
|
||||
cityId,
|
||||
troopId: 0,
|
||||
stats,
|
||||
experience: 0,
|
||||
dedication: 0,
|
||||
experience: age * 100,
|
||||
dedication: age * 100,
|
||||
officerLevel,
|
||||
role: {
|
||||
personality: row.personality,
|
||||
specialDomestic: row.special,
|
||||
specialWar: row.specialWar ?? null,
|
||||
personality: initialized.personality,
|
||||
specialDomestic,
|
||||
specialWar,
|
||||
items: {
|
||||
horse: row.horse ?? null,
|
||||
weapon: row.weapon ?? null,
|
||||
@@ -632,8 +787,8 @@ export const buildScenarioBootstrap = (input: ScenarioBootstrapInput): ScenarioB
|
||||
}
|
||||
|
||||
const mapDefaults = resolveMapDefaults(map, options);
|
||||
const placementRng = new RandUtil(
|
||||
new LiteHashDRBG(simpleSerialize(options?.hiddenSeed ?? scenario.title, 'InitScenarioGeneralCities'))
|
||||
const installRng = new RandUtil(
|
||||
new LiteHashDRBG(simpleSerialize(options?.hiddenSeed ?? scenario.title, 'InitScenario'))
|
||||
);
|
||||
const defaultCrewTypeId = unitSet?.defaultCrewTypeId ?? options?.defaultCrewTypeId ?? DEFAULT_CREWTYPE_ID;
|
||||
const seedCities: CitySeed[] = [];
|
||||
@@ -714,6 +869,19 @@ export const buildScenarioBootstrap = (input: ScenarioBootstrapInput): ScenarioB
|
||||
const allGeneralSeeds: GeneralSeed[] = [];
|
||||
const allGenerals: General[] = [];
|
||||
const delayedActionsByBirthYear = new Map<number, unknown[][]>();
|
||||
const initializedValues = new Map<ScenarioGeneral, { affinity: number; personality: string }>();
|
||||
const rawAvailablePersonalities = scenario.config.const.availablePersonality;
|
||||
const availablePersonalities = Array.isArray(rawAvailablePersonalities)
|
||||
? rawAvailablePersonalities.filter((value): value is string => typeof value === 'string')
|
||||
: DEFAULT_AVAILABLE_PERSONALITIES;
|
||||
const personalityPool =
|
||||
availablePersonalities.length > 0 ? availablePersonalities : DEFAULT_AVAILABLE_PERSONALITIES;
|
||||
for (const row of [...scenario.generals, ...scenario.generalsEx, ...scenario.generalsNeutral]) {
|
||||
const affinity = row.affinity !== null && row.affinity > 0 ? row.affinity : installRng.nextRangeInt(1, 150);
|
||||
const rawPersonality = row.personality ?? installRng.choice(personalityPool);
|
||||
const personality = rawPersonality.includes('_') ? rawPersonality : `che_${rawPersonality}`;
|
||||
initializedValues.set(row, { affinity, personality });
|
||||
}
|
||||
|
||||
const partitionGenerals = (rows: ScenarioGeneral[], npcType: 2 | 6): ScenarioGeneral[] => {
|
||||
const active: ScenarioGeneral[] = [];
|
||||
@@ -750,7 +918,8 @@ export const buildScenarioBootstrap = (input: ScenarioBootstrapInput): ScenarioB
|
||||
defaultCrewTypeId,
|
||||
map.cities,
|
||||
nationCityIds,
|
||||
placementRng,
|
||||
installRng,
|
||||
initializedValues,
|
||||
options
|
||||
);
|
||||
allGeneralSeeds.push(...generalResult.seeds);
|
||||
@@ -769,7 +938,8 @@ export const buildScenarioBootstrap = (input: ScenarioBootstrapInput): ScenarioB
|
||||
defaultCrewTypeId,
|
||||
map.cities,
|
||||
nationCityIds,
|
||||
placementRng,
|
||||
installRng,
|
||||
initializedValues,
|
||||
options
|
||||
);
|
||||
allGeneralSeeds.push(...generalExResult.seeds);
|
||||
@@ -788,7 +958,8 @@ export const buildScenarioBootstrap = (input: ScenarioBootstrapInput): ScenarioB
|
||||
defaultCrewTypeId,
|
||||
map.cities,
|
||||
nationCityIds,
|
||||
placementRng,
|
||||
installRng,
|
||||
initializedValues,
|
||||
options
|
||||
);
|
||||
allGeneralSeeds.push(...generalNeutralResult.seeds);
|
||||
@@ -801,7 +972,9 @@ export const buildScenarioBootstrap = (input: ScenarioBootstrapInput): ScenarioB
|
||||
...actions,
|
||||
['DeleteEvent'],
|
||||
]);
|
||||
const events = [...scenario.events, ...delayedGeneralEvents];
|
||||
const defaultEvents = scenario.ignoreDefaultEvents ? [] : LEGACY_DEFAULT_EVENTS;
|
||||
const defaultInitialEvents = scenario.ignoreDefaultEvents ? [] : LEGACY_DEFAULT_INITIAL_EVENTS;
|
||||
const events = [...defaultEvents, ...scenario.events, ...delayedGeneralEvents];
|
||||
|
||||
const seed: WorldSeedPayload = {
|
||||
scenarioConfig: scenario.config,
|
||||
@@ -814,7 +987,7 @@ export const buildScenarioBootstrap = (input: ScenarioBootstrapInput): ScenarioB
|
||||
troops: [],
|
||||
diplomacy: scenario.diplomacy,
|
||||
events,
|
||||
initialEvents: scenario.initialEvents,
|
||||
initialEvents: [...defaultInitialEvents, ...scenario.initialEvents],
|
||||
};
|
||||
|
||||
const snapshot: WorldSnapshot = {
|
||||
@@ -828,7 +1001,7 @@ export const buildScenarioBootstrap = (input: ScenarioBootstrapInput): ScenarioB
|
||||
troops: [],
|
||||
diplomacy: scenario.diplomacy,
|
||||
events,
|
||||
initialEvents: scenario.initialEvents,
|
||||
initialEvents: [...defaultInitialEvents, ...scenario.initialEvents],
|
||||
};
|
||||
|
||||
return { snapshot, seed, warnings };
|
||||
|
||||
@@ -27,16 +27,20 @@ export const getCityDistance = (map: MapDefinition, startCityId: number, endCity
|
||||
return Infinity;
|
||||
};
|
||||
|
||||
export const searchDistance = (map: MapDefinition, startCityId: number, range: number): Record<number, number> => {
|
||||
const result: Record<number, number> = {};
|
||||
export const searchDistanceEntries = (
|
||||
map: MapDefinition,
|
||||
startCityId: number,
|
||||
range: number
|
||||
): Array<[cityId: number, distance: number]> => {
|
||||
const result: Array<[number, number]> = [];
|
||||
const visited = new Set<number>();
|
||||
const queue: [number, number][] = [[startCityId, 0]];
|
||||
|
||||
visited.add(startCityId);
|
||||
result[startCityId] = 0;
|
||||
|
||||
while (queue.length > 0) {
|
||||
const [currentId, dist] = queue.shift()!;
|
||||
result.push([currentId, dist]);
|
||||
|
||||
if (dist >= range) continue;
|
||||
|
||||
@@ -47,7 +51,6 @@ export const searchDistance = (map: MapDefinition, startCityId: number, range: n
|
||||
if (!visited.has(neighborId)) {
|
||||
visited.add(neighborId);
|
||||
const newDist = dist + 1;
|
||||
result[neighborId] = newDist;
|
||||
queue.push([neighborId, newDist]);
|
||||
}
|
||||
}
|
||||
@@ -55,3 +58,6 @@ export const searchDistance = (map: MapDefinition, startCityId: number, range: n
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
export const searchDistance = (map: MapDefinition, startCityId: number, range: number): Record<number, number> =>
|
||||
Object.fromEntries(searchDistanceEntries(map, startCityId, range));
|
||||
|
||||
@@ -158,6 +158,8 @@ export interface GeneralSeed {
|
||||
npcType: number;
|
||||
text: string | null;
|
||||
crewTypeId: number;
|
||||
experience?: number;
|
||||
dedication?: number;
|
||||
meta: Record<string, unknown>;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user