fix: Ref 게임 로직과 시나리오 풀 호환을 보정

월 경계, 전투 기술 상한, 연감과 베팅·설문·경매 정산 순서를 Ref 계약에 맞춘다.\n\n시나리오 일반 풀을 ENGINE mutation과 logical tick 기반으로 직렬화하고 914·915 catalog 및 조건부 100기 pool 실행 경계를 추가한다.\n\n경매 worker는 세대별 durable event만 만들고 ENGINE이 row lock 후 상태 전이와 정산을 단일 transaction으로 소유한다.
This commit is contained in:
2026-08-23 16:26:14 +00:00
parent bf6b7be7b0
commit 85591c68ad
114 changed files with 13327 additions and 901 deletions
+1
View File
@@ -2,6 +2,7 @@ export * from './definition.js';
export * from './engine.js';
export * from './turn/commandEnv.js';
export * from './turn/actionContext.js';
export * from './turn/generalPool.js';
export * from './turn/commandModule.js';
export * from './turn/commandProfile.js';
export * from './turn/general/index.js';
@@ -4,6 +4,7 @@ import type { ScenarioConfig } from '@sammo-ts/logic/scenario/types.js';
import type { ScenarioMeta } from '@sammo-ts/logic/world/types.js';
import type { MapDefinition, UnitSetDefinition } from '@sammo-ts/logic/world/types.js';
import type { GeneralWorldView } from '@sammo-ts/logic/triggers/general.js';
import type { ScenarioGeneralPoolCandidate } from '@sammo-ts/logic/actions/turn/generalPool.js';
export interface ActionRandomSource {
nextFloat1(): number;
@@ -27,6 +28,7 @@ export type ActionContextBase = {
month: number;
startYear: number;
};
maxTechLevel?: number;
};
export type ActionResolveContext = ActionContextBase & Record<string, unknown>;
@@ -50,6 +52,7 @@ export interface ActionContextWorldRef {
toNationId: number;
state: number;
}>;
listGeneralPoolCandidates?(claimedAt: Date): ScenarioGeneralPoolCandidate[] | undefined;
getDiplomacyEntry(
fromNationId: number,
toNationId: number
@@ -209,6 +209,7 @@ 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),
maxTechLevel: resolveNumber(constValues, ['maxTechLevel'], DEFAULT_AFTER_CONFIG.maxTechLevel),
maxGeneralStat: resolveNumber(constValues, ['maxLevel'], LEGACY_DEFAULT_MAX_LEVEL),
statUpgradeLimit: resolveNumber(constValues, ['upgradeLimit'], 30),
castleCrewTypeId,
@@ -12,6 +12,7 @@ import { JosaUtil } from '@sammo-ts/common';
import { getMetaNumber, setMetaNumber, increaseMetaNumber } from '@sammo-ts/logic/war/utils.js';
import { z } from 'zod';
import { parseArgsWithSchema } from '../parseArgs.js';
import { reconcileCentennialDexConversion } from '@sammo-ts/logic/scenario/centennialAllStar.js';
export interface DexTransferContext<
TriggerState extends GeneralTriggerState = GeneralTriggerState,
@@ -82,11 +83,24 @@ export class ActionDefinition<
const srcKey = `dex${args.srcArmType}`;
const destKey = `dex${args.destArmType}`;
const srcDex = getMetaNumber(general.meta, srcKey, 0);
const destDex = getMetaNumber(general.meta, destKey, 0);
const cutDex = Math.trunc(srcDex * DECREASE_COEFF);
const addDex = Math.trunc(cutDex * CONVERT_COEFF);
setMetaNumber(general.meta, srcKey, srcDex - cutDex);
setMetaNumber(general.meta, destKey, getMetaNumber(general.meta, destKey, 0) + addDex);
setMetaNumber(general.meta, destKey, destDex + addDex);
if (args.srcArmType <= 5 && args.destArmType <= 5) {
general.meta = reconcileCentennialDexConversion(
general.meta,
srcKey as `dex${1 | 2 | 3 | 4 | 5}`,
destKey as `dex${1 | 2 | 3 | 4 | 5}`,
srcDex,
srcDex - cutDex,
destDex,
destDex + addDex,
CONVERT_COEFF
);
}
const srcName = resolveArmTypeName(context.unitSet, args.srcArmType);
const destName = resolveArmTypeName(context.unitSet, args.destArmType);
@@ -20,22 +20,44 @@ import { createGeneralAddEffect } from '@sammo-ts/logic/actions/engine.js';
import { LogCategory, LogFormat, LogScope } from '@sammo-ts/logic/logging/types.js';
import { buildRecruitmentGeneral } from './recruitment.js';
import type { ActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js';
import { buildWorldSummary } from '@sammo-ts/logic/actions/turn/actionContextHelpers.js';
import { buildWorldSummary, resolveStartYear } from '@sammo-ts/logic/actions/turn/actionContextHelpers.js';
import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js';
import { tryApplyUniqueLottery } from '@sammo-ts/logic/rewards/uniqueLottery.js';
import type { GeneralTurnCommandSpec } from './index.js';
import {
buildScenarioGeneralPoolClaimMeta,
pickUniqueScenarioGeneralPoolCandidates,
resolveLegacyNpcStatTypeFromFixedStats,
type ScenarioGeneralPoolCandidate,
} from '@sammo-ts/logic/actions/turn/generalPool.js';
import {
CENTENNIAL_ALL_STAR_NPC_PROGRESS_MULTIPLIER,
applyCentennialAllStarTarget,
initializeCentennialGeneratedNpc,
readCentennialAllStarPoolTarget,
resolveCentennialAllStarRules,
resolveCentennialNpcDexTargetRatio,
type CentennialAllStarRules,
} from '@sammo-ts/logic/scenario/centennialAllStar.js';
export interface TalentScoutArgs {}
export interface TalentScoutCandidate {
name: string;
poolEntryId?: number;
uniqueName?: string;
stats?: Partial<StatBlock>;
dex?: [number, number, number, number, number];
personality?: string | null;
affinity?: number | null;
specialDomestic?: string | null;
specialWar?: string | null;
picture?: number | string | null;
imageServer?: number;
text?: string | null;
experience?: number;
dedication?: number;
sourceInfo?: Record<string, unknown>;
}
export interface TalentScoutWorldSummary {
@@ -50,9 +72,12 @@ export interface TalentScoutResolveContext<
> extends GeneralActionResolveContext<TriggerState> {
currentYear: number;
currentMonth: number;
startYear: number;
retirementYear: number;
centennialRules: CentennialAllStarRules;
centennialNpcDexTargetRatio: number;
worldSummary: TalentScoutWorldSummary;
generalPool?: TalentScoutCandidate[];
generalPool?: ScenarioGeneralPoolCandidate[];
cityPool?: City[];
existingGeneralNames: string[];
createGeneralId: () => number;
@@ -228,11 +253,10 @@ const resolveCandidate = (
return env.pickCandidate(context, rng);
}
const pool = context.generalPool ?? [];
if (pool.length === 0) {
if (context.generalPool === undefined) {
return null;
}
const idx = legacyChoiceIndex(rng, pool.length);
return pool[idx] ?? null;
return pickUniqueScenarioGeneralPoolCandidates(rng, pool, 1)[0] ?? null;
};
const resolveSpawnCityId = (
@@ -371,51 +395,66 @@ export class ActionResolver<
this.env.maxDeathYears ?? DEFAULT_DEATH_MAX
);
const candidate = resolveCandidate(context, context.rng, this.env);
const centennialTarget =
candidate?.sourceInfo && candidate.uniqueName
? readCentennialAllStarPoolTarget({
uniqueName: candidate.uniqueName,
name: candidate.name,
sourceInfo: candidate.sourceInfo,
})
: null;
const firstNames = this.env.randomGeneralFirstNames ?? ['가'];
const middleNames = this.env.randomGeneralMiddleNames ?? [''];
const lastNames = this.env.randomGeneralLastNames ?? ['가'];
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;
let generatedName: string | null = null;
if (!candidate) {
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;
}
if (duplicateLoopCount >= 99 || duplicateCount < 2) {
generatedName += duplicateCount + 1;
break;
}
duplicateLoopCount += 1;
}
const newGeneralId = context.createGeneralId();
const resolvedCandidate: TalentScoutCandidate = candidate ?? { name: generatedName };
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 };
let pickType: '무' | '지' | '무지';
let stats: StatBlock;
if (candidate?.stats && !centennialTarget) {
stats = resolveStats(context, context.rng, this.env, resolvedCandidate);
pickType = resolveLegacyNpcStatTypeFromFixedStats(context.rng, stats);
} else {
generatedStats = { leadership: otherStat, strength: subStat, intelligence: mainStat };
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;
if (pickType === '무') {
stats = { leadership: subStat, strength: mainStat, intelligence: otherStat };
} else if (pickType === '지') {
stats = { leadership: subStat, strength: otherStat, intelligence: mainStat };
} else {
stats = { 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 === '무') {
if (candidate?.dex?.[0] && !centennialTarget) {
dex = candidate.dex;
} else if (pickType === '무') {
const distributions = [
[(dexTotal * 5) / 8, dexTotal / 8, dexTotal / 8, dexTotal / 8],
[dexTotal / 8, (dexTotal * 5) / 8, dexTotal / 8, dexTotal / 8],
@@ -469,11 +508,14 @@ export class ActionResolver<
dex5: dex[4],
turnSecond,
turnFraction,
...(candidate && candidate.poolEntryId !== undefined && candidate.uniqueName
? buildScenarioGeneralPoolClaimMeta(candidate as ScenarioGeneralPoolCandidate, context.turnTimeBase)
: {}),
};
addMetaValue(meta, 'picture', resolvedCandidate.picture ?? null);
addMetaValue(meta, 'text', resolvedCandidate.text ?? null);
const newGeneral = {
let newGeneral = {
...buildRecruitmentGeneral<TriggerState>({
id: newGeneralId,
name,
@@ -485,8 +527,8 @@ export class ActionResolver<
npcState: NPC_TYPE,
gold: this.env.defaultNpcGold,
rice: this.env.defaultNpcRice,
experience: age * 100,
dedication: age * 100,
experience: resolvedCandidate.experience || age * 100,
dedication: resolvedCandidate.dedication || age * 100,
crewTypeId: this.env.defaultCrewTypeId,
role: {
personality,
@@ -500,7 +542,25 @@ export class ActionResolver<
bornYear: birthYear,
deadYear: deathYear,
affinity,
imageServer: resolvedCandidate.imageServer ?? 0,
picture: resolvedCandidate.picture ?? 'default.jpg',
};
if (centennialTarget) {
const initialized = initializeCentennialGeneratedNpc(newGeneral, centennialTarget, context.centennialRules);
const growth = applyCentennialAllStarTarget(
{ ...newGeneral, ...initialized },
centennialTarget,
{
startYear: context.startYear,
year: context.currentYear,
month: context.currentMonth,
},
context.centennialRules,
CENTENNIAL_ALL_STAR_NPC_PROGRESS_MULTIPLIER,
context.centennialNpcDexTargetRatio
);
newGeneral = { ...newGeneral, stats: growth.stats, role: growth.role, meta: growth.meta };
}
const recruitVerb = '발견';
const nameRa = JosaUtil.pick(name, '라');
@@ -578,10 +638,13 @@ export const actionContextBuilder: ActionContextBuilder = (base, options) => ({
...base,
currentYear: options.world.currentYear,
currentMonth: options.world.currentMonth,
startYear: resolveStartYear(options.world, options.scenarioMeta),
retirementYear:
typeof options.scenarioConfig.const.retirementYear === 'number'
? options.scenarioConfig.const.retirementYear
: 80,
centennialRules: resolveCentennialAllStarRules(options.scenarioConfig),
centennialNpcDexTargetRatio: resolveCentennialNpcDexTargetRatio(options.scenarioConfig),
worldSummary: {
...buildWorldSummary(options.worldRef),
averageDex: (() => {
@@ -605,6 +668,11 @@ export const actionContextBuilder: ActionContextBuilder = (base, options) => ({
// AbsGeneralPool::checkDuplicatedCnt semantics; the duplicated ⓝ prefix in
// GeneralBuilder::$prefixList intentionally counts NPC matches twice.
existingGeneralNames: options.worldRef?.listGenerals().map(restoreLegacyStoredName) ?? [],
...(() => {
const claimedAt = options.world.lastTurnTime ?? base.general.turnTime;
const generalPool = options.worldRef?.listGeneralPoolCandidates?.(claimedAt);
return generalPool === undefined ? {} : { generalPool };
})(),
createGeneralId: options.createGeneralId,
turnTermMinutes: Math.max(1, Math.round(options.world.tickSeconds / 60)),
// GeneralBuilder::build() derives a new NPC turn from gameStor.turntime,
@@ -68,7 +68,7 @@ export class ActionDefinition<
? (view.get({ kind: 'nation', id: ctx.nationId }) as Nation | null)
: null;
const crew = typeof general?.crew === 'number' ? general.crew : 0;
const techCost = getTechCost(readNationTech(nation));
const techCost = getTechCost(readNationTech(nation), this.env.maxTechLevel);
return Math.round((crew / 100) * 3 * techCost);
}, nationRequirement),
reqGeneralRice(() => 0),
@@ -39,6 +39,7 @@ export interface RecruitEnvironment {
defaultAtmos?: number;
minAvailableRecruitPop?: number;
defaultTrust?: number;
maxTechLevel?: number;
actionName?: '징병' | '모병';
}
@@ -191,6 +192,12 @@ type RecruitCalcContext<TriggerState extends GeneralTriggerState = GeneralTrigge
general: General<TriggerState>;
city?: City;
nation?: Nation | null;
time?: {
year: number;
month: number;
startYear: number;
};
maxTechLevel?: number;
};
const buildCalcContext = <TriggerState extends GeneralTriggerState>(
@@ -216,6 +223,25 @@ const buildCalcContext = <TriggerState extends GeneralTriggerState>(
if (nation !== undefined) {
result.nation = nation;
}
const year =
typeof ctx.env.currentYear === 'number'
? ctx.env.currentYear
: typeof ctx.env.year === 'number'
? ctx.env.year
: undefined;
const month =
typeof ctx.env.currentMonth === 'number'
? ctx.env.currentMonth
: typeof ctx.env.month === 'number'
? ctx.env.month
: undefined;
const startYear = typeof ctx.env.startYear === 'number' ? ctx.env.startYear : undefined;
if (year !== undefined && month !== undefined && startYear !== undefined) {
result.time = { year, month, startYear };
}
if (typeof ctx.env.maxTechLevel === 'number') {
result.maxTechLevel = ctx.env.maxTechLevel;
}
return result;
};
@@ -247,7 +273,10 @@ export class CommandResolver<TriggerState extends GeneralTriggerState = GeneralT
context: RecruitCalcContext<TriggerState>,
crewType: { armType: number; cost: number; rice: number }
): { gold: number; rice: number } {
const techCost = getTechCost(readNationTech(context.nation ?? null));
const techCost = getTechCost(
readNationTech(context.nation ?? null),
context.maxTechLevel ?? this.env.maxTechLevel
);
return {
gold: this.pipeline.onCalcDomestic(context, this.actionName, 'cost', crewType.cost * techCost, {
armType: crewType.armType,
@@ -282,7 +311,9 @@ export class CommandResolver<TriggerState extends GeneralTriggerState = GeneralT
const plan = this.resolveCrewPlan(context, crewTypeId, amount);
const tech = readNationTech(context.nation ?? null);
const costOffset = this.env.costOffset ?? DEFAULT_COST_OFFSET;
const baseGold = crewType ? (crewType.cost * getTechCost(tech) * plan.applied) / 100 : 0;
const baseGold = crewType
? (crewType.cost * getTechCost(tech, context.maxTechLevel ?? this.env.maxTechLevel) * plan.applied) / 100
: 0;
const adjustedGold = this.pipeline.onCalcDomestic(
context,
this.actionName,
@@ -0,0 +1,202 @@
import { asRecord, type RandomGenerator } from '@sammo-ts/common';
import type { GeneralMeta, StatBlock } from '@sammo-ts/logic/domain/entities.js';
export interface ScenarioGeneralPoolCandidate {
poolEntryId: number;
uniqueName: string;
name: string;
stats?: StatBlock;
dex?: [number, number, number, number, number];
personality?: string | null;
affinity?: number | null;
specialDomestic?: string | null;
specialWar?: string | null;
imageServer?: number;
picture?: number | string | null;
text?: string | null;
experience?: number;
dedication?: number;
weight?: number;
sourceInfo: Record<string, unknown>;
}
export interface ScenarioGeneralPoolClaim {
poolEntryId: number;
uniqueName: string;
claimedAt: string;
}
const CLAIM_META_KEY = 'scenarioGeneralPoolClaim';
const readFiniteNumber = (value: unknown): number | null =>
typeof value === 'number' && Number.isFinite(value) ? value : null;
const readOptionalString = (value: unknown): string | null | undefined => {
if (value === null) {
return null;
}
return typeof value === 'string' ? value : undefined;
};
export const parseScenarioGeneralPoolCandidate = (entry: {
id: number;
uniqueName: string;
info: unknown;
}): ScenarioGeneralPoolCandidate => {
const info = asRecord(entry.info);
const name = typeof info.generalName === 'string' && info.generalName !== '' ? info.generalName : entry.uniqueName;
const leadership = readFiniteNumber(info.leadership);
const strength = readFiniteNumber(info.strength);
const intelligence = readFiniteNumber(info.intel);
const rawDex = Array.isArray(info.dex) ? info.dex.map(readFiniteNumber) : [];
const dex =
rawDex.length === 5 && rawDex.every((value): value is number => value !== null)
? (rawDex as [number, number, number, number, number])
: undefined;
const experience = readFiniteNumber(info.experience);
const dedication = readFiniteNumber(info.dedication);
const weight = readFiniteNumber(info.weight);
const imageServer = readFiniteNumber(info.imgsvr);
const specialDomestic = readOptionalString(info.specialDomestic);
const specialWar = readOptionalString(info.specialWar);
return {
poolEntryId: entry.id,
uniqueName: entry.uniqueName,
name,
sourceInfo: structuredClone(info),
...(leadership !== null && strength !== null && intelligence !== null
? {
stats: {
leadership,
strength,
intelligence,
},
}
: {}),
...(dex ? { dex } : {}),
...(specialDomestic !== undefined ? { specialDomestic } : {}),
...(specialWar !== undefined ? { specialWar } : {}),
...(imageServer !== null ? { imageServer } : {}),
...(info.picture === null || typeof info.picture === 'string' || typeof info.picture === 'number'
? { picture: info.picture }
: {}),
...(experience !== null ? { experience } : {}),
...(dedication !== null ? { dedication } : {}),
...(weight !== null ? { weight } : {}),
};
};
export const buildScenarioGeneralPoolClaimMeta = (
candidate: ScenarioGeneralPoolCandidate,
claimedAt: Date
): Pick<GeneralMeta, typeof CLAIM_META_KEY> => ({
[CLAIM_META_KEY]: {
poolEntryId: candidate.poolEntryId,
uniqueName: candidate.uniqueName,
claimedAt: claimedAt.toISOString(),
},
});
export const readScenarioGeneralPoolClaim = (meta: Record<string, unknown>): ScenarioGeneralPoolClaim | null => {
const raw = asRecord(meta[CLAIM_META_KEY]);
const poolEntryId = readFiniteNumber(raw.poolEntryId);
if (
poolEntryId === null ||
!Number.isSafeInteger(poolEntryId) ||
poolEntryId <= 0 ||
typeof raw.uniqueName !== 'string' ||
raw.uniqueName === '' ||
typeof raw.claimedAt !== 'string' ||
Number.isNaN(new Date(raw.claimedAt).getTime())
) {
return null;
}
return {
poolEntryId,
uniqueName: raw.uniqueName,
claimedAt: raw.claimedAt,
};
};
export const getScenarioGeneralPoolCandidateWeight = (candidate: ScenarioGeneralPoolCandidate): number => {
const weight = candidate.weight ?? candidate.dex?.reduce((sum, value) => sum + value, 0) ?? 0;
// SPoolUnderU100 gives NPC/system draws (owner <= 0) a minimum weight so
// zero-dex growth candidates remain selectable. User selection calculates
// its distinct owner-aware weight in selectPoolService.
return candidate.sourceInfo.event100Growth === true ? Math.max(100_000, weight) : weight;
};
const pickUsingWeightPair = <T>(rng: RandomGenerator, values: Array<[T, number]>): T => {
let total = 0;
for (const [, weight] of values) {
if (weight > 0) {
total += weight;
}
}
let cursor = rng.nextFloat1() * total;
for (const [value, weight] of values) {
if (weight <= 0) {
if (cursor <= 0) {
return value;
}
continue;
}
if (cursor <= weight) {
return value;
}
cursor -= weight;
}
throw new Error('Unreachable weighted general-pool selection.');
};
/**
* Ref keeps the original weighted array while retrying duplicate pool IDs.
* A duplicate draw therefore consumes RNG instead of shrinking the weights.
*/
export const pickUniqueScenarioGeneralPoolCandidates = (
rng: RandomGenerator,
candidates: readonly ScenarioGeneralPoolCandidate[],
count: number
): ScenarioGeneralPoolCandidate[] => {
if (count <= 0) {
return [];
}
if (candidates.length < count) {
throw new Error('pool 부족');
}
const weighted = candidates.map(
(candidate) =>
[candidate, getScenarioGeneralPoolCandidateWeight(candidate)] as [ScenarioGeneralPoolCandidate, number]
);
const selectedIds = new Set<number>();
const selected: ScenarioGeneralPoolCandidate[] = [];
while (selected.length < count) {
const candidate = pickUsingWeightPair(rng, weighted);
if (selectedIds.has(candidate.poolEntryId)) {
continue;
}
selectedIds.add(candidate.poolEntryId);
selected.push(candidate);
}
return selected;
};
export type LegacyNpcStatType = '무' | '지' | '무지';
export const resolveLegacyNpcStatTypeFromFixedStats = (rng: RandomGenerator, stats: StatBlock): LegacyNpcStatType => {
if (stats.leadership < 40) {
return '무지';
}
if (stats.intelligence * 0.8 > stats.strength) {
return '지';
}
if (stats.strength * 0.8 > stats.intelligence) {
return '무';
}
return pickUsingWeightPair(rng, [
['무', stats.strength],
['지', stats.intelligence],
]);
};
@@ -33,18 +33,38 @@ import {
} from '@sammo-ts/logic/actions/turn/actionContextHelpers.js';
import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js';
import type { NationTurnCommandSpec } from './index.js';
import {
buildScenarioGeneralPoolClaimMeta,
pickUniqueScenarioGeneralPoolCandidates,
resolveLegacyNpcStatTypeFromFixedStats,
type ScenarioGeneralPoolCandidate,
} from '@sammo-ts/logic/actions/turn/generalPool.js';
import {
CENTENNIAL_ALL_STAR_NPC_PROGRESS_MULTIPLIER,
applyCentennialAllStarTarget,
initializeCentennialGeneratedNpc,
readCentennialAllStarPoolTarget,
resolveCentennialAllStarRules,
resolveCentennialNpcDexTargetRatio,
type CentennialAllStarRules,
} from '@sammo-ts/logic/scenario/centennialAllStar.js';
export interface VolunteerRecruitArgs {}
export interface VolunteerRecruitCandidate {
name: string;
poolEntryId?: number;
uniqueName?: string;
stats?: Partial<StatBlock>;
dex?: [number, number, number, number, number];
personality?: string | null;
affinity?: number | null;
specialDomestic?: string | null;
specialWar?: string | null;
picture?: number | string | null;
imageServer?: number;
text?: string | null;
sourceInfo?: Record<string, unknown>;
}
export interface VolunteerRecruitResolveContext<
@@ -53,13 +73,16 @@ export interface VolunteerRecruitResolveContext<
currentYear: number;
currentMonth: number;
startYear: number;
centennialRules: CentennialAllStarRules;
centennialNpcDexTargetRatio: number;
averageNationGeneralCount: number;
nationAverageStats?: StatBlock;
nationAverageExperience?: number;
nationAverageDedication?: number;
nationAverageDex?: [number, number, number, number, number];
friendlyGenerals: Array<General<TriggerState>>;
generalPool?: VolunteerRecruitCandidate[];
generalPool?: ScenarioGeneralPoolCandidate[];
existingGeneralNames?: string[];
createGeneralId: () => number;
turnTermSeconds: number;
turnTimeBase: Date;
@@ -112,6 +135,18 @@ const DEFAULT_SPEC_AGE = 19;
const DEFAULT_NPC_STAT_TOTAL = 150;
const DEFAULT_NPC_STAT_MIN = 10;
const DEFAULT_NPC_STAT_MAX = 75;
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 addMetaValue = (
meta: Record<string, TriggerValue>,
@@ -146,6 +181,43 @@ const legacyChoiceIndex = (rng: RandomGenerator, length: number): number => {
const legacyChoice = <T>(rng: RandomGenerator, values: readonly T[]): T =>
values[legacyChoiceIndex(rng, values.length)]!;
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 pickLegacyRandomNames = (
rng: RandomGenerator,
count: number,
existingNames: readonly string[],
firstNames: readonly string[],
middleNames: readonly string[],
lastNames: readonly string[]
): string[] =>
Array.from({ length: count }, () => {
let loopCount = 0;
while (true) {
let name = `${legacyChoice(rng, firstNames)}${legacyChoice(rng, middleNames)}${legacyChoice(rng, lastNames)}`;
const duplicateCount = countLegacyNameDuplicates(existingNames, name);
if (duplicateCount === 0) {
return name;
}
if (loopCount >= 99 || duplicateCount < 2) {
name += duplicateCount + 1;
return name;
}
loopCount += 1;
}
});
const pickByWeight = <T extends string>(rng: RandomGenerator, weights: Record<T, number>): T => {
const entries = Object.entries(weights) as Array<[T, number]>;
const first = entries[0];
@@ -208,12 +280,10 @@ const resolveCandidate = (
if (env.pickCandidate) {
return env.pickCandidate(context, rng);
}
const pool = context.generalPool ?? [];
if (pool.length === 0) {
if (context.generalPool === undefined) {
return null;
}
const idx = rng.nextInt(0, pool.length);
return pool[idx] ?? null;
return pickUniqueScenarioGeneralPoolCandidates(rng, context.generalPool, 1)[0] ?? null;
};
const resolveStats = (
@@ -357,44 +427,81 @@ export class ActionResolver<
const firstNames = this.env.randomGeneralFirstNames ?? ['가'];
const middleNames = this.env.randomGeneralMiddleNames ?? [''];
const lastNames = this.env.randomGeneralLastNames ?? ['가'];
const candidates = Array.from({ length: createCount }, () => {
const selected = resolveCandidate(context, context.rng, this.env);
if (selected) {
return selected;
}
return {
name: `${legacyChoice(context.rng, firstNames)}${legacyChoice(
const candidates: VolunteerRecruitCandidate[] = this.env.pickCandidate
? Array.from(
{ length: createCount },
() =>
resolveCandidate(context, context.rng, this.env) ?? {
name: pickLegacyRandomNames(
context.rng,
1,
context.existingGeneralNames ?? [],
firstNames,
middleNames,
lastNames
)[0]!,
}
)
: context.generalPool === undefined
? pickLegacyRandomNames(
context.rng,
middleNames
)}${legacyChoice(context.rng, lastNames)}`,
};
});
createCount,
context.existingGeneralNames ?? [],
firstNames,
middleNames,
lastNames
).map((name) => ({ name }))
: pickUniqueScenarioGeneralPoolCandidates(context.rng, context.generalPool, createCount);
for (const candidate of candidates) {
const centennialTarget =
candidate.sourceInfo && candidate.uniqueName
? readCentennialAllStarPoolTarget({
uniqueName: candidate.uniqueName,
name: candidate.name,
sourceInfo: candidate.sourceInfo,
})
: null;
const newGeneralId = context.createGeneralId();
const name = this.env.decorateName ? this.env.decorateName(candidate.name, NPC_TYPE) : `${candidate.name}`;
const birthYear = context.currentYear - baseAge;
const deathYear = context.currentYear + deathYears;
const killturn = randomRangeInt(context.rng, killTurnMin, killTurnMax);
const affinity = candidate.affinity ?? randomRangeInt(context.rng, 1, 150);
const generated = buildLegacyRandomStats(context.rng, this.env);
const stats = candidate.stats ? resolveStats(context, context.rng, this.env, candidate) : generated.stats;
let pickType: '무' | '지' | '무지';
let stats: StatBlock;
if (candidate.stats && !centennialTarget) {
stats = resolveStats(context, context.rng, this.env, candidate);
pickType = resolveLegacyNpcStatTypeFromFixedStats(context.rng, stats);
} else {
const generated = buildLegacyRandomStats(context.rng, this.env);
pickType = generated.pickType;
stats = generated.stats;
}
const averageDex = context.nationAverageDex ?? [0, 0, 0, 0, 0];
const dexTotal = averageDex[0] + averageDex[1] + averageDex[2] + averageDex[3];
const rawDex: [number, number, number, number] =
generated.pickType === '무'
? legacyChoice(context.rng, [
[(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],
])
: [dexTotal / 8, dexTotal / 8, dexTotal / 8, (dexTotal * 5) / 8];
const dex: [number, number, number, number] = [
Math.trunc(rawDex[0]),
Math.trunc(rawDex[1]),
Math.trunc(rawDex[2]),
Math.trunc(rawDex[3]),
];
let dex: [number, number, number, number, number];
if (candidate.dex?.[0] && !centennialTarget) {
dex = candidate.dex;
} else {
const rawDex: [number, number, number, number] =
pickType === '무'
? legacyChoice(context.rng, [
[(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],
])
: pickType === '지'
? [dexTotal / 8, dexTotal / 8, dexTotal / 8, (dexTotal * 5) / 8]
: [dexTotal / 4, dexTotal / 4, dexTotal / 4, dexTotal / 4];
dex = [
Math.trunc(rawDex[0]),
Math.trunc(rawDex[1]),
Math.trunc(rawDex[2]),
Math.trunc(rawDex[3]),
Math.trunc(averageDex[4]),
];
}
const personality =
candidate.personality ?? legacyChoice(context.rng, this.env.availablePersonalities ?? ['che_안전']);
const turnSecond = randomRangeInt(context.rng, 0, context.turnTermSeconds - 1);
@@ -416,9 +523,12 @@ export class ActionResolver<
dex2: dex[1],
dex3: dex[2],
dex4: dex[3],
dex5: Math.trunc(averageDex[4]),
dex5: dex[4],
turnSecond,
turnFraction,
...(candidate.poolEntryId !== undefined && candidate.uniqueName
? buildScenarioGeneralPoolClaimMeta(candidate as ScenarioGeneralPoolCandidate, context.turnTimeBase)
: {}),
};
addMetaValue(meta, 'affinity', affinity);
addMetaValue(meta, 'picture', candidate.picture ?? null);
@@ -428,7 +538,10 @@ export class ActionResolver<
addMetaValue(meta, 'specage2', DEFAULT_SPEC_AGE);
addMetaValue(meta, 'text', candidate.text ?? null);
const newGeneral = {
const averageExperience = Math.trunc(context.nationAverageExperience ?? 0);
const averageDedication = Math.trunc(context.nationAverageDedication ?? 0);
let newGeneral = {
...buildRecruitmentGeneral<TriggerState>({
id: newGeneralId,
name,
@@ -440,8 +553,10 @@ export class ActionResolver<
npcState: NPC_TYPE,
gold: this.env.defaultNpcGold,
rice: this.env.defaultNpcRice,
experience: Math.trunc(context.nationAverageExperience ?? 0),
dedication: Math.trunc(context.nationAverageDedication ?? 0),
// GeneralBuilder::build() uses PHP's falsy `?: age * 100`
// after setExpDed(), including when a nation's averages are 0.
experience: averageExperience || baseAge * 100,
dedication: averageDedication || baseAge * 100,
crewTypeId: this.env.defaultCrewTypeId,
role: {
personality,
@@ -454,7 +569,29 @@ export class ActionResolver<
...(turnTick === undefined ? {} : { turnTick }),
bornYear: birthYear,
deadYear: deathYear,
imageServer: candidate.imageServer ?? 0,
picture: candidate.picture ?? 'default.jpg',
};
if (centennialTarget) {
const initialized = initializeCentennialGeneratedNpc(
newGeneral,
centennialTarget,
context.centennialRules
);
const growth = applyCentennialAllStarTarget(
{ ...newGeneral, ...initialized },
centennialTarget,
{
startYear: context.startYear,
year: context.currentYear,
month: context.currentMonth,
},
context.centennialRules,
CENTENNIAL_ALL_STAR_NPC_PROGRESS_MULTIPLIER,
context.centennialNpcDexTargetRatio
);
newGeneral = { ...newGeneral, stats: growth.stats, role: growth.role, meta: growth.meta };
}
effects.push(createGeneralAddEffect(newGeneral));
}
@@ -521,12 +658,20 @@ export const actionContextBuilder: ActionContextBuilder = (base, options) => {
currentYear: options.world.currentYear,
currentMonth: options.world.currentMonth,
startYear: resolveStartYear(options.world, options.scenarioMeta),
centennialRules: resolveCentennialAllStarRules(options.scenarioConfig),
centennialNpcDexTargetRatio: resolveCentennialNpcDexTargetRatio(options.scenarioConfig),
averageNationGeneralCount: buildAverageNationGeneralCount(options.worldRef),
nationAverageStats: nationSummary.averageStats,
nationAverageExperience: nationSummary.averageExperience,
nationAverageDedication: nationSummary.averageDedication,
nationAverageDex: nationSummary.averageDex,
friendlyGenerals,
existingGeneralNames: options.worldRef?.listGenerals().map(restoreLegacyStoredName) ?? [],
...(() => {
const claimedAt = options.world.lastTurnTime ?? base.general.turnTime;
const generalPool = options.worldRef?.listGeneralPoolCandidates?.(claimedAt);
return generalPool === undefined ? {} : { generalPool };
})(),
createGeneralId: options.createGeneralId,
turnTermSeconds: Math.max(1, Math.round(options.world.tickSeconds)),
turnTimeBase: options.world.lastTurnTime ?? base.general.turnTime,
@@ -286,6 +286,7 @@ const resolveCityRiceConsumption = (options: {
castleCrewTypeId: number;
year: number;
startYear: number;
maxTechLevel?: number;
}): number => {
const cityReport = options.battle.reports.find((report: WarUnitReport) => report.type === 'city');
if (!cityReport) {
@@ -304,7 +305,7 @@ const resolveCityRiceConsumption = (options: {
let rice = (cityReport.killed / 100) * 0.8;
rice *= riceCoef;
rice *= getTechCost(tech);
rice *= getTechCost(tech, options.maxTechLevel);
rice *= trainAtmos / 100 - 0.2;
return Math.round(rice);
};
@@ -454,6 +455,7 @@ export const processBattleSimJob = (
castleCrewTypeId: payload.config.castleCrewTypeId,
year: payload.time.year,
startYear: payload.time.startYear,
...(payload.config.maxTechLevel === undefined ? {} : { maxTechLevel: payload.config.maxTechLevel }),
});
defenderAvgRice += (defenderRiceInit - defenderRiceAfter + cityRice) * weight;
@@ -21,16 +21,16 @@ export const itemModule: ItemModule = {
reqSecu: 0,
unique: false,
onCalcStat: function (
_context: GeneralActionContext | WarActionContext,
context: GeneralActionContext | WarActionContext,
statName: GeneralStatName | WarStatName,
value: unknown,
aux?: unknown
): unknown {
if (statName === 'strength') {
const auxObj = aux as Record<string, unknown> | undefined;
const year = resolveNumber(auxObj?.['year']);
const startYear = resolveNumber(auxObj?.['startYear']);
const maxTechLevel = resolveNumber(auxObj?.['maxTechLevel'], 12);
const year = resolveNumber(context.time?.year, resolveNumber(auxObj?.['year']));
const startYear = resolveNumber(context.time?.startYear, resolveNumber(auxObj?.['startYear']));
const maxTechLevel = resolveNumber(context.maxTechLevel, resolveNumber(auxObj?.['maxTechLevel'], 12));
const relYear = Math.max(0, year - startYear);
const bonus = 5 + clamp(Math.floor(relYear / 4), 0, maxTechLevel);
@@ -21,16 +21,16 @@ export const itemModule: ItemModule = {
reqSecu: 0,
unique: false,
onCalcStat: function (
_context: GeneralActionContext | WarActionContext,
context: GeneralActionContext | WarActionContext,
statName: GeneralStatName | WarStatName,
value: unknown,
aux?: unknown
): unknown {
if (statName === 'intelligence') {
const auxObj = aux as Record<string, unknown> | undefined;
const year = resolveNumber(auxObj?.['year']);
const startYear = resolveNumber(auxObj?.['startYear']);
const maxTechLevel = resolveNumber(auxObj?.['maxTechLevel'], 12);
const year = resolveNumber(context.time?.year, resolveNumber(auxObj?.['year']));
const startYear = resolveNumber(context.time?.startYear, resolveNumber(auxObj?.['startYear']));
const maxTechLevel = resolveNumber(context.maxTechLevel, resolveNumber(auxObj?.['maxTechLevel'], 12));
const relYear = Math.max(0, year - startYear);
const bonus = 5 + clamp(Math.floor(relYear / 4), 0, maxTechLevel);
@@ -21,16 +21,16 @@ export const itemModule: ItemModule = {
reqSecu: 0,
unique: false,
onCalcStat: function (
_context: GeneralActionContext | WarActionContext,
context: GeneralActionContext | WarActionContext,
statName: GeneralStatName | WarStatName,
value: unknown,
aux?: unknown
): unknown {
if (statName === 'leadership') {
const auxObj = aux as Record<string, unknown> | undefined;
const year = resolveNumber(auxObj?.['year']);
const startYear = resolveNumber(auxObj?.['startYear']);
const maxTechLevel = resolveNumber(auxObj?.['maxTechLevel'], 12);
const year = resolveNumber(context.time?.year, resolveNumber(auxObj?.['year']));
const startYear = resolveNumber(context.time?.startYear, resolveNumber(auxObj?.['startYear']));
const maxTechLevel = resolveNumber(context.maxTechLevel, resolveNumber(auxObj?.['maxTechLevel'], 12));
const relYear = Math.max(0, year - startYear);
const bonus = 5 + clamp(Math.floor(relYear / 4), 0, maxTechLevel);
@@ -0,0 +1,672 @@
import { asNumber, asRecord } from '@sammo-ts/common';
import type { General, GeneralMeta, GeneralRole, StatBlock } from '../domain/entities.js';
import { LEGACY_DEFAULT_MAX_LEVEL } from './constants.js';
export const CENTENNIAL_ALL_STAR_POOL = 'SPoolUnderU100';
export const CENTENNIAL_ALL_STAR_AUX_KEY = 'event100_allstar';
export const CENTENNIAL_ALL_STAR_TRAIT_UNLOCK_PROGRESS = 0.4;
export const CENTENNIAL_ALL_STAR_NPC_PROGRESS_MULTIPLIER = 0.9;
export const CENTENNIAL_ALL_STAR_DEFAULT_GROWTH_YEARS = 15;
export const CENTENNIAL_ALL_STAR_DEFAULT_DEX_LIMIT = 1_000_000;
export const isCentennialAllStarActive = (scenarioConfig: unknown): boolean =>
asRecord(asRecord(scenarioConfig).map).targetGeneralPool === CENTENNIAL_ALL_STAR_POOL;
export const isCentennialStatResetAllowed = (scenarioConfig: unknown): boolean =>
!isCentennialAllStarActive(scenarioConfig);
const STAT_KEYS = ['leadership', 'strength', 'intel'] as const;
const DEX_KEYS = ['dex1', 'dex2', 'dex3', 'dex4', 'dex5'] as const;
type CentennialStatKey = (typeof STAT_KEYS)[number];
type CentennialDexKey = (typeof DEX_KEYS)[number];
export interface CentennialAllStarTarget {
uniqueName: string;
generalName?: string;
leadership: number;
strength: number;
intel: number;
dex: readonly [number, number, number, number, number];
specialDomestic?: string | null;
[key: string]: unknown;
}
export interface CentennialAllStarPoolCandidate {
uniqueName: string;
name: string;
sourceInfo: Record<string, unknown>;
}
export interface CentennialAllStarEnvironment {
startYear: number;
year: number;
month: number;
}
export interface CentennialAllStarRules {
defaultStatMin: number;
defaultStatMax: number;
defaultStatTotal: number;
maxStatLevel: number;
defaultSpecialDomestic: string | null;
dexLimit?: number;
}
export interface CentennialAllStarScenarioConfig {
stat: {
min: number;
max: number;
total: number;
};
const: Record<string, unknown>;
map: Record<string, unknown>;
}
export interface CentennialAllStarAux {
targetId: string;
target: CentennialAllStarTarget;
granted: Record<CentennialStatKey | CentennialDexKey, number>;
dexConsumed: Record<CentennialDexKey, number>;
dexFloor: Record<CentennialDexKey, number>;
progressMonth: number;
milestone: number;
naturalSpecialDomestic: string | null;
eventSpecialDomestic: string | null;
userInitialStats: Record<CentennialStatKey, number> | null;
dexTargetRatio: number;
}
export interface CentennialAllStarApplyResult {
stats: StatBlock;
role: GeneralRole;
meta: GeneralMeta;
progress: number;
milestone: number;
previousMilestone: number;
targetChanged: boolean;
changed: boolean;
}
export const resolveCentennialAllStarRules = (
config: CentennialAllStarScenarioConfig,
fallbackSpecialDomestic: string | null = null
): CentennialAllStarRules => ({
defaultStatMin: config.stat.min,
defaultStatMax: config.stat.max,
defaultStatTotal: config.stat.total,
maxStatLevel: asNumber(config.const.maxLevel, LEGACY_DEFAULT_MAX_LEVEL),
defaultSpecialDomestic:
typeof config.const.defaultSpecialDomestic === 'string'
? config.const.defaultSpecialDomestic
: fallbackSpecialDomestic,
dexLimit: asNumber(config.const.dexLimit, CENTENNIAL_ALL_STAR_DEFAULT_DEX_LIMIT),
});
export const resolveCentennialNpcDexTargetRatio = (config: CentennialAllStarScenarioConfig): number => {
const ratio = asNumber(config.map.centennialNpcDexTargetRatio, 0.4);
if (ratio < 0 || ratio > 1) {
throw new Error('centennialNpcDexTargetRatio must be between 0 and 1');
}
return ratio;
};
const emptyGranted = (): CentennialAllStarAux['granted'] => ({
leadership: 0,
strength: 0,
intel: 0,
dex1: 0,
dex2: 0,
dex3: 0,
dex4: 0,
dex5: 0,
});
const emptyDex = (): Record<CentennialDexKey, number> => ({
dex1: 0,
dex2: 0,
dex3: 0,
dex4: 0,
dex5: 0,
});
const readFiniteNumber = (source: Record<string, unknown>, key: string, fallback = 0): number => {
const value = source[key];
if (typeof value === 'number' && Number.isFinite(value)) {
return value;
}
if (typeof value === 'string' && value.trim() !== '') {
const parsed = Number(value);
if (Number.isFinite(parsed)) {
return parsed;
}
}
return fallback;
};
const readIntegerRecord = <Key extends string>(raw: unknown, keys: readonly Key[]): Record<Key, number> => {
const source = asRecord(raw);
return Object.fromEntries(
keys.map((key) => [key, Math.max(0, Math.trunc(readFiniteNumber(source, key)))])
) as Record<Key, number>;
};
const normalizeTarget = (raw: unknown, fallback?: CentennialAllStarTarget): CentennialAllStarTarget => {
const source = asRecord(raw);
const dex = Array.isArray(source.dex) ? source.dex : fallback?.dex;
const uniqueName = typeof source.uniqueName === 'string' ? source.uniqueName : fallback?.uniqueName;
if (!uniqueName || !dex || dex.length !== DEX_KEYS.length) {
if (fallback) {
return fallback;
}
throw new Error('100기 올스타 목표 정보가 올바르지 않습니다.');
}
const normalizedDex = dex.map((value) =>
typeof value === 'number' && Number.isFinite(value) ? Math.trunc(value) : Number.NaN
);
if (normalizedDex.some((value) => !Number.isInteger(value) || value < 0)) {
throw new Error(`100기 올스타 숙련 목표가 올바르지 않습니다: ${uniqueName}`);
}
const readTargetStat = (key: CentennialStatKey): number => {
const value = source[key] ?? fallback?.[key];
if (typeof value !== 'number' || !Number.isFinite(value)) {
throw new Error(`100기 올스타 능력 목표가 올바르지 않습니다: ${uniqueName}`);
}
return Math.trunc(value);
};
return {
...(fallback ?? {}),
...source,
uniqueName,
leadership: readTargetStat('leadership'),
strength: readTargetStat('strength'),
intel: readTargetStat('intel'),
dex: normalizedDex as [number, number, number, number, number],
specialDomestic:
typeof source.specialDomestic === 'string' || source.specialDomestic === null
? source.specialDomestic
: (fallback?.specialDomestic ?? null),
};
};
export const readCentennialAllStarPoolTarget = (
candidate: CentennialAllStarPoolCandidate | null | undefined
): CentennialAllStarTarget | null => {
if (!candidate || candidate.sourceInfo.event100Growth !== true) {
return null;
}
return normalizeTarget({
...candidate.sourceInfo,
uniqueName: candidate.uniqueName,
generalName: candidate.name,
});
};
export const initialCentennialAllStarAux = (
target: CentennialAllStarTarget,
rules: Pick<CentennialAllStarRules, 'defaultStatMin'>,
userInitialStats: Record<CentennialStatKey, number> | null = null
): CentennialAllStarAux => {
const granted = emptyGranted();
if (userInitialStats) {
for (const key of STAT_KEYS) {
const initial = userInitialStats[key] ?? rules.defaultStatMin;
granted[key] = Math.max(0, initial - Math.min(initial, rules.defaultStatMin));
}
}
return {
targetId: target.uniqueName,
target,
granted,
dexConsumed: emptyDex(),
dexFloor: emptyDex(),
progressMonth: -1,
milestone: 0,
naturalSpecialDomestic: null,
eventSpecialDomestic: null,
userInitialStats,
dexTargetRatio: 1,
};
};
export const readCentennialAllStarAux = (
meta: Record<string, unknown>,
fallbackTarget?: CentennialAllStarTarget
): CentennialAllStarAux | null => {
const source = asRecord(meta[CENTENNIAL_ALL_STAR_AUX_KEY]);
if (Object.keys(source).length === 0 && !fallbackTarget) {
return null;
}
const target = normalizeTarget(source.target, fallbackTarget);
const rawInitial = asRecord(source.userInitialStats);
const userInitialStats = Object.keys(rawInitial).length
? (Object.fromEntries(STAT_KEYS.map((key) => [key, Math.trunc(readFiniteNumber(rawInitial, key))])) as Record<
CentennialStatKey,
number
>)
: null;
return {
targetId: typeof source.targetId === 'string' ? source.targetId : target.uniqueName,
target,
granted: readIntegerRecord(source.granted, [...STAT_KEYS, ...DEX_KEYS]),
dexConsumed: readIntegerRecord(source.dexConsumed, DEX_KEYS),
dexFloor: readIntegerRecord(source.dexFloor, DEX_KEYS),
progressMonth: Math.trunc(readFiniteNumber(source, 'progressMonth', -1)),
milestone: Math.trunc(readFiniteNumber(source, 'milestone')),
naturalSpecialDomestic:
typeof source.naturalSpecialDomestic === 'string' ? source.naturalSpecialDomestic : null,
eventSpecialDomestic: typeof source.eventSpecialDomestic === 'string' ? source.eventSpecialDomestic : null,
userInitialStats,
dexTargetRatio: readFiniteNumber(source, 'dexTargetRatio', 1),
};
};
export const calculateCentennialUserInitialStats = (
target: CentennialAllStarTarget,
rules: Pick<CentennialAllStarRules, 'defaultStatMin' | 'defaultStatMax' | 'defaultStatTotal'>
): Record<CentennialStatKey, number> => {
const targets = {} as Record<CentennialStatKey, number>;
const bases = {} as Record<CentennialStatKey, number>;
for (const key of STAT_KEYS) {
const value = Math.min(rules.defaultStatMax, Math.max(0, Math.trunc(target[key])));
targets[key] = value;
bases[key] = Math.min(value, rules.defaultStatMin);
}
const targetTotal = STAT_KEYS.reduce((sum, key) => sum + targets[key], 0);
const desiredTotal = Math.min(rules.defaultStatTotal, targetTotal);
const baseTotal = STAT_KEYS.reduce((sum, key) => sum + bases[key], 0);
const capacityTotal = targetTotal - baseTotal;
if (capacityTotal <= 0 || desiredTotal <= baseTotal) {
return bases;
}
const ratio = (desiredTotal - baseTotal) / capacityTotal;
const result = {} as Record<CentennialStatKey, number>;
const fractions = STAT_KEYS.map((key, order) => {
const raw = bases[key] + (targets[key] - bases[key]) * ratio;
result[key] = Math.floor(raw);
return { key, fraction: raw - result[key], order };
}).sort((left, right) => right.fraction - left.fraction || left.order - right.order);
let remainder = desiredTotal - STAT_KEYS.reduce((sum, key) => sum + result[key], 0);
for (const { key } of fractions) {
if (remainder <= 0) {
break;
}
if (result[key] >= targets[key]) {
continue;
}
result[key] += 1;
remainder -= 1;
}
return result;
};
export const calculateCentennialProgress = (
environment: CentennialAllStarEnvironment,
progressMultiplier = 1,
growthYears = CENTENNIAL_ALL_STAR_DEFAULT_GROWTH_YEARS
): number => {
if (progressMultiplier < 0 || progressMultiplier > 1) {
throw new Error('progress multiplier must be between 0 and 1');
}
if (growthYears <= 0) {
throw new Error('growthYears must be positive');
}
if (environment.month < 1 || environment.month > 12) {
throw new Error('month must be between 1 and 12');
}
const elapsedMonths = Math.max(0, (environment.year - environment.startYear) * 12 + environment.month - 1);
// Ref caps the common calendar progress first and applies the NPC
// multiplier afterwards. Generated M/G generals therefore remain at a
// permanent 90% stat target even after the fifteenth year.
return Math.min(1, elapsedMonths / (growthYears * 12)) * progressMultiplier;
};
export const centennialStatFloor = (target: number, minimum: number, progress: number): number => {
const normalizedProgress = Math.max(0, Math.min(1, progress));
if (target <= minimum) {
return target;
}
return Math.min(target, Math.floor(minimum + (target - minimum) * normalizedProgress));
};
export const centennialDexFloor = (target: number, progress: number): number => {
const normalizedProgress = Math.max(0, Math.min(1, progress));
return Math.min(target, Math.floor(target * normalizedProgress * normalizedProgress));
};
export const calculateCentennialDexTargetFloor = (
target: number,
environment: CentennialAllStarEnvironment,
targetRatio = 1,
dexLimit = CENTENNIAL_ALL_STAR_DEFAULT_DEX_LIMIT
): number => {
if (targetRatio < 0 || targetRatio > 1) {
throw new Error('dex target ratio must be between 0 and 1');
}
const capped = Math.min(dexLimit, Math.max(0, Math.trunc(target)));
const scaled = Math.floor(capped * targetRatio);
// Ref는 M/G장의 0.9 배율을 능력치에만 적용하고 숙련 진행률은 공통값을 쓴다.
return centennialDexFloor(scaled, calculateCentennialProgress(environment));
};
const advance = (current: number, granted: number, floor: number): { value: number; granted: number } => {
const delta = Math.max(0, floor - current);
return { value: current + delta, granted: Math.max(0, granted) + delta };
};
const replaceTarget = (current: number, oldGranted: number, newFloor: number): { value: number; granted: number } => {
const organic = Math.max(0, current - Math.max(0, oldGranted));
const value = Math.max(organic, newFloor);
return { value, granted: value - organic };
};
export const calculateCentennialUserCurrentTargetStats = (
target: CentennialAllStarTarget,
environment: CentennialAllStarEnvironment,
rules: CentennialAllStarRules
): Record<CentennialStatKey, number> => {
const initial = calculateCentennialUserInitialStats(target, rules);
const progress = calculateCentennialProgress(environment);
return Object.fromEntries(
STAT_KEYS.map((key) => [
key,
Math.max(
initial[key],
centennialStatFloor(
Math.min(rules.maxStatLevel, Math.max(0, Math.trunc(target[key]))),
rules.defaultStatMin,
progress
)
),
])
) as Record<CentennialStatKey, number>;
};
export const calculateCentennialLegacyUserGrant = (
current: number,
eventGrant: number,
rules: Pick<CentennialAllStarRules, 'defaultStatMin' | 'defaultStatMax'>
): number => {
const normalizedEventGrant = Math.max(0, Math.trunc(eventGrant));
const beforeEventGrant = Math.max(0, Math.trunc(current) - normalizedEventGrant);
const replaceableInitialGrant = Math.max(
0,
Math.min(beforeEventGrant, rules.defaultStatMax) - Math.min(beforeEventGrant, rules.defaultStatMin)
);
return normalizedEventGrant + replaceableInitialGrant;
};
/**
* Ref's first S100 deployment did not persist userInitialStats. Before the
* first reselection it treats the ordinary creation-range portion as an event
* grant, so changing targets cannot preserve those points as organic growth.
*/
export const prepareCentennialLegacyUserReselection = (
general: Pick<General, 'stats' | 'meta'>,
rules: Pick<CentennialAllStarRules, 'defaultStatMin' | 'defaultStatMax'>
): GeneralMeta => {
const rawAuxValue = (general.meta as Record<string, unknown>)[CENTENNIAL_ALL_STAR_AUX_KEY];
if (!rawAuxValue || typeof rawAuxValue !== 'object' || Array.isArray(rawAuxValue)) {
return general.meta;
}
const rawAux = asRecord(rawAuxValue);
const rawInitial = rawAux.userInitialStats;
if (rawInitial && typeof rawInitial === 'object' && !Array.isArray(rawInitial)) {
return general.meta;
}
const rawGranted = asRecord(rawAux.granted);
const granted = readIntegerRecord(rawGranted, [...STAT_KEYS, ...DEX_KEYS]);
const currentStats: Record<CentennialStatKey, number> = {
leadership: general.stats.leadership,
strength: general.stats.strength,
intel: general.stats.intelligence,
};
const legacyInitialStats = {} as Record<CentennialStatKey, number>;
for (const key of STAT_KEYS) {
const current = Math.trunc(currentStats[key]);
const oldEventGrant = Math.max(0, Math.trunc(readFiniteNumber(rawGranted, key)));
granted[key] = calculateCentennialLegacyUserGrant(current, granted[key], rules);
const beforeEventGrant = Math.max(0, current - oldEventGrant);
legacyInitialStats[key] = Math.min(beforeEventGrant, rules.defaultStatMax);
}
const meta: GeneralMeta = { ...general.meta };
const mutableMeta: Record<string, unknown> = meta;
mutableMeta[CENTENNIAL_ALL_STAR_AUX_KEY] = {
...rawAux,
granted,
userInitialStats: legacyInitialStats,
};
return meta;
};
export const applyCentennialAllStarTarget = (
general: Pick<General, 'stats' | 'role' | 'meta'>,
target: CentennialAllStarTarget,
environment: CentennialAllStarEnvironment,
rules: CentennialAllStarRules,
progressMultiplier = 1,
dexTargetRatio = 1
): CentennialAllStarApplyResult => {
const progress = calculateCentennialProgress(environment, progressMultiplier);
const progressMonth = Math.floor(
Math.max(0, (environment.year - environment.startYear) * 12 + environment.month - 1) * progressMultiplier
);
const previousAux = readCentennialAllStarAux(general.meta as Record<string, unknown>, target);
const aux = previousAux ?? initialCentennialAllStarAux(target, rules);
const targetChanged = aux.targetId !== target.uniqueName;
const granted = { ...aux.granted };
const dexConsumed = targetChanged ? emptyDex() : { ...aux.dexConsumed };
const dexFloor = { ...aux.dexFloor };
const isUserTarget = aux.userInitialStats !== null;
const nextUserInitialStats =
targetChanged && isUserTarget ? calculateCentennialUserInitialStats(target, rules) : aux.userInitialStats;
const dexTargetRatioChanged = aux.dexTargetRatio !== dexTargetRatio;
const userCurrentTargetStats = isUserTarget
? calculateCentennialUserCurrentTargetStats(target, environment, rules)
: null;
const stats = { ...general.stats };
const role = { ...general.role, items: { ...general.role.items } };
const meta: GeneralMeta = { ...general.meta };
const mutableMeta: Record<string, unknown> = meta;
let changed = dexTargetRatioChanged;
const statProperty: Record<CentennialStatKey, keyof StatBlock> = {
leadership: 'leadership',
strength: 'strength',
intel: 'intelligence',
};
for (const key of STAT_KEYS) {
const targetValue = Math.min(rules.maxStatLevel, Math.max(0, Math.trunc(target[key])));
const floor = userCurrentTargetStats?.[key] ?? centennialStatFloor(targetValue, rules.defaultStatMin, progress);
const property = statProperty[key];
const current = stats[property];
const result = targetChanged
? replaceTarget(current, granted[key], floor)
: advance(current, granted[key], floor);
if (result.value !== current) {
stats[property] = result.value;
changed = true;
}
granted[key] = result.granted;
}
for (const [index, key] of DEX_KEYS.entries()) {
const floor = Math.max(
0,
calculateCentennialDexTargetFloor(
target.dex[index]!,
environment,
dexTargetRatio,
rules.dexLimit ?? CENTENNIAL_ALL_STAR_DEFAULT_DEX_LIMIT
) - dexConsumed[key]
);
dexFloor[key] = floor;
const current = Math.trunc(readFiniteNumber(mutableMeta, key));
const result =
targetChanged || dexTargetRatioChanged
? replaceTarget(current, granted[key], floor)
: advance(current, granted[key], floor);
if (result.value !== current) {
mutableMeta[key] = result.value;
changed = true;
}
granted[key] = result.granted;
}
let naturalSpecialDomestic = aux.naturalSpecialDomestic;
let eventSpecialDomestic = aux.eventSpecialDomestic;
if (targetChanged && eventSpecialDomestic !== null && role.specialDomestic === eventSpecialDomestic) {
role.specialDomestic = naturalSpecialDomestic ?? rules.defaultSpecialDomestic;
eventSpecialDomestic = null;
changed = true;
}
const targetSpecial = target.specialDomestic;
if (
progress >= CENTENNIAL_ALL_STAR_TRAIT_UNLOCK_PROGRESS &&
typeof targetSpecial === 'string' &&
targetSpecial !== ''
) {
if (naturalSpecialDomestic === null) {
naturalSpecialDomestic = role.specialDomestic;
}
if (role.specialDomestic !== targetSpecial) {
role.specialDomestic = targetSpecial;
changed = true;
}
eventSpecialDomestic = targetSpecial;
}
const previousMilestone = aux.milestone;
const milestone = Math.min(5, Math.floor(progress * 5 + 0.0000001));
const nextAux: CentennialAllStarAux = {
...aux,
targetId: target.uniqueName,
target,
granted,
dexConsumed,
dexFloor,
progressMonth: Math.max(aux.progressMonth, progressMonth),
milestone: Math.max(previousMilestone, milestone),
naturalSpecialDomestic,
eventSpecialDomestic,
userInitialStats: nextUserInitialStats,
dexTargetRatio,
};
mutableMeta[CENTENNIAL_ALL_STAR_AUX_KEY] = nextAux;
return {
stats,
role,
meta,
progress,
milestone,
previousMilestone,
targetChanged,
changed: changed || targetChanged || milestone > previousMilestone,
};
};
export const calculateCentennialGeneratedNpcInitialStats = (
target: CentennialAllStarTarget,
generated: StatBlock
): StatBlock => {
const keyOrder = Object.fromEntries(STAT_KEYS.map((key, index) => [key, index])) as Record<
CentennialStatKey,
number
>;
const targetOrder = [...STAT_KEYS].sort(
(left, right) => target[right] - target[left] || keyOrder[left] - keyOrder[right]
);
const generatedValues = [generated.leadership, generated.strength, generated.intelligence].sort(
(left, right) => right - left
);
const values = Object.fromEntries(targetOrder.map((key, index) => [key, generatedValues[index]!])) as Record<
CentennialStatKey,
number
>;
return {
leadership: values.leadership,
strength: values.strength,
intelligence: values.intel,
};
};
export const initializeCentennialGeneratedNpc = (
general: Pick<General, 'stats' | 'role' | 'meta'>,
target: CentennialAllStarTarget,
rules: CentennialAllStarRules
): Pick<CentennialAllStarApplyResult, 'stats' | 'role' | 'meta'> => {
const meta: GeneralMeta = { ...general.meta };
const mutableMeta: Record<string, unknown> = meta;
Object.assign(mutableMeta, {
dex1: 0,
dex2: 0,
dex3: 0,
dex4: 0,
dex5: 0,
[CENTENNIAL_ALL_STAR_AUX_KEY]: initialCentennialAllStarAux(target, rules),
});
return {
stats: calculateCentennialGeneratedNpcInitialStats(target, general.stats),
role: { ...general.role, items: { ...general.role.items } },
meta,
};
};
export const reconcileCentennialDexConversion = (
metaInput: GeneralMeta,
sourceKey: CentennialDexKey,
destinationKey: CentennialDexKey,
sourceBefore: number,
sourceAfter: number,
destinationBefore: number,
destinationAfter: number,
convertCoefficient: number
): GeneralMeta => {
if (!DEX_KEYS.includes(sourceKey) || !DEX_KEYS.includes(destinationKey) || sourceKey === destinationKey) {
throw new Error('invalid dex conversion keys');
}
if (convertCoefficient < 0 || convertCoefficient > 1) {
throw new Error('dex conversion coefficient must be between 0 and 1');
}
const sourceDecrease = Math.max(0, sourceBefore - sourceAfter);
const destinationIncrease = Math.max(0, destinationAfter - destinationBefore);
if (sourceDecrease === 0 && destinationIncrease === 0) {
return metaInput;
}
const aux = readCentennialAllStarAux(metaInput as Record<string, unknown>);
if (!aux) {
return metaInput;
}
const granted = { ...aux.granted };
const dexConsumed = { ...aux.dexConsumed };
const sourceGrantedBefore = Math.min(Math.max(0, sourceBefore), Math.max(0, granted[sourceKey]));
const eventGrantRemoved = sourceBefore > 0 ? Math.trunc((sourceDecrease * sourceGrantedBefore) / sourceBefore) : 0;
const sourceGrantedAfter = Math.max(0, sourceGrantedBefore - eventGrantRemoved);
const destinationGrantedBefore = Math.min(Math.max(0, destinationBefore), Math.max(0, granted[destinationKey]));
let eventGrantTransferred =
sourceBefore > 0 ? Math.trunc((destinationIncrease * sourceGrantedBefore) / sourceBefore) : 0;
eventGrantTransferred = Math.min(destinationIncrease, eventGrantRemoved, eventGrantTransferred);
granted[sourceKey] = sourceGrantedAfter;
granted[destinationKey] = Math.min(Math.max(0, destinationAfter), destinationGrantedBefore + eventGrantTransferred);
const sourceFloor = Math.max(0, aux.dexFloor[sourceKey] ?? sourceBefore);
const gapBefore = Math.max(0, sourceFloor - sourceBefore);
const gapAfter = Math.max(0, sourceFloor - sourceAfter);
dexConsumed[sourceKey] = Math.max(0, dexConsumed[sourceKey] + Math.max(0, gapAfter - gapBefore));
const meta: GeneralMeta = { ...metaInput };
const mutableMeta: Record<string, unknown> = meta;
mutableMeta[CENTENNIAL_ALL_STAR_AUX_KEY] = {
...aux,
granted,
dexConsumed,
};
return meta;
};
export const centennialRecordableValue = (current: number, granted: number): number =>
Math.max(0, current - Math.max(0, granted));
+1
View File
@@ -2,3 +2,4 @@ export * from './types.js';
export * from './parseScenario.js';
export * from './scenarioEffect.js';
export * from './constants.js';
export * from './centennialAllStar.js';
+1
View File
@@ -43,6 +43,7 @@ export interface GeneralActionContext<TriggerState extends GeneralTriggerState =
month: number;
startYear: number;
};
maxTechLevel?: number;
}
export interface GeneralTriggerContext<
+3
View File
@@ -4,6 +4,7 @@ import type { City, General, GeneralTriggerState, Nation } from '@sammo-ts/logic
import type { ActionLogger } from '@sammo-ts/logic/logging/actionLogger.js';
import type { WarStatName } from '@sammo-ts/logic/actionModules/types.js';
import type { WarUnit } from './units.js';
import type { WarTimeContext } from './types.js';
import { WarTriggerCaller } from './triggers.js';
export interface WarActionContext<TriggerState extends GeneralTriggerState = GeneralTriggerState> {
@@ -13,6 +14,8 @@ export interface WarActionContext<TriggerState extends GeneralTriggerState = Gen
log?: ActionLogger;
rng?: RandUtil;
unit?: WarUnit<TriggerState>;
time?: WarTimeContext;
maxTechLevel?: number;
}
export interface WarActionModule<TriggerState extends GeneralTriggerState = GeneralTriggerState> {
+1 -1
View File
@@ -520,7 +520,7 @@ export const resolveWarAftermath = <TriggerState extends GeneralTriggerState = G
let rice = (cityKilled / 100) * 0.8;
rice *= riceCoef;
rice *= getTechCost(getMetaNumber(defenderNation.meta, 'tech', 0));
rice *= getTechCost(getMetaNumber(defenderNation.meta, 'tech', 0), input.config.maxTechLevel);
rice *= resolveCityTrainAtmos(input.time.year, input.time.startYear) / 100 - 0.2;
rice = round(rice);
+8 -4
View File
@@ -278,7 +278,8 @@ export const resolveWarBattle = <TriggerState extends GeneralTriggerState = Gene
true,
resolveCrewType(crewTypeIndex, input.attacker.general.crewTypeId),
attackerLogger,
attackerPipeline
attackerPipeline,
input.time
);
const cityLogger = loggerFactory({
@@ -313,7 +314,8 @@ export const resolveWarBattle = <TriggerState extends GeneralTriggerState = Gene
false,
resolveCrewType(crewTypeIndex, defender.general.crewTypeId),
defenderLogger,
createPipeline(defender, crewTypeCatalog.warActionModule)
createPipeline(defender, crewTypeCatalog.warActionModule),
input.time
);
if (computeBattleOrder(unit, attackerUnit) <= 0) {
continue;
@@ -750,7 +752,8 @@ export const resolveDefenderOrder = <TriggerState extends GeneralTriggerState =
true,
resolveCrewType(crewTypeIndex, input.attacker.general.crewTypeId),
attackerLogger,
attackerPipeline
attackerPipeline,
input.time
);
const defenderUnits: WarUnitGeneral<TriggerState>[] = [];
@@ -770,7 +773,8 @@ export const resolveDefenderOrder = <TriggerState extends GeneralTriggerState =
false,
resolveCrewType(crewTypeIndex, defender.general.crewTypeId),
defenderLogger,
createPipeline(defender, crewTypeCatalog.warActionModule)
createPipeline(defender, crewTypeCatalog.warActionModule),
input.time
);
if (computeBattleOrder(unit, attackerUnit) <= 0) {
continue;
+1
View File
@@ -27,6 +27,7 @@ export interface WarEngineConfig {
maxAtmosByWar: number;
maxGeneralStat?: number;
statUpgradeLimit?: number;
maxTechLevel?: number;
castleCrewTypeId: number;
armTypes: WarArmTypes;
}
+8 -5
View File
@@ -14,7 +14,7 @@ import type { WarStatName } from '@sammo-ts/logic/actionModules/types.js';
import { getTechAbility, getTechCost } from '@sammo-ts/logic/world/unitSet.js';
import { LEGACY_DEFAULT_MAX_LEVEL } from '@sammo-ts/logic/scenario/constants.js';
import type { WarActionPipeline, WarActionContext } from '../actions.js';
import type { WarEngineConfig } from '../types.js';
import type { WarEngineConfig, WarTimeContext } from '../types.js';
import type { WarCrewType } from '../crewType.js';
import { clamp, clampMin, getMetaNumber, increaseMetaNumber, round } from '../utils.js';
import { WAR_CRITICAL_RANGE, WarUnit, resolveNationTech } from './base.js';
@@ -58,7 +58,8 @@ export class WarUnitGeneral<
isAttacker: boolean,
crewType: WarCrewType,
logger: ActionLogger,
pipeline: WarActionPipeline<TriggerState>
pipeline: WarActionPipeline<TriggerState>,
private readonly time?: WarTimeContext
) {
super(rng, config, crewType, logger, isAttacker, nation);
this.actionPipeline = pipeline;
@@ -89,6 +90,8 @@ export class WarUnitGeneral<
log: this.logger,
rng: this.rng,
unit: this,
...(this.time ? { time: this.time } : {}),
maxTechLevel: this.config.maxTechLevel ?? 12,
};
}
@@ -243,13 +246,13 @@ export class WarUnitGeneral<
ratio = 50 + ratio / 2;
}
const attack = this.getCrewType().attack + getTechAbility(tech);
const attack = this.getCrewType().attack + getTechAbility(tech, this.config.maxTechLevel);
return attack * (ratio / 100);
}
public override getComputedDefence(): number {
const tech = resolveNationTech(this.nation);
const defence = this.getCrewType().defence + getTechAbility(tech);
const defence = this.getCrewType().defence + getTechAbility(tech, this.config.maxTechLevel);
const crew = this.general.crew / (7000 / 30) + 70;
return defence * (crew / 100);
}
@@ -407,7 +410,7 @@ export class WarUnitGeneral<
rice *= 0.8;
}
rice *= this.getCrewType().rice;
rice *= getTechCost(resolveNationTech(this.nation));
rice *= getTechCost(resolveNationTech(this.nation), this.config.maxTechLevel);
rice = this.actionPipeline.onCalcStat(this.getActionContext(), 'killRice', rice);
return rice;
}
+11 -4
View File
@@ -170,12 +170,19 @@ export const getTechLevel = (tech: number, maxLevel = DEFAULT_MAX_TECH_LEVEL): n
return Math.max(0, Math.min(level, maxLevel));
};
export const getTechAbility = (tech: number): number => getTechLevel(tech) * 25;
export const getTechAbility = (tech: number, maxLevel = DEFAULT_MAX_TECH_LEVEL): number =>
getTechLevel(tech, maxLevel) * 25;
export const getTechCost = (tech: number): number => 1 + getTechLevel(tech) * 0.15;
export const getTechCost = (tech: number, maxLevel = DEFAULT_MAX_TECH_LEVEL): number =>
1 + getTechLevel(tech, maxLevel) * 0.15;
export const getCrewTypePickScore = (crewType: CrewTypeDefinition, tech: number, armPerPhase: number): number => {
let score = armPerPhase + crewType.attack + crewType.defence + getTechAbility(tech) * 2;
export const getCrewTypePickScore = (
crewType: CrewTypeDefinition,
tech: number,
armPerPhase: number,
maxTechLevel = DEFAULT_MAX_TECH_LEVEL
): number => {
let score = armPerPhase + crewType.attack + crewType.defence + getTechAbility(tech, maxTechLevel) * 2;
score *= 1 + crewType.speed / 2;
score /= Math.max(1 - crewType.avoid / 100, 0.1);
score *= 1 + crewType.magicCoef / 2;