fix: align reserved turn commands with legacy
This commit is contained in:
@@ -26,9 +26,11 @@ export const buildWorldSummary = (world: ActionContextWorldRef | null): WorldSum
|
||||
if (generals.length === 0) {
|
||||
return { totalGeneralCount: 0, totalNpcCount: 0 };
|
||||
}
|
||||
const total = generals.length;
|
||||
const npcCount = generals.filter((general) => general.npcState > 0).length;
|
||||
const statSum = generals.reduce(
|
||||
const countedGenerals = generals.filter((general) => general.npcState < 4);
|
||||
const total = countedGenerals.length;
|
||||
const totalGeneralCount = generals.filter((general) => general.npcState <= 2).length;
|
||||
const totalNpcCount = generals.filter((general) => general.npcState >= 3 && general.npcState <= 4).length;
|
||||
const statSum = countedGenerals.reduce(
|
||||
(acc, general) => ({
|
||||
leadership: acc.leadership + general.stats.leadership,
|
||||
strength: acc.strength + general.stats.strength,
|
||||
@@ -37,8 +39,8 @@ export const buildWorldSummary = (world: ActionContextWorldRef | null): WorldSum
|
||||
{ leadership: 0, strength: 0, intelligence: 0 }
|
||||
);
|
||||
return {
|
||||
totalGeneralCount: total,
|
||||
totalNpcCount: npcCount,
|
||||
totalGeneralCount,
|
||||
totalNpcCount,
|
||||
averageStats: {
|
||||
leadership: statSum.leadership / total,
|
||||
strength: statSum.strength / total,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { GeneralActionModule } from '@sammo-ts/logic/triggers/general-action.js';
|
||||
import type { WarActionModule } from '@sammo-ts/logic/war/actions.js';
|
||||
import type { UnitSetDefinition } from '@sammo-ts/logic/world/types.js';
|
||||
import type { NationTraitModule } from '@sammo-ts/logic/triggers/special/nation/index.js';
|
||||
|
||||
export interface TurnCommandItemCatalogEntry {
|
||||
slot: 'horse' | 'weapon' | 'book' | 'item';
|
||||
@@ -34,9 +35,18 @@ export interface TurnCommandEnv {
|
||||
defaultCrewTypeId: number;
|
||||
defaultSpecialDomestic: string | null;
|
||||
defaultSpecialWar: string | null;
|
||||
npcStatTotal?: number;
|
||||
npcStatMin?: number;
|
||||
npcStatMax?: number;
|
||||
randomGeneralFirstNames?: string[];
|
||||
randomGeneralMiddleNames?: string[];
|
||||
randomGeneralLastNames?: string[];
|
||||
availablePersonalities?: string[];
|
||||
initialNationGenLimit: number;
|
||||
maxTechLevel: number;
|
||||
maxStatLevel?: number;
|
||||
maxDedicationLevel?: number;
|
||||
statUpgradeLimit?: number;
|
||||
techLevelIncYear?: number;
|
||||
initialAllowedTechLevel?: number;
|
||||
baseGold: number;
|
||||
@@ -45,4 +55,5 @@ export interface TurnCommandEnv {
|
||||
itemCatalog?: Record<string, TurnCommandItemCatalogEntry>;
|
||||
generalActionModules?: Array<GeneralActionModule>;
|
||||
warActionModules?: Array<WarActionModule>;
|
||||
nationTraitModules?: Array<NationTraitModule>;
|
||||
}
|
||||
|
||||
@@ -24,6 +24,17 @@ const DecRice = 0x800;
|
||||
const Wounded = 0x1000;
|
||||
const HeavyWounded = 0x2000;
|
||||
|
||||
type InclusiveRandomGenerator = GeneralActionResolveContext['rng'] & {
|
||||
nextIntInclusive?: (maxInclusive: number) => number;
|
||||
};
|
||||
|
||||
const legacyChoiceIndex = (rng: GeneralActionResolveContext['rng'], length: number): number => {
|
||||
const inclusive = rng as InclusiveRandomGenerator;
|
||||
return inclusive.nextIntInclusive
|
||||
? inclusive.nextIntInclusive(length - 1)
|
||||
: rng.nextInt(0, length);
|
||||
};
|
||||
|
||||
const SIGHTSEEING_MESSAGES: Array<{
|
||||
flags: number;
|
||||
texts: string[];
|
||||
@@ -138,7 +149,7 @@ const pickByWeight = (rng: GeneralActionResolveContext['rng']): { flags: number;
|
||||
const weight = Math.max(entry.weight, 0);
|
||||
cursor -= weight;
|
||||
if (cursor <= 0) {
|
||||
const index = rng.nextInt(0, entry.texts.length);
|
||||
const index = legacyChoiceIndex(rng, entry.texts.length);
|
||||
const text = entry.texts[index] ?? entry.texts[0] ?? '';
|
||||
return { flags: entry.flags, text };
|
||||
}
|
||||
@@ -147,7 +158,7 @@ const pickByWeight = (rng: GeneralActionResolveContext['rng']): { flags: number;
|
||||
if (!fallback) {
|
||||
return { flags: 0, text: '' };
|
||||
}
|
||||
const index = rng.nextInt(0, fallback.texts.length);
|
||||
const index = legacyChoiceIndex(rng, fallback.texts.length);
|
||||
const text = fallback.texts[index] ?? fallback.texts[0] ?? '';
|
||||
return { flags: fallback.flags, text };
|
||||
};
|
||||
|
||||
@@ -140,7 +140,7 @@ export class ActionDefinition<
|
||||
setMetaNumber(general.meta, dexKey, nextDex);
|
||||
|
||||
const expGain = general.crew / 400;
|
||||
general.experience += expGain;
|
||||
general.experience = Math.round(general.experience + expGain);
|
||||
|
||||
const statKey = pickByWeight(context.rng, {
|
||||
leadership_exp: general.stats.leadership,
|
||||
|
||||
@@ -49,7 +49,17 @@ export class ActionResolver<
|
||||
const resKey = picked === 'gold' ? 'gold' : 'rice';
|
||||
|
||||
// 2. Base Score
|
||||
let score = general.stats.leadership + general.stats.strength + general.stats.intelligence;
|
||||
const injuryMultiplier = (100 - general.injury) / 100;
|
||||
const rawLeadership = general.stats.leadership * injuryMultiplier;
|
||||
const rawStrength = general.stats.strength * injuryMultiplier;
|
||||
const rawIntelligence = general.stats.intelligence * injuryMultiplier;
|
||||
const maxStat = 255;
|
||||
const legacyStat = (stat: 'leadership' | 'strength' | 'intelligence', value: number): number =>
|
||||
Math.trunc(Math.max(0, Math.min(maxStat, this.pipeline.onCalcStat(context, stat, value))));
|
||||
let score =
|
||||
legacyStat('leadership', rawLeadership) +
|
||||
legacyStat('strength', rawStrength + Math.round(rawIntelligence / 4)) +
|
||||
legacyStat('intelligence', rawIntelligence + Math.round(rawStrength / 4));
|
||||
const expLevel = typeof general.meta.explevel === 'number' ? general.meta.explevel : 0;
|
||||
score *= 1 + expLevel / 500;
|
||||
score *= context.rng.nextFloat1() * 0.4 + 0.8;
|
||||
|
||||
@@ -29,7 +29,7 @@ export interface BoostMoraleEnvironment {
|
||||
generalActionModules?: TurnCommandEnv['generalActionModules'];
|
||||
}
|
||||
|
||||
const ACTION_NAME = '사기 진작';
|
||||
const ACTION_NAME = '사기진작';
|
||||
const DEFAULT_ATMOS_DELTA = 5;
|
||||
const DEFAULT_MAX_ATMOS = 100;
|
||||
|
||||
|
||||
@@ -43,6 +43,7 @@ export interface TalentScoutWorldSummary {
|
||||
totalGeneralCount: number;
|
||||
totalNpcCount: number;
|
||||
averageStats?: StatBlock;
|
||||
averageDex?: [number, number, number, number, number];
|
||||
}
|
||||
|
||||
export interface TalentScoutResolveContext<
|
||||
@@ -54,6 +55,7 @@ export interface TalentScoutResolveContext<
|
||||
generalPool?: TalentScoutCandidate[];
|
||||
cityPool?: City[];
|
||||
createGeneralId: () => number;
|
||||
turnTermMinutes: number;
|
||||
}
|
||||
|
||||
export interface TalentScoutEnvironment {
|
||||
@@ -76,6 +78,13 @@ export interface TalentScoutEnvironment {
|
||||
rng: RandomGenerator,
|
||||
candidate: TalentScoutCandidate
|
||||
) => StatBlock;
|
||||
npcStatTotal?: number;
|
||||
npcStatMin?: number;
|
||||
npcStatMax?: number;
|
||||
randomGeneralFirstNames?: string[];
|
||||
randomGeneralMiddleNames?: string[];
|
||||
randomGeneralLastNames?: string[];
|
||||
availablePersonalities?: string[];
|
||||
}
|
||||
|
||||
type StatExpKey = 'leadership_exp' | 'strength_exp' | 'intel_exp';
|
||||
@@ -88,20 +97,6 @@ const DEFAULT_MAX_AGE = 25;
|
||||
const DEFAULT_DEATH_MIN = 10;
|
||||
const DEFAULT_DEATH_MAX = 50;
|
||||
|
||||
const resolveKillturnFromDeathYear = (
|
||||
currentYear: number,
|
||||
currentMonth: number,
|
||||
deathYear: number,
|
||||
rng: RandomGenerator
|
||||
): number => {
|
||||
if (!Number.isFinite(deathYear) || deathYear <= 0) {
|
||||
return 0;
|
||||
}
|
||||
const deathMonth = randomRangeInt(rng, 1, 12);
|
||||
const diff = (deathYear - currentYear) * 12 + (deathMonth - currentMonth);
|
||||
return Math.max(diff, 0);
|
||||
};
|
||||
|
||||
const addMetaValue = (
|
||||
meta: Record<string, TriggerValue>,
|
||||
key: string,
|
||||
@@ -158,7 +153,7 @@ const calcFoundProp = (maxGeneral: number, totalGeneralCount: number, totalNpcCo
|
||||
if (maxGeneral <= 0) {
|
||||
return 0;
|
||||
}
|
||||
const current = totalGeneralCount + totalNpcCount / 2;
|
||||
const current = Math.trunc(totalGeneralCount + totalNpcCount / 2);
|
||||
const remainSlot = Math.max(maxGeneral - current, 0);
|
||||
const main = Math.pow(remainSlot / maxGeneral, 6);
|
||||
const small = 1 / (totalNpcCount / 3 + 1);
|
||||
@@ -171,6 +166,22 @@ const calcFoundProp = (maxGeneral: number, totalGeneralCount: number, totalNpcCo
|
||||
|
||||
const randomRangeInt = (rng: RandomGenerator, min: number, max: number): number => rng.nextInt(min, max + 1);
|
||||
|
||||
type InclusiveRandomGenerator = RandomGenerator & {
|
||||
nextIntInclusive?: (maxInclusive: number) => number;
|
||||
};
|
||||
|
||||
const legacyChoiceIndex = (rng: RandomGenerator, length: number): number => {
|
||||
if (length <= 0) {
|
||||
throw new Error('Empty items');
|
||||
}
|
||||
const inclusive = rng as InclusiveRandomGenerator;
|
||||
return inclusive.nextIntInclusive
|
||||
? inclusive.nextIntInclusive(length - 1)
|
||||
: rng.nextInt(0, length);
|
||||
};
|
||||
|
||||
const legacyChoice = <T>(rng: RandomGenerator, values: readonly T[]): T => values[legacyChoiceIndex(rng, values.length)]!;
|
||||
|
||||
const resolveCandidate = (
|
||||
context: TalentScoutResolveContext,
|
||||
rng: RandomGenerator,
|
||||
@@ -183,7 +194,7 @@ const resolveCandidate = (
|
||||
if (pool.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const idx = rng.nextInt(0, pool.length);
|
||||
const idx = legacyChoiceIndex(rng, pool.length);
|
||||
return pool[idx] ?? null;
|
||||
};
|
||||
|
||||
@@ -200,7 +211,7 @@ const resolveSpawnCityId = (
|
||||
}
|
||||
const pool = context.cityPool ?? [];
|
||||
if (pool.length > 0) {
|
||||
const idx = rng.nextInt(0, pool.length);
|
||||
const idx = legacyChoiceIndex(rng, pool.length);
|
||||
return pool[idx]!.id;
|
||||
}
|
||||
return context.general.cityId;
|
||||
@@ -273,13 +284,6 @@ export class ActionResolver<
|
||||
const prop = this.command.calcFoundProp(context);
|
||||
const found = context.rng.nextBool(prop);
|
||||
|
||||
const statKey = pickStatExpKey(context.rng, general);
|
||||
const metaAfter = found ? addMetaNumber(general.meta, statKey, 3) : addMetaNumber(general.meta, statKey, 1);
|
||||
if (found) {
|
||||
const active = typeof metaAfter.inherit_active_action === 'number' ? metaAfter.inherit_active_action : 0;
|
||||
metaAfter.inherit_active_action = active + Math.max(Math.sqrt(1 / prop), 1);
|
||||
}
|
||||
|
||||
const nextGold = Math.max(0, general.gold - reqGold);
|
||||
const nextRice = Math.max(0, general.rice - reqRice);
|
||||
const expGain = found ? 200 : 100;
|
||||
@@ -290,9 +294,10 @@ export class ActionResolver<
|
||||
general.rice = nextRice;
|
||||
general.experience += expGain;
|
||||
general.dedication += dedGain;
|
||||
general.meta = metaAfter;
|
||||
|
||||
if (!found) {
|
||||
const statKey = pickStatExpKey(context.rng, general);
|
||||
general.meta = addMetaNumber(general.meta, statKey, 1);
|
||||
context.addLog('인재를 찾을 수 없었습니다.', {
|
||||
category: LogCategory.ACTION,
|
||||
format: LogFormat.MONTH,
|
||||
@@ -301,10 +306,6 @@ export class ActionResolver<
|
||||
return { effects: [] };
|
||||
}
|
||||
|
||||
const candidate = resolveCandidate(context, context.rng, this.env);
|
||||
const newGeneralId = context.createGeneralId();
|
||||
const resolvedCandidate: TalentScoutCandidate = candidate ?? { name: `NPC_${newGeneralId}` };
|
||||
|
||||
const age = randomRangeInt(
|
||||
context.rng,
|
||||
this.env.minNpcAge ?? DEFAULT_MIN_AGE,
|
||||
@@ -318,45 +319,108 @@ export class ActionResolver<
|
||||
this.env.minDeathYears ?? DEFAULT_DEATH_MIN,
|
||||
this.env.maxDeathYears ?? DEFAULT_DEATH_MAX
|
||||
);
|
||||
const stats = resolveStats(context, context.rng, this.env, resolvedCandidate);
|
||||
const candidate = resolveCandidate(context, context.rng, this.env);
|
||||
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)}`;
|
||||
const newGeneralId = context.createGeneralId();
|
||||
const resolvedCandidate: TalentScoutCandidate = candidate ?? { name: generatedName };
|
||||
const affinity = randomRangeInt(context.rng, 1, 150);
|
||||
const npcStatTotal = this.env.npcStatTotal ?? 150;
|
||||
const npcStatMin = this.env.npcStatMin ?? 10;
|
||||
const npcStatMax = this.env.npcStatMax ?? 50;
|
||||
const pickType = pickByWeight(context.rng, { 무: 6, 지: 6, 무지: 3 });
|
||||
const mainStat = npcStatMax - randomRangeInt(context.rng, 0, npcStatMin);
|
||||
const otherStat = npcStatMin + randomRangeInt(context.rng, 0, Math.trunc(npcStatMin / 2));
|
||||
const subStat = npcStatTotal - mainStat - otherStat;
|
||||
let generatedStats: StatBlock;
|
||||
if (pickType === '무') {
|
||||
generatedStats = { leadership: subStat, strength: mainStat, intelligence: otherStat };
|
||||
} else if (pickType === '지') {
|
||||
generatedStats = { leadership: subStat, strength: otherStat, intelligence: mainStat };
|
||||
} else {
|
||||
generatedStats = { leadership: otherStat, strength: subStat, intelligence: mainStat };
|
||||
}
|
||||
const stats = candidate?.stats
|
||||
? resolveStats(context, context.rng, this.env, resolvedCandidate)
|
||||
: generatedStats;
|
||||
const averageDex = context.worldSummary.averageDex ?? [0, 0, 0, 0, 0];
|
||||
const dexTotal = averageDex[0] + averageDex[1] + averageDex[2] + averageDex[3];
|
||||
let dex: [number, number, number, number, number];
|
||||
if (pickType === '무') {
|
||||
const distributions = [
|
||||
[dexTotal * 5 / 8, dexTotal / 8, dexTotal / 8, dexTotal / 8],
|
||||
[dexTotal / 8, dexTotal * 5 / 8, dexTotal / 8, dexTotal / 8],
|
||||
[dexTotal / 8, dexTotal / 8, dexTotal * 5 / 8, dexTotal / 8],
|
||||
] as const;
|
||||
const picked = legacyChoice(context.rng, distributions);
|
||||
dex = [picked[0], picked[1], picked[2], picked[3], averageDex[4]];
|
||||
} else if (pickType === '지') {
|
||||
dex = [dexTotal / 8, dexTotal / 8, dexTotal / 8, dexTotal * 5 / 8, averageDex[4]];
|
||||
} else {
|
||||
dex = [dexTotal / 4, dexTotal / 4, dexTotal / 4, dexTotal / 4, averageDex[4]];
|
||||
}
|
||||
const personality =
|
||||
resolvedCandidate.personality ??
|
||||
legacyChoice(context.rng, this.env.availablePersonalities ?? ['che_안전']);
|
||||
const name = this.env.decorateName
|
||||
? this.env.decorateName(resolvedCandidate.name, NPC_TYPE)
|
||||
: resolvedCandidate.name;
|
||||
: `ⓜ${resolvedCandidate.name}`;
|
||||
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 killturn =
|
||||
(deathYear - context.currentYear) * 12 +
|
||||
randomRangeInt(context.rng, 0, 11) +
|
||||
context.currentMonth -
|
||||
1;
|
||||
const meta: GeneralMeta = {
|
||||
killturn: resolveKillturnFromDeathYear(context.currentYear, context.currentMonth, deathYear, context.rng),
|
||||
killturn,
|
||||
npcType: NPC_TYPE,
|
||||
crewTypeId: this.env.defaultCrewTypeId,
|
||||
affinity,
|
||||
birthYear,
|
||||
deathYear,
|
||||
dex1: dex[0],
|
||||
dex2: dex[1],
|
||||
dex3: dex[2],
|
||||
dex4: dex[3],
|
||||
dex5: dex[4],
|
||||
turnSecond,
|
||||
turnFraction,
|
||||
};
|
||||
addMetaValue(meta, 'affinity', resolvedCandidate.affinity ?? null);
|
||||
addMetaValue(meta, 'picture', resolvedCandidate.picture ?? null);
|
||||
addMetaValue(meta, 'birthYear', birthYear);
|
||||
addMetaValue(meta, 'text', resolvedCandidate.text ?? null);
|
||||
|
||||
const newGeneral = buildRecruitmentGeneral<TriggerState>({
|
||||
id: newGeneralId,
|
||||
name,
|
||||
nationId: 0,
|
||||
cityId: resolveSpawnCityId(context, context.rng, this.env),
|
||||
cityId,
|
||||
stats,
|
||||
officerLevel: 0,
|
||||
age,
|
||||
npcState: NPC_TYPE,
|
||||
gold: this.env.defaultNpcGold,
|
||||
rice: this.env.defaultNpcRice,
|
||||
experience: 0,
|
||||
dedication: 0,
|
||||
experience: age * 100,
|
||||
dedication: age * 100,
|
||||
crewTypeId: this.env.defaultCrewTypeId,
|
||||
role: {
|
||||
personality: resolvedCandidate.personality ?? null,
|
||||
specialDomestic: this.env.defaultSpecialDomestic,
|
||||
specialWar: this.env.defaultSpecialWar,
|
||||
personality,
|
||||
specialDomestic: null,
|
||||
specialWar: null,
|
||||
},
|
||||
meta,
|
||||
});
|
||||
|
||||
const nameObjJosa = JosaUtil.pick(name, '을');
|
||||
const nameSubjJosa = JosaUtil.pick(name, '이');
|
||||
const recruitVerb = randomRangeInt(context.rng, 0, 1) === 0 ? '발견' : '등용';
|
||||
const recruitVerb = '발견';
|
||||
const nameRa = JosaUtil.pick(name, '라');
|
||||
context.addLog(`<Y>${name}</>${nameRa}는 <C>인재</>를 ${recruitVerb}하였습니다!`, {
|
||||
category: LogCategory.ACTION,
|
||||
@@ -374,6 +438,12 @@ export class ActionResolver<
|
||||
|
||||
tryApplyUniqueLottery(context, { acquireType: '아이템', reason: ACTION_NAME });
|
||||
|
||||
const statKey = pickStatExpKey(context.rng, general);
|
||||
const metaAfter = addMetaNumber(general.meta, statKey, 3);
|
||||
const active = typeof metaAfter.inherit_active_action === 'number' ? metaAfter.inherit_active_action : 0;
|
||||
metaAfter.inherit_active_action = active + Math.max(Math.sqrt(1 / prop), 1);
|
||||
general.meta = metaAfter;
|
||||
|
||||
return {
|
||||
effects: [createGeneralAddEffect(newGeneral)],
|
||||
};
|
||||
@@ -418,8 +488,25 @@ export const actionContextBuilder: ActionContextBuilder = (base, options) => ({
|
||||
...base,
|
||||
currentYear: options.world.currentYear,
|
||||
currentMonth: options.world.currentMonth,
|
||||
worldSummary: buildWorldSummary(options.worldRef),
|
||||
worldSummary: {
|
||||
...buildWorldSummary(options.worldRef),
|
||||
averageDex: (() => {
|
||||
const generals = options.worldRef?.listGenerals().filter((general) => general.npcState < 4) ?? [];
|
||||
if (generals.length === 0) {
|
||||
return [0, 0, 0, 0, 0] as [number, number, number, number, number];
|
||||
}
|
||||
return [1, 2, 3, 4, 5].map(
|
||||
(armType) =>
|
||||
generals.reduce((sum, general) => {
|
||||
const value = general.meta[`dex${armType}`];
|
||||
return sum + (typeof value === 'number' ? value : 0);
|
||||
}, 0) / generals.length
|
||||
) as [number, number, number, number, number];
|
||||
})(),
|
||||
},
|
||||
cityPool: options.worldRef?.listCities() ?? [],
|
||||
createGeneralId: options.createGeneralId,
|
||||
turnTermMinutes: Math.max(1, Math.round(options.world.tickSeconds / 60)),
|
||||
});
|
||||
|
||||
export const commandSpec: GeneralTurnCommandSpec = {
|
||||
@@ -427,5 +514,6 @@ export const commandSpec: GeneralTurnCommandSpec = {
|
||||
category: '인사',
|
||||
reqArg: false,
|
||||
|
||||
createDefinition: (env: TurnCommandEnv) => new ActionDefinition(env.generalActionModules ?? [], env),
|
||||
createDefinition: (env: TurnCommandEnv) =>
|
||||
new ActionDefinition(env.generalActionModules ?? [], env),
|
||||
};
|
||||
|
||||
@@ -31,6 +31,7 @@ import type { WarAftermathConfig, WarEngineConfig, WarTimeContext } from '@sammo
|
||||
import { resolveWarAftermath } from '@sammo-ts/logic/war/aftermath.js';
|
||||
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/triggers/special/nation/index.js';
|
||||
import { increaseMetaNumber, 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';
|
||||
@@ -157,6 +158,15 @@ const pickCandidateCity = (
|
||||
distanceList: Map<number, Array<[number, number]>>,
|
||||
attackerNationId: number
|
||||
): { cityId: number; isEnemy: boolean; minDist: number } | null => {
|
||||
const pickLegacyChoice = <T>(items: T[]): T => {
|
||||
const legacyCompatibleRng = rng as typeof rng & {
|
||||
nextIntInclusive?: (maxInclusive: number) => number;
|
||||
};
|
||||
const index =
|
||||
legacyCompatibleRng.nextIntInclusive?.(items.length - 1) ??
|
||||
rng.nextInt(0, items.length);
|
||||
return items[index]!;
|
||||
};
|
||||
const distances = Array.from(distanceList.keys()).sort((a, b) => a - b);
|
||||
const minDist = distances[0];
|
||||
if (minDist === undefined) {
|
||||
@@ -174,8 +184,9 @@ const pickCandidateCity = (
|
||||
}
|
||||
}
|
||||
if (candidates.length > 0) {
|
||||
const index = rng.nextInt(0, candidates.length);
|
||||
const [cityId] = candidates[index] ?? candidates[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) ?? [];
|
||||
@@ -183,8 +194,7 @@ const pickCandidateCity = (
|
||||
if (friendly.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const index = rng.nextInt(0, friendly.length);
|
||||
const [cityId] = friendly[index] ?? friendly[0]!;
|
||||
const [cityId] = pickLegacyChoice(friendly);
|
||||
return { cityId, isEnemy: false, minDist };
|
||||
};
|
||||
|
||||
@@ -245,9 +255,14 @@ export class ActionDefinition<
|
||||
return 1;
|
||||
}
|
||||
private readonly warModules: Array<WarActionModule<TriggerState>>;
|
||||
private readonly nationTraitModules: Map<string, NationTraitModule>;
|
||||
|
||||
constructor(modules: Array<WarActionModule<TriggerState> | null | undefined> = []) {
|
||||
constructor(
|
||||
modules: Array<WarActionModule<TriggerState> | null | undefined> = [],
|
||||
nationTraitModules: NationTraitModule[] = []
|
||||
) {
|
||||
this.warModules = modules.filter(Boolean) as Array<WarActionModule<TriggerState>>;
|
||||
this.nationTraitModules = new Map(nationTraitModules.map((module) => [module.key, module]));
|
||||
}
|
||||
|
||||
parseArgs(raw: unknown): DispatchArgs | null {
|
||||
@@ -400,6 +415,11 @@ export class ActionDefinition<
|
||||
const nationMap = new Map(nations.map((nation) => [nation.id, nation]));
|
||||
|
||||
const defenderCity = cityMap.get(destCity.id) ?? cloneCity(destCity);
|
||||
// Legacy marks the destination as an active battle for three turns
|
||||
// before processWar(), including when the attack immediately conquers
|
||||
// the city. ConquerCity resets term but intentionally leaves state 43.
|
||||
defenderCity.state = 43;
|
||||
defenderCity.meta.term = 3;
|
||||
const defenderNation = defenderCity.nationId > 0 ? (nationMap.get(defenderCity.nationId) ?? null) : null;
|
||||
|
||||
const defenderGenerals = generals.filter(
|
||||
@@ -444,6 +464,17 @@ export class ActionDefinition<
|
||||
config: context.aftermathConfig,
|
||||
time,
|
||||
hiddenSeed: context.seedBase,
|
||||
calcNationTechGain: ({ nation, baseGain }) => {
|
||||
const module = this.nationTraitModules.get(nation.typeCode);
|
||||
return (
|
||||
module?.onCalcDomestic?.(
|
||||
{ general: context.general, nation },
|
||||
'기술',
|
||||
'score',
|
||||
baseGain
|
||||
) ?? baseGain
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const effects: Array<GeneralActionEffect<TriggerState>> = [];
|
||||
@@ -559,5 +590,6 @@ export const commandSpec: GeneralTurnCommandSpec = {
|
||||
reqArg: true,
|
||||
availabilityArgs: { destCityId: 0 },
|
||||
argsSchema: ARGS_SCHEMA,
|
||||
createDefinition: (env: TurnCommandEnv) => new ActionDefinition(env.warActionModules ?? []),
|
||||
createDefinition: (env: TurnCommandEnv) =>
|
||||
new ActionDefinition(env.warActionModules ?? [], env.nationTraitModules ?? []),
|
||||
};
|
||||
|
||||
@@ -15,6 +15,7 @@ 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/triggers/general-action.js';
|
||||
|
||||
const ACTION_NAME = '맹훈련';
|
||||
const ACTION_KEY = 'cr_맹훈련';
|
||||
@@ -36,8 +37,11 @@ export class ActionDefinition<
|
||||
> implements GeneralActionDefinition<TriggerState, FierceTrainingArgs> {
|
||||
public readonly key = ACTION_KEY;
|
||||
public readonly name = ACTION_NAME;
|
||||
private readonly pipeline: GeneralActionPipeline<TriggerState>;
|
||||
|
||||
constructor(private readonly env: TurnCommandEnv) {}
|
||||
constructor(private readonly env: TurnCommandEnv) {
|
||||
this.pipeline = new GeneralActionPipeline(env.generalActionModules ?? []);
|
||||
}
|
||||
|
||||
parseArgs(_raw: unknown): FierceTrainingArgs | null {
|
||||
return {};
|
||||
@@ -67,7 +71,8 @@ export class ActionDefinition<
|
||||
const maxTrain = this.env.maxTrainByCommand > 0 ? this.env.maxTrainByCommand : 100;
|
||||
const maxAtmos = this.env.maxAtmosByCommand > 0 ? this.env.maxAtmosByCommand : 100;
|
||||
|
||||
const score = Math.round((general.stats.leadership * 100 * trainDelta * 2) / (Math.max(general.crew, 1) * 3));
|
||||
const leadership = this.pipeline.onCalcStat(context, 'leadership', general.stats.leadership);
|
||||
const score = Math.round((leadership * 100 * trainDelta * 2) / (Math.max(general.crew, 1) * 3));
|
||||
const scoreText = score.toLocaleString('en-US');
|
||||
|
||||
context.addLog(`훈련, 사기치가 <C>${scoreText}</> 상승했습니다.`, {
|
||||
@@ -81,11 +86,13 @@ export class ActionDefinition<
|
||||
const nextTrain = clamp(general.train + score, 0, maxTrain);
|
||||
const nextAtmos = clamp(general.atmos + score, 0, maxAtmos);
|
||||
const leadershipExp = typeof general.meta.leadership_exp === 'number' ? general.meta.leadership_exp : 0;
|
||||
const crewType = this.env.unitSet?.crewTypes?.find((entry) => entry.id === general.crewTypeId);
|
||||
const dexKey = crewType ? `dex${crewType.armType}` : null;
|
||||
const currentDex = dexKey && typeof general.meta[dexKey] === 'number' ? general.meta[dexKey] : 0;
|
||||
|
||||
return {
|
||||
effects: [
|
||||
createGeneralPatchEffect<TriggerState>({
|
||||
rice: Math.max(0, general.rice - 500),
|
||||
train: nextTrain,
|
||||
atmos: nextAtmos,
|
||||
experience: general.experience + 150,
|
||||
@@ -93,6 +100,7 @@ export class ActionDefinition<
|
||||
meta: {
|
||||
...general.meta,
|
||||
leadership_exp: leadershipExp + 1,
|
||||
...(dexKey ? { [dexKey]: currentDex + score * 2 } : {}),
|
||||
},
|
||||
}),
|
||||
],
|
||||
|
||||
@@ -93,7 +93,7 @@ export class ActionDefinition<
|
||||
}
|
||||
|
||||
resolve(context: SeizureResolveContext<TriggerState>, args: SeizureArgs): GeneralActionOutcome<TriggerState> {
|
||||
const { general, nation, destGeneral } = context;
|
||||
const { nation, destGeneral } = context;
|
||||
if (!nation) {
|
||||
return { effects: [createLogEffect('국가 정보가 없습니다.', { scope: LogScope.GENERAL })] };
|
||||
}
|
||||
@@ -147,9 +147,6 @@ export class ActionDefinition<
|
||||
}),
|
||||
];
|
||||
|
||||
general.experience += 5;
|
||||
general.dedication += 5;
|
||||
|
||||
return { effects };
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user