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,
|
||||
|
||||
Reference in New Issue
Block a user