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:
@@ -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));
|
||||
@@ -2,3 +2,4 @@ export * from './types.js';
|
||||
export * from './parseScenario.js';
|
||||
export * from './scenarioEffect.js';
|
||||
export * from './constants.js';
|
||||
export * from './centennialAllStar.js';
|
||||
|
||||
@@ -43,6 +43,7 @@ export interface GeneralActionContext<TriggerState extends GeneralTriggerState =
|
||||
month: number;
|
||||
startYear: number;
|
||||
};
|
||||
maxTechLevel?: number;
|
||||
}
|
||||
|
||||
export interface GeneralTriggerContext<
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -27,6 +27,7 @@ export interface WarEngineConfig {
|
||||
maxAtmosByWar: number;
|
||||
maxGeneralStat?: number;
|
||||
statUpgradeLimit?: number;
|
||||
maxTechLevel?: number;
|
||||
castleCrewTypeId: number;
|
||||
armTypes: WarArmTypes;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
buildScenarioGeneralPoolClaimMeta,
|
||||
getScenarioGeneralPoolCandidateWeight,
|
||||
parseScenarioGeneralPoolCandidate,
|
||||
pickUniqueScenarioGeneralPoolCandidates,
|
||||
readScenarioGeneralPoolClaim,
|
||||
resolveLegacyNpcStatTypeFromFixedStats,
|
||||
type ScenarioGeneralPoolCandidate,
|
||||
} from '../../../src/actions/turn/generalPool.js';
|
||||
|
||||
const candidate = (poolEntryId: number, name: string, weight: number): ScenarioGeneralPoolCandidate => ({
|
||||
poolEntryId,
|
||||
uniqueName: name,
|
||||
name,
|
||||
dex: [weight, 0, 0, 0, 0],
|
||||
sourceInfo: {},
|
||||
});
|
||||
|
||||
describe('scenario general pool', () => {
|
||||
it('keeps the Ref weighted array and consumes duplicate draws before selecting a new row', () => {
|
||||
const draws = [0, 0, 0.75];
|
||||
let drawCount = 0;
|
||||
const rng = {
|
||||
nextFloat1: () => {
|
||||
const value = draws[drawCount];
|
||||
drawCount += 1;
|
||||
return value ?? 0;
|
||||
},
|
||||
nextBool: () => false,
|
||||
nextInt: () => 0,
|
||||
};
|
||||
|
||||
expect(pickUniqueScenarioGeneralPoolCandidates(rng, [candidate(1, '갑', 1), candidate(2, '을', 1)], 2)).toEqual(
|
||||
[expect.objectContaining({ poolEntryId: 1 }), expect.objectContaining({ poolEntryId: 2 })]
|
||||
);
|
||||
expect(drawCount).toBe(3);
|
||||
});
|
||||
|
||||
it('fails with the Ref pool-shortage message instead of using a random-name fallback', () => {
|
||||
const rng = { nextFloat1: () => 0, nextBool: () => false, nextInt: () => 0 };
|
||||
expect(() => pickUniqueScenarioGeneralPoolCandidates(rng, [candidate(1, '갑', 1)], 2)).toThrow('pool 부족');
|
||||
});
|
||||
|
||||
it('keeps zero-dex centennial NPC candidates selectable with the Ref minimum weight', () => {
|
||||
const centennial = {
|
||||
...candidate(3, '성장후보', 0),
|
||||
sourceInfo: { event100Growth: true },
|
||||
};
|
||||
let draws = 0;
|
||||
const rng = {
|
||||
nextFloat1: () => {
|
||||
draws += 1;
|
||||
return 0;
|
||||
},
|
||||
nextBool: () => false,
|
||||
nextInt: () => 0,
|
||||
};
|
||||
|
||||
expect(getScenarioGeneralPoolCandidateWeight(centennial)).toBe(100_000);
|
||||
expect(pickUniqueScenarioGeneralPoolCandidates(rng, [centennial], 1)).toEqual([centennial]);
|
||||
expect(draws).toBe(1);
|
||||
});
|
||||
|
||||
it('parses the U30 builder fields and round-trips the persisted claim marker', () => {
|
||||
const parsed = parseScenarioGeneralPoolCandidate({
|
||||
id: 17,
|
||||
uniqueName: '풀장수',
|
||||
info: {
|
||||
generalName: '풀장수',
|
||||
leadership: 69,
|
||||
strength: 12,
|
||||
intel: 80,
|
||||
specialDomestic: 'che_event_징병',
|
||||
dex: [1, 2, 3, 4, 5],
|
||||
imgsvr: 1,
|
||||
picture: 'pool.gif',
|
||||
},
|
||||
});
|
||||
const claimedAt = new Date('0200-05-01T00:00:00.000Z');
|
||||
const meta = { killturn: 1, ...buildScenarioGeneralPoolClaimMeta(parsed, claimedAt) };
|
||||
|
||||
expect(parsed).toMatchObject({
|
||||
poolEntryId: 17,
|
||||
uniqueName: '풀장수',
|
||||
name: '풀장수',
|
||||
stats: { leadership: 69, strength: 12, intelligence: 80 },
|
||||
dex: [1, 2, 3, 4, 5],
|
||||
specialDomestic: 'che_event_징병',
|
||||
imageServer: 1,
|
||||
picture: 'pool.gif',
|
||||
});
|
||||
expect(readScenarioGeneralPoolClaim(meta)).toEqual({
|
||||
poolEntryId: 17,
|
||||
uniqueName: '풀장수',
|
||||
claimedAt: claimedAt.toISOString(),
|
||||
});
|
||||
});
|
||||
|
||||
it('only consumes a stat-type draw for an ambiguous fixed-stat candidate', () => {
|
||||
let draws = 0;
|
||||
const rng = {
|
||||
nextFloat1: () => {
|
||||
draws += 1;
|
||||
return 0;
|
||||
},
|
||||
nextBool: () => false,
|
||||
nextInt: () => 0,
|
||||
};
|
||||
|
||||
expect(
|
||||
resolveLegacyNpcStatTypeFromFixedStats(rng, {
|
||||
leadership: 70,
|
||||
strength: 80,
|
||||
intelligence: 10,
|
||||
})
|
||||
).toBe('무');
|
||||
expect(draws).toBe(0);
|
||||
expect(
|
||||
resolveLegacyNpcStatTypeFromFixedStats(rng, {
|
||||
leadership: 70,
|
||||
strength: 50,
|
||||
intelligence: 50,
|
||||
})
|
||||
).toBe('무');
|
||||
expect(draws).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import { ConstantRNG, RandUtil } from '@sammo-ts/common';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { General, Nation } from '../../../src/domain/entities.js';
|
||||
import { parseScenarioGeneralPoolCandidate } from '../../../src/actions/turn/generalPool.js';
|
||||
import {
|
||||
ActionResolver,
|
||||
type VolunteerRecruitEnvironment,
|
||||
@@ -80,10 +81,19 @@ describe('nation volunteer recruitment lifespan', () => {
|
||||
currentYear: 190,
|
||||
currentMonth: 1,
|
||||
startYear: 180,
|
||||
centennialRules: {
|
||||
defaultStatMin: 15,
|
||||
defaultStatMax: 80,
|
||||
defaultStatTotal: 165,
|
||||
maxStatLevel: 255,
|
||||
defaultSpecialDomestic: null,
|
||||
dexLimit: 1_000_000,
|
||||
},
|
||||
centennialNpcDexTargetRatio: 0.4,
|
||||
averageNationGeneralCount: 0,
|
||||
nationAverageStats: { leadership: 50, strength: 50, intelligence: 50 },
|
||||
nationAverageExperience: 1_000,
|
||||
nationAverageDedication: 1_000,
|
||||
nationAverageExperience: 0,
|
||||
nationAverageDedication: 0,
|
||||
nationAverageDex: [100, 100, 100, 100, 100],
|
||||
friendlyGenerals: [general],
|
||||
createGeneralId: () => 2,
|
||||
@@ -104,10 +114,161 @@ describe('nation volunteer recruitment lifespan', () => {
|
||||
name: 'ⓖ장수',
|
||||
bornYear: 170,
|
||||
deadYear: 200,
|
||||
experience: 2_000,
|
||||
dedication: 2_000,
|
||||
meta: {
|
||||
birthYear: 170,
|
||||
deathYear: 200,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('uses a U30 candidate batch while keeping the Ref volunteer overrides', () => {
|
||||
const resolver = new ActionResolver([], environment);
|
||||
const turnTimeBase = new Date('0190-01-01T00:00:00.000Z');
|
||||
const poolCandidate = parseScenarioGeneralPoolCandidate({
|
||||
id: 17,
|
||||
uniqueName: '의병후보',
|
||||
info: {
|
||||
generalName: '의병후보',
|
||||
leadership: 70,
|
||||
strength: 80,
|
||||
intel: 10,
|
||||
specialDomestic: 'che_event_징병',
|
||||
dex: [11, 22, 33, 44, 55],
|
||||
imgsvr: 1,
|
||||
picture: 'volunteer.gif',
|
||||
},
|
||||
});
|
||||
const context = {
|
||||
general: structuredClone(general),
|
||||
nation: structuredClone(nation),
|
||||
rng: new RandUtil(new ConstantRNG(0)),
|
||||
addLog: () => undefined,
|
||||
currentYear: 190,
|
||||
currentMonth: 1,
|
||||
startYear: 180,
|
||||
centennialRules: {
|
||||
defaultStatMin: 15,
|
||||
defaultStatMax: 80,
|
||||
defaultStatTotal: 165,
|
||||
maxStatLevel: 255,
|
||||
defaultSpecialDomestic: null,
|
||||
dexLimit: 1_000_000,
|
||||
},
|
||||
centennialNpcDexTargetRatio: 0.4,
|
||||
averageNationGeneralCount: 0,
|
||||
nationAverageStats: { leadership: 50, strength: 50, intelligence: 50 },
|
||||
nationAverageExperience: 1_000,
|
||||
nationAverageDedication: 1_000,
|
||||
nationAverageDex: [100, 100, 100, 100, 100],
|
||||
friendlyGenerals: [general],
|
||||
generalPool: [poolCandidate],
|
||||
existingGeneralNames: ['군주'],
|
||||
createGeneralId: () => 2,
|
||||
turnTermSeconds: 60,
|
||||
turnTimeBase,
|
||||
ticksPerSecond: 1,
|
||||
} as VolunteerRecruitResolveContext;
|
||||
|
||||
const outcome = resolver.resolve(context, {});
|
||||
const createdEffect = outcome.effects.find((effect) => effect.type === 'general:add');
|
||||
expect(createdEffect?.type).toBe('general:add');
|
||||
if (!createdEffect || createdEffect.type !== 'general:add') {
|
||||
return;
|
||||
}
|
||||
|
||||
expect(createdEffect.general).toMatchObject({
|
||||
name: 'ⓖ의병후보',
|
||||
stats: { leadership: 70, strength: 80, intelligence: 10 },
|
||||
picture: 'volunteer.gif',
|
||||
imageServer: 1,
|
||||
role: { specialDomestic: null, specialWar: null },
|
||||
meta: {
|
||||
dex1: 11,
|
||||
dex2: 22,
|
||||
dex3: 33,
|
||||
dex4: 44,
|
||||
dex5: 55,
|
||||
scenarioGeneralPoolClaim: {
|
||||
poolEntryId: 17,
|
||||
uniqueName: '의병후보',
|
||||
claimedAt: turnTimeBase.toISOString(),
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('generates ordinary volunteer stats and dex before applying the S100 .9/.4 target', () => {
|
||||
const resolver = new ActionResolver([], environment);
|
||||
const turnTimeBase = new Date('0195-01-01T00:00:00.000Z');
|
||||
const poolCandidate = parseScenarioGeneralPoolCandidate({
|
||||
id: 101,
|
||||
uniqueName: 'A1000101',
|
||||
info: {
|
||||
uniqueName: 'A1000101',
|
||||
generalName: '100기의병',
|
||||
leadership: 100,
|
||||
strength: 80,
|
||||
intel: 10,
|
||||
specialDomestic: 'che_event_징병',
|
||||
dex: [900_000, 800_000, 700_000, 600_000, 500_000],
|
||||
imgsvr: 1,
|
||||
picture: 'centennial-volunteer.gif',
|
||||
event100Growth: true,
|
||||
},
|
||||
});
|
||||
const context = {
|
||||
general: structuredClone(general),
|
||||
nation: structuredClone(nation),
|
||||
rng: new RandUtil(new ConstantRNG(0)),
|
||||
addLog: () => undefined,
|
||||
currentYear: 195,
|
||||
currentMonth: 1,
|
||||
startYear: 180,
|
||||
centennialRules: {
|
||||
defaultStatMin: 15,
|
||||
defaultStatMax: 80,
|
||||
defaultStatTotal: 165,
|
||||
maxStatLevel: 255,
|
||||
defaultSpecialDomestic: null,
|
||||
dexLimit: 1_000_000,
|
||||
},
|
||||
centennialNpcDexTargetRatio: 0.4,
|
||||
averageNationGeneralCount: 0,
|
||||
nationAverageStats: { leadership: 50, strength: 50, intelligence: 50 },
|
||||
nationAverageExperience: 1_000,
|
||||
nationAverageDedication: 1_000,
|
||||
nationAverageDex: [100, 100, 100, 100, 100],
|
||||
friendlyGenerals: [general],
|
||||
generalPool: [poolCandidate],
|
||||
existingGeneralNames: ['군주'],
|
||||
createGeneralId: () => 2,
|
||||
turnTermSeconds: 60,
|
||||
turnTimeBase,
|
||||
ticksPerSecond: 1,
|
||||
} as VolunteerRecruitResolveContext;
|
||||
|
||||
const outcome = resolver.resolve(context, {});
|
||||
const createdEffect = outcome.effects.find((effect) => effect.type === 'general:add');
|
||||
expect(createdEffect?.type).toBe('general:add');
|
||||
if (!createdEffect || createdEffect.type !== 'general:add') {
|
||||
return;
|
||||
}
|
||||
|
||||
expect(createdEffect.general).toMatchObject({
|
||||
name: 'ⓖ100기의병',
|
||||
stats: { leadership: 91, strength: 73, intelligence: 10 },
|
||||
role: { specialDomestic: 'che_event_징병' },
|
||||
meta: {
|
||||
dex1: 360_000,
|
||||
dex2: 320_000,
|
||||
dex3: 280_000,
|
||||
dex4: 240_000,
|
||||
dex5: 200_000,
|
||||
scenarioGeneralPoolClaim: { poolEntryId: 101, uniqueName: 'A1000101' },
|
||||
event100_allstar: { targetId: 'A1000101', milestone: 4, dexTargetRatio: 0.4 },
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
import { ConstantRNG, RandUtil } from '@sammo-ts/common';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { ActionResolver, type TalentScoutResolveContext } from '../../../src/actions/turn/general/che_인재탐색.js';
|
||||
import { parseScenarioGeneralPoolCandidate } from '../../../src/actions/turn/generalPool.js';
|
||||
import type { City, General } from '../../../src/domain/entities.js';
|
||||
|
||||
const general: General = {
|
||||
id: 1,
|
||||
name: '탐색자',
|
||||
nationId: 1,
|
||||
cityId: 3,
|
||||
troopId: 0,
|
||||
stats: { leadership: 70, strength: 70, intelligence: 70 },
|
||||
experience: 1_000,
|
||||
dedication: 1_000,
|
||||
officerLevel: 1,
|
||||
role: {
|
||||
personality: null,
|
||||
specialDomestic: null,
|
||||
specialWar: null,
|
||||
items: { horse: null, weapon: null, book: null, item: null },
|
||||
},
|
||||
injury: 0,
|
||||
gold: 1_000,
|
||||
rice: 1_000,
|
||||
crew: 0,
|
||||
crewTypeId: 0,
|
||||
train: 0,
|
||||
atmos: 0,
|
||||
age: 30,
|
||||
npcState: 0,
|
||||
triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} },
|
||||
meta: { killturn: 24 },
|
||||
};
|
||||
|
||||
const city: City = {
|
||||
id: 3,
|
||||
name: '탐색도시',
|
||||
nationId: 1,
|
||||
level: 4,
|
||||
state: 0,
|
||||
population: 10_000,
|
||||
populationMax: 20_000,
|
||||
agriculture: 1_000,
|
||||
agricultureMax: 2_000,
|
||||
commerce: 1_000,
|
||||
commerceMax: 2_000,
|
||||
security: 1_000,
|
||||
securityMax: 2_000,
|
||||
supplyState: 1,
|
||||
frontState: 0,
|
||||
defence: 1_000,
|
||||
defenceMax: 2_000,
|
||||
wall: 1_000,
|
||||
wallMax: 2_000,
|
||||
meta: {},
|
||||
};
|
||||
|
||||
describe('talent scout scenario general pool', () => {
|
||||
it('keeps the U30 fixed stats and dex while applying the Ref scout overrides', () => {
|
||||
const resolver = new ActionResolver([], {
|
||||
develCost: 100,
|
||||
maxGeneral: 100,
|
||||
defaultNpcGold: 1_000,
|
||||
defaultNpcRice: 1_000,
|
||||
defaultCrewTypeId: 0,
|
||||
defaultSpecialDomestic: null,
|
||||
defaultSpecialWar: null,
|
||||
availablePersonalities: ['che_안전'],
|
||||
});
|
||||
const turnTimeBase = new Date('0190-01-01T00:00:00.000Z');
|
||||
const poolCandidate = parseScenarioGeneralPoolCandidate({
|
||||
id: 23,
|
||||
uniqueName: '탐색후보',
|
||||
info: {
|
||||
generalName: '탐색후보',
|
||||
leadership: 70,
|
||||
strength: 80,
|
||||
intel: 10,
|
||||
specialDomestic: 'che_event_징병',
|
||||
dex: [12, 24, 36, 48, 60],
|
||||
imgsvr: 1,
|
||||
picture: 'scout.gif',
|
||||
},
|
||||
});
|
||||
const context = {
|
||||
general: structuredClone(general),
|
||||
rng: new RandUtil(new ConstantRNG(0)),
|
||||
addLog: () => undefined,
|
||||
currentYear: 190,
|
||||
currentMonth: 1,
|
||||
startYear: 180,
|
||||
retirementYear: 80,
|
||||
centennialRules: {
|
||||
defaultStatMin: 15,
|
||||
defaultStatMax: 80,
|
||||
defaultStatTotal: 165,
|
||||
maxStatLevel: 255,
|
||||
defaultSpecialDomestic: null,
|
||||
dexLimit: 1_000_000,
|
||||
},
|
||||
centennialNpcDexTargetRatio: 0.4,
|
||||
worldSummary: {
|
||||
totalGeneralCount: 0,
|
||||
totalNpcCount: 0,
|
||||
averageStats: { leadership: 50, strength: 50, intelligence: 50 },
|
||||
averageDex: [100, 100, 100, 100, 100],
|
||||
},
|
||||
generalPool: [poolCandidate],
|
||||
cityPool: [city],
|
||||
existingGeneralNames: ['탐색자'],
|
||||
createGeneralId: () => 2,
|
||||
turnTermMinutes: 10,
|
||||
turnTimeBase,
|
||||
ticksPerSecond: 1,
|
||||
} as TalentScoutResolveContext;
|
||||
|
||||
const outcome = resolver.resolve(context, {});
|
||||
const createdEffect = outcome.effects.find((effect) => effect.type === 'general:add');
|
||||
expect(createdEffect?.type).toBe('general:add');
|
||||
if (!createdEffect || createdEffect.type !== 'general:add') {
|
||||
return;
|
||||
}
|
||||
|
||||
expect(createdEffect.general).toMatchObject({
|
||||
name: 'ⓜ탐색후보',
|
||||
stats: { leadership: 70, strength: 80, intelligence: 10 },
|
||||
picture: 'scout.gif',
|
||||
imageServer: 1,
|
||||
role: { specialDomestic: null, specialWar: null },
|
||||
meta: {
|
||||
dex1: 12,
|
||||
dex2: 24,
|
||||
dex3: 36,
|
||||
dex4: 48,
|
||||
dex5: 60,
|
||||
scenarioGeneralPoolClaim: {
|
||||
poolEntryId: 23,
|
||||
uniqueName: '탐색후보',
|
||||
claimedAt: turnTimeBase.toISOString(),
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('generates ordinary stats and dex before applying the S100 .9/.4 target', () => {
|
||||
const resolver = new ActionResolver([], {
|
||||
develCost: 100,
|
||||
maxGeneral: 100,
|
||||
defaultNpcGold: 1_000,
|
||||
defaultNpcRice: 1_000,
|
||||
defaultCrewTypeId: 0,
|
||||
defaultSpecialDomestic: null,
|
||||
defaultSpecialWar: null,
|
||||
availablePersonalities: ['che_안전'],
|
||||
});
|
||||
const turnTimeBase = new Date('0195-01-01T00:00:00.000Z');
|
||||
const poolCandidate = parseScenarioGeneralPoolCandidate({
|
||||
id: 100,
|
||||
uniqueName: 'A1000100',
|
||||
info: {
|
||||
uniqueName: 'A1000100',
|
||||
generalName: '100기탐색',
|
||||
leadership: 100,
|
||||
strength: 80,
|
||||
intel: 10,
|
||||
specialDomestic: 'che_event_징병',
|
||||
dex: [900_000, 800_000, 700_000, 600_000, 500_000],
|
||||
imgsvr: 1,
|
||||
picture: 'centennial-scout.gif',
|
||||
event100Growth: true,
|
||||
},
|
||||
});
|
||||
const context = {
|
||||
general: structuredClone(general),
|
||||
rng: new RandUtil(new ConstantRNG(0)),
|
||||
addLog: () => undefined,
|
||||
currentYear: 195,
|
||||
currentMonth: 1,
|
||||
startYear: 180,
|
||||
retirementYear: 80,
|
||||
centennialRules: {
|
||||
defaultStatMin: 15,
|
||||
defaultStatMax: 80,
|
||||
defaultStatTotal: 165,
|
||||
maxStatLevel: 255,
|
||||
defaultSpecialDomestic: null,
|
||||
dexLimit: 1_000_000,
|
||||
},
|
||||
centennialNpcDexTargetRatio: 0.4,
|
||||
worldSummary: {
|
||||
totalGeneralCount: 0,
|
||||
totalNpcCount: 0,
|
||||
averageStats: { leadership: 50, strength: 50, intelligence: 50 },
|
||||
averageDex: [100, 100, 100, 100, 100],
|
||||
},
|
||||
generalPool: [poolCandidate],
|
||||
cityPool: [city],
|
||||
existingGeneralNames: ['탐색자'],
|
||||
createGeneralId: () => 2,
|
||||
turnTermMinutes: 10,
|
||||
turnTimeBase,
|
||||
ticksPerSecond: 1,
|
||||
} as TalentScoutResolveContext;
|
||||
|
||||
const outcome = resolver.resolve(context, {});
|
||||
const createdEffect = outcome.effects.find((effect) => effect.type === 'general:add');
|
||||
expect(createdEffect?.type).toBe('general:add');
|
||||
if (!createdEffect || createdEffect.type !== 'general:add') {
|
||||
return;
|
||||
}
|
||||
|
||||
expect(createdEffect.general).toMatchObject({
|
||||
name: 'ⓜ100기탐색',
|
||||
stats: { leadership: 91, strength: 73, intelligence: 10 },
|
||||
role: { specialDomestic: 'che_event_징병' },
|
||||
meta: {
|
||||
dex1: 360_000,
|
||||
dex2: 320_000,
|
||||
dex3: 280_000,
|
||||
dex4: 240_000,
|
||||
dex5: 200_000,
|
||||
scenarioGeneralPoolClaim: { poolEntryId: 100, uniqueName: 'A1000100' },
|
||||
event100_allstar: { targetId: 'A1000100', milestone: 4, dexTargetRatio: 0.4 },
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,229 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { General } from '../src/domain/entities.js';
|
||||
import {
|
||||
CENTENNIAL_ALL_STAR_AUX_KEY,
|
||||
applyCentennialAllStarTarget,
|
||||
calculateCentennialGeneratedNpcInitialStats,
|
||||
calculateCentennialLegacyUserGrant,
|
||||
calculateCentennialProgress,
|
||||
calculateCentennialUserInitialStats,
|
||||
initialCentennialAllStarAux,
|
||||
prepareCentennialLegacyUserReselection,
|
||||
readCentennialAllStarPoolTarget,
|
||||
readCentennialAllStarAux,
|
||||
reconcileCentennialDexConversion,
|
||||
type CentennialAllStarRules,
|
||||
type CentennialAllStarTarget,
|
||||
} from '../src/scenario/centennialAllStar.js';
|
||||
|
||||
const rules: CentennialAllStarRules = {
|
||||
defaultStatMin: 15,
|
||||
defaultStatMax: 80,
|
||||
defaultStatTotal: 165,
|
||||
maxStatLevel: 255,
|
||||
defaultSpecialDomestic: 'None',
|
||||
dexLimit: 1_000_000,
|
||||
};
|
||||
|
||||
const target = (overrides: Partial<CentennialAllStarTarget> = {}): CentennialAllStarTarget => ({
|
||||
uniqueName: 'A1000001',
|
||||
generalName: '1·조민',
|
||||
leadership: 100,
|
||||
strength: 80,
|
||||
intel: 10,
|
||||
dex: [900_000, 800_000, 700_000, 600_000, 500_000],
|
||||
specialDomestic: 'che_event_무쌍',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const general = (overrides: Partial<General> = {}): General => ({
|
||||
id: 1,
|
||||
name: '장수',
|
||||
nationId: 0,
|
||||
cityId: 1,
|
||||
troopId: 0,
|
||||
stats: { leadership: 15, strength: 15, intelligence: 10 },
|
||||
experience: 0,
|
||||
dedication: 0,
|
||||
officerLevel: 0,
|
||||
role: {
|
||||
personality: 'che_안전',
|
||||
specialDomestic: 'None',
|
||||
specialWar: 'None',
|
||||
items: { horse: null, weapon: null, book: null, item: null },
|
||||
},
|
||||
injury: 0,
|
||||
gold: 1_000,
|
||||
rice: 1_000,
|
||||
crew: 0,
|
||||
crewTypeId: 1100,
|
||||
train: 0,
|
||||
atmos: 0,
|
||||
age: 20,
|
||||
npcState: 0,
|
||||
triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} },
|
||||
meta: { killturn: 5, dex1: 0, dex2: 0, dex3: 0, dex4: 0, dex5: 0 },
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const metaWithAux = (aux: ReturnType<typeof initialCentennialAllStarAux>, killturn = 5): General['meta'] => {
|
||||
const meta: General['meta'] = { killturn, dex1: 0, dex2: 0, dex3: 0, dex4: 0, dex5: 0 };
|
||||
const mutable: Record<string, unknown> = meta;
|
||||
mutable[CENTENNIAL_ALL_STAR_AUX_KEY] = aux;
|
||||
return meta;
|
||||
};
|
||||
|
||||
describe('100기 올스타 성장 계약', () => {
|
||||
it('uses the Ref 15-year linear milestones and shaped user allocation', () => {
|
||||
expect(calculateCentennialProgress({ startYear: 180, year: 180, month: 1 })).toBe(0);
|
||||
expect(calculateCentennialProgress({ startYear: 180, year: 183, month: 1 })).toBeCloseTo(0.2);
|
||||
expect(calculateCentennialProgress({ startYear: 180, year: 186, month: 1 })).toBeCloseTo(0.4);
|
||||
expect(calculateCentennialProgress({ startYear: 180, year: 195, month: 1 })).toBe(1);
|
||||
expect(calculateCentennialProgress({ startYear: 180, year: 220, month: 1 }, 0.9)).toBe(0.9);
|
||||
expect(calculateCentennialUserInitialStats(target({ leadership: 80, strength: 70, intel: 50 }), rules)).toEqual(
|
||||
{ leadership: 65, strength: 58, intel: 42 }
|
||||
);
|
||||
});
|
||||
|
||||
it('recognizes only the S100 source marker as a Centennial pool target', () => {
|
||||
expect(
|
||||
readCentennialAllStarPoolTarget({
|
||||
uniqueName: target().uniqueName,
|
||||
name: target().generalName!,
|
||||
sourceInfo: { ...target(), event100Growth: true },
|
||||
})
|
||||
).toMatchObject(target());
|
||||
expect(
|
||||
readCentennialAllStarPoolTarget({
|
||||
uniqueName: 'legacy',
|
||||
name: 'legacy',
|
||||
sourceInfo: { ...target(), event100Growth: false },
|
||||
})
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('marks the legacy creation range as replaceable before first reselection', () => {
|
||||
expect(calculateCentennialLegacyUserGrant(90, 10, rules)).toBe(75);
|
||||
const legacyMeta = metaWithAux({
|
||||
...initialCentennialAllStarAux(target(), rules),
|
||||
granted: {
|
||||
...initialCentennialAllStarAux(target(), rules).granted,
|
||||
leadership: 10,
|
||||
},
|
||||
userInitialStats: null,
|
||||
});
|
||||
const prepared = prepareCentennialLegacyUserReselection(
|
||||
general({
|
||||
stats: { leadership: 90, strength: 50, intelligence: 10 },
|
||||
meta: legacyMeta,
|
||||
}),
|
||||
rules
|
||||
);
|
||||
const aux = readCentennialAllStarAux(prepared)!;
|
||||
|
||||
expect(aux.granted.leadership).toBe(75);
|
||||
expect(aux.granted.strength).toBe(35);
|
||||
expect(aux.granted.intel).toBe(0);
|
||||
expect(aux.userInitialStats).toEqual({ leadership: 80, strength: 50, intel: 10 });
|
||||
});
|
||||
|
||||
it('keeps generated NPC stat RNG output while mapping its strong axes to the target', () => {
|
||||
expect(
|
||||
calculateCentennialGeneratedNpcInitialStats(target(), {
|
||||
leadership: 72,
|
||||
strength: 66,
|
||||
intelligence: 12,
|
||||
})
|
||||
).toEqual({ leadership: 72, strength: 66, intelligence: 12 });
|
||||
|
||||
const npc = general({
|
||||
npcState: 3,
|
||||
stats: { leadership: 72, strength: 66, intelligence: 12 },
|
||||
meta: metaWithAux(initialCentennialAllStarAux(target(), rules), 120),
|
||||
});
|
||||
const result = applyCentennialAllStarTarget(
|
||||
npc,
|
||||
target(),
|
||||
{ startYear: 180, year: 195, month: 1 },
|
||||
rules,
|
||||
0.9,
|
||||
0.4
|
||||
);
|
||||
expect(result.stats).toEqual({ leadership: 91, strength: 73, intelligence: 12 });
|
||||
expect([result.meta.dex1, result.meta.dex2, result.meta.dex3, result.meta.dex4, result.meta.dex5]).toEqual([
|
||||
360_000, 320_000, 280_000, 240_000, 200_000,
|
||||
]);
|
||||
expect(result.milestone).toBe(4);
|
||||
});
|
||||
|
||||
it('unlocks the historical trait at 40% and preserves organic growth on reselection', () => {
|
||||
const firstTarget = target();
|
||||
const initial = calculateCentennialUserInitialStats(firstTarget, rules);
|
||||
const first = general({
|
||||
stats: {
|
||||
leadership: initial.leadership,
|
||||
strength: initial.strength,
|
||||
intelligence: initial.intel,
|
||||
},
|
||||
meta: metaWithAux(initialCentennialAllStarAux(firstTarget, rules, initial)),
|
||||
});
|
||||
const grown = applyCentennialAllStarTarget(first, firstTarget, { startYear: 180, year: 186, month: 1 }, rules);
|
||||
expect(grown.role.specialDomestic).toBe('che_event_무쌍');
|
||||
expect(grown.milestone).toBe(2);
|
||||
|
||||
const oldGranted = readCentennialAllStarAux(grown.meta)!.granted.leadership;
|
||||
const organicLeadership = grown.stats.leadership + 100;
|
||||
const changed = applyCentennialAllStarTarget(
|
||||
{
|
||||
...first,
|
||||
stats: { ...grown.stats, leadership: organicLeadership },
|
||||
role: grown.role,
|
||||
meta: grown.meta,
|
||||
},
|
||||
target({
|
||||
uniqueName: 'A1000002',
|
||||
leadership: 70,
|
||||
strength: 60,
|
||||
intel: 50,
|
||||
specialDomestic: 'che_event_견고',
|
||||
}),
|
||||
{ startYear: 180, year: 186, month: 1 },
|
||||
rules
|
||||
);
|
||||
expect(changed.stats.leadership).toBe(organicLeadership - oldGranted);
|
||||
expect(changed.role.specialDomestic).toBe('che_event_견고');
|
||||
expect(changed.targetChanged).toBe(true);
|
||||
});
|
||||
|
||||
it('does not refill an event-backed dex floor after 숙련전환 consumes it', () => {
|
||||
const dexTarget = target({ dex: [1_000_000, 0, 0, 0, 0] });
|
||||
const base = general({
|
||||
meta: metaWithAux(initialCentennialAllStarAux(dexTarget, rules)),
|
||||
});
|
||||
const full = applyCentennialAllStarTarget(base, dexTarget, { startYear: 180, year: 195, month: 1 }, rules);
|
||||
const convertedMeta = {
|
||||
...full.meta,
|
||||
dex1: 600_000,
|
||||
dex2: 360_000,
|
||||
};
|
||||
const reconciled = reconcileCentennialDexConversion(
|
||||
convertedMeta,
|
||||
'dex1',
|
||||
'dex2',
|
||||
1_000_000,
|
||||
600_000,
|
||||
0,
|
||||
360_000,
|
||||
0.9
|
||||
);
|
||||
const afterMonth = applyCentennialAllStarTarget(
|
||||
{ ...base, stats: full.stats, role: full.role, meta: reconciled },
|
||||
dexTarget,
|
||||
{ startYear: 180, year: 195, month: 2 },
|
||||
rules
|
||||
);
|
||||
expect(afterMonth.meta.dex1).toBe(600_000);
|
||||
expect(afterMonth.meta.dex2).toBe(360_000);
|
||||
});
|
||||
});
|
||||
@@ -13,7 +13,13 @@ import { computeBattleOrder, resolveWarBattle } from '../src/war/engine.js';
|
||||
import { createWarTriggerEnv, WarTriggerCaller } from '../src/war/triggers.js';
|
||||
import type { WarEngineConfig } from '../src/war/types.js';
|
||||
import { WarUnitCity, WarUnitGeneral, type WarUnit } from '../src/war/units.js';
|
||||
import { getCrewTypePickScore, parseUnitSetDefinition } from '../src/world/unitSet.js';
|
||||
import {
|
||||
getCrewTypePickScore,
|
||||
getTechAbility,
|
||||
getTechCost,
|
||||
getTechLevel,
|
||||
parseUnitSetDefinition,
|
||||
} from '../src/world/unitSet.js';
|
||||
import type { CrewTypeDefinition, UnitSetDefinition } from '../src/world/types.js';
|
||||
|
||||
const config: WarEngineConfig = {
|
||||
@@ -393,6 +399,20 @@ describe('crew type war triggers', () => {
|
||||
});
|
||||
|
||||
describe('crew type numeric policy', () => {
|
||||
it('uses the scenario 913 maximum tech level for ability and cost', () => {
|
||||
expect(getTechLevel(13_000, 15)).toBe(13);
|
||||
expect(getTechAbility(13_000, 15)).toBe(325);
|
||||
expect(getTechCost(13_000, 15)).toBe(2.95);
|
||||
|
||||
expect(getTechLevel(15_000, 15)).toBe(15);
|
||||
expect(getTechAbility(15_000, 15)).toBe(375);
|
||||
expect(getTechCost(15_000, 15)).toBe(3.25);
|
||||
|
||||
expect(getTechLevel(15_000)).toBe(12);
|
||||
expect(getTechAbility(15_000)).toBe(300);
|
||||
expect(getTechCost(15_000)).toBe(2.8);
|
||||
});
|
||||
|
||||
it('matches the legacy pickScore formula including magicCoef', () => {
|
||||
const wizard = crewType(1400, 4, '귀병', {
|
||||
attack: 80,
|
||||
@@ -404,4 +424,17 @@ describe('crew type numeric policy', () => {
|
||||
const expected = ((500 + 80 + 80 + 75 * 2) * (1 + 7 / 2) * (1 + 0.5 / 2)) / (1 - 0.05);
|
||||
expect(getCrewTypePickScore(wizard, 3000, 500)).toBeCloseTo(expected);
|
||||
});
|
||||
|
||||
it('uses the scenario maximum tech level in the crew pick score', () => {
|
||||
const wizard = crewType(1400, 4, '귀병', {
|
||||
attack: 80,
|
||||
defence: 80,
|
||||
speed: 7,
|
||||
avoid: 5,
|
||||
magicCoef: 0.5,
|
||||
});
|
||||
const expected = ((500 + 80 + 80 + 375 * 2) * (1 + 7 / 2) * (1 + 0.5 / 2)) / (1 - 0.05);
|
||||
|
||||
expect(getCrewTypePickScore(wizard, 15_000, 500, 15)).toBeCloseTo(expected);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,6 +12,9 @@ import { createItemActionModules, createItemModuleRegistry } from '../src/items/
|
||||
import { getEquippedItemInstance } from '../src/items/inventory.js';
|
||||
import { itemModule as dogiModule } from '../src/items/che_보물_도기.js';
|
||||
import { itemModule as strategyItemModule } from '../src/items/che_계략_이추.js';
|
||||
import { itemModule as leadershipWineModule } from '../src/items/che_능력치_통솔_보령압주.js';
|
||||
import { itemModule as strengthWineModule } from '../src/items/che_능력치_무력_두강주.js';
|
||||
import { itemModule as intelligenceWineModule } from '../src/items/che_능력치_지력_이강주.js';
|
||||
import { LogFormat } from '../src/logging/types.js';
|
||||
|
||||
const BASE_ENV: TurnCommandEnv = {
|
||||
@@ -109,6 +112,31 @@ const dogiCatalog: Record<string, TurnCommandItemCatalogEntry> = {
|
||||
},
|
||||
};
|
||||
|
||||
const scalingStatItems = [
|
||||
{ module: leadershipWineModule, statName: 'leadership' as const },
|
||||
{ module: strengthWineModule, statName: 'strength' as const },
|
||||
{ module: intelligenceWineModule, statName: 'intelligence' as const },
|
||||
];
|
||||
|
||||
describe('year-scaling stat items', () => {
|
||||
it.each([
|
||||
{ year: 180, startYear: 180, maxTechLevel: 12, expected: 75 },
|
||||
{ year: 200, startYear: 180, maxTechLevel: 12, expected: 80 },
|
||||
{ year: 260, startYear: 180, maxTechLevel: 12, expected: 87 },
|
||||
{ year: 260, startYear: 180, maxTechLevel: 15, expected: 90 },
|
||||
])('applies +5, four-year growth, and the $maxTechLevel cap', ({ year, startYear, maxTechLevel, expected }) => {
|
||||
const context = {
|
||||
general: makeGeneral(null),
|
||||
time: { year, month: 1, startYear },
|
||||
maxTechLevel,
|
||||
};
|
||||
|
||||
for (const { module, statName } of scalingStatItems) {
|
||||
expect(module.onCalcStat?.(context, statName, 70)).toBe(expected);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const createItemOnlyStack = (items: ReturnType<typeof createItemActionModules>['general']) => {
|
||||
const noOp = {};
|
||||
return createRefOrderedActionStack({
|
||||
|
||||
@@ -8,7 +8,7 @@ import type { UnitSetDefinition } from '../src/world/types.js';
|
||||
import { resolveWarAftermath } from '../src/war/aftermath.js';
|
||||
import type { WarAftermathConfig } from '../src/war/types.js';
|
||||
import { LogFormat } from '../src/logging/types.js';
|
||||
import { buildWarAftermathConfig } from '../src/actions/turn/actionContextHelpers.js';
|
||||
import { buildWarAftermathConfig, buildWarConfig } from '../src/actions/turn/actionContextHelpers.js';
|
||||
import type { ScenarioConfig } from '../src/scenario/types.js';
|
||||
|
||||
const buildUnitSet = (): UnitSetDefinition => ({
|
||||
@@ -137,6 +137,62 @@ describe('war aftermath', () => {
|
||||
expect(config.maxTechLevel).toBe(12);
|
||||
});
|
||||
|
||||
it('propagates the scenario maximum tech level to battle and aftermath configs', () => {
|
||||
const scenarioConfig: ScenarioConfig = {
|
||||
stat: { total: 0, min: 0, max: 0, npcTotal: 0, npcMax: 0, npcMin: 0, chiefMin: 0 },
|
||||
iconPath: '',
|
||||
map: {},
|
||||
const: { maxTechLevel: 15 },
|
||||
environment: { mapName: 'test', unitSet: 'test' },
|
||||
};
|
||||
|
||||
expect(buildWarConfig(scenarioConfig, buildUnitSet()).maxTechLevel).toBe(15);
|
||||
expect(buildWarAftermathConfig(scenarioConfig, 999).maxTechLevel).toBe(15);
|
||||
});
|
||||
|
||||
it('uses the scenario maximum tech level for supply-city rice consumption', () => {
|
||||
const attackerNation = buildNation(1);
|
||||
const defenderNation = buildNation(2);
|
||||
defenderNation.meta.tech = 15_000;
|
||||
const attackerCity = buildCity(1, 1);
|
||||
const defenderCity = buildCity(2, 2);
|
||||
defenderCity.meta.supply = 1;
|
||||
const attacker = buildGeneral(1, 1, 1);
|
||||
|
||||
resolveWarAftermath({
|
||||
battle: {
|
||||
attacker,
|
||||
defenders: [],
|
||||
defenderCity,
|
||||
logs: [],
|
||||
conquered: false,
|
||||
reports: [
|
||||
{
|
||||
id: defenderCity.id,
|
||||
type: 'city',
|
||||
name: defenderCity.name,
|
||||
isAttacker: false,
|
||||
killed: 100,
|
||||
dead: 0,
|
||||
phase: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
attackerNation,
|
||||
defenderNation,
|
||||
attackerCity,
|
||||
defenderCity,
|
||||
nations: [attackerNation, defenderNation],
|
||||
cities: [attackerCity, defenderCity],
|
||||
generals: [attacker],
|
||||
unitSet: buildUnitSet(),
|
||||
config: { ...buildConfig(), maxTechLevel: 15 },
|
||||
time: { year: 200, month: 1, startYear: 180 },
|
||||
});
|
||||
|
||||
expect(defenderNation.rice).toBe(985);
|
||||
});
|
||||
|
||||
it('updates tech and diplomacy deltas', () => {
|
||||
const attackerNation = buildNation(1);
|
||||
const defenderNation = buildNation(2);
|
||||
|
||||
@@ -166,6 +166,45 @@ const buildGeneral = (strength: number): General => ({
|
||||
});
|
||||
|
||||
describe('war triggers', () => {
|
||||
it('passes battle time and maximum tech level to year-scaling stat items', async () => {
|
||||
const general = buildGeneral(80);
|
||||
const [leadershipWine] = await loadItemModules(['che_능력치_통솔_보령압주']);
|
||||
expect(leadershipWine).toBeDefined();
|
||||
const unit = new WarUnitGeneral(
|
||||
new RandUtil(new ConstantRNG(0)),
|
||||
{ ...buildConfig(), maxTechLevel: 15 },
|
||||
general,
|
||||
buildCity(),
|
||||
buildNation(),
|
||||
true,
|
||||
new WarCrewType(buildUnitSet().crewTypes![0]!),
|
||||
new ActionLogger({ generalId: 1, nationId: 1 }),
|
||||
new WarActionPipeline([leadershipWine!]),
|
||||
{ year: 260, month: 1, startYear: 180 }
|
||||
);
|
||||
|
||||
expect(unit.getComputedStat('leadership', general.stats.leadership, { withInjury: false })).toBe(90);
|
||||
});
|
||||
|
||||
it('uses the battle config maximum tech level for combat ability', () => {
|
||||
const nation = buildNation();
|
||||
nation.meta.tech = 15_000;
|
||||
const unit = new WarUnitGeneral(
|
||||
new RandUtil(new ConstantRNG(0)),
|
||||
{ ...buildConfig(), maxTechLevel: 15 },
|
||||
buildGeneral(80),
|
||||
buildCity(),
|
||||
nation,
|
||||
true,
|
||||
new WarCrewType(buildUnitSet().crewTypes![0]!),
|
||||
new ActionLogger({ generalId: 1, nationId: 1 }),
|
||||
new WarActionPipeline([]),
|
||||
{ year: 200, month: 1, startYear: 180 }
|
||||
);
|
||||
|
||||
expect(unit.getComputedAttack()).toBeCloseTo(608, 12);
|
||||
});
|
||||
|
||||
it('normalizes accumulated dexterity to the PHP SQL float precision', () => {
|
||||
const general = buildGeneral(80);
|
||||
general.meta.dex4 = 14_677.199999999997;
|
||||
|
||||
Reference in New Issue
Block a user