import { GAME_TICKS_PER_TURN, JosaUtil, LiteHashDRBG, RandUtil, asRecord } from '@sammo-ts/common'; import { LogCategory, LogFormat, LogScope, type TurnCommandEnv } from '@sammo-ts/logic'; import { simpleSerialize } from '@sammo-ts/logic/war/utils.js'; import type { InMemoryTurnWorld } from './inMemoryWorld.js'; import type { MonthlyEventActionHandler, MonthlyEventEnvironment } from './monthlyEventHandler.js'; import type { InMemoryReservedTurnStore } from './reservedTurnStore.js'; import type { TurnGeneral } from './types.js'; const NPC_TYPE = 3; const NPC_NAME_PREFIX = 'ⓜ'; const LEGACY_GENERAL_NAME_PREFIXES = ['', 'ⓝ', 'ⓝ', 'ⓜ', 'ⓖ', '㉥', 'ⓤ', 'ⓞ']; const STAT_TYPE_WEIGHTS = { 무: 0.333, 지: 0.333, 무지: 0.334 } as const; const readLegacyNumber = (value: unknown, fallback: number): number => { 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 resolveHiddenSeed = (world: InMemoryTurnWorld): string | number => { const state = world.getState(); const value = state.meta.hiddenSeed ?? state.meta.seed ?? state.id; return typeof value === 'string' || typeof value === 'number' ? value : String(value); }; const countLegacyNameDuplicates = (generals: readonly TurnGeneral[], baseName: string): number => { let count = 0; for (const prefix of LEGACY_GENERAL_NAME_PREFIXES) { const target = `${prefix}${baseName}`; count += generals.filter((general) => general.name.startsWith(target)).length; } return count; }; export const pickNpcNames = ( rng: RandUtil, count: number, existingGenerals: readonly TurnGeneral[], env: TurnCommandEnv ): string[] => { const firstNames = env.randomGeneralFirstNames ?? ['가']; const middleNames = env.randomGeneralMiddleNames ?? ['']; const lastNames = env.randomGeneralLastNames ?? ['가']; const names: string[] = []; for (let index = 0; index < count; index += 1) { let loopCount = 0; while (true) { let name = `${rng.choice(firstNames)}${rng.choice(middleNames)}${rng.choice(lastNames)}`; const duplicateCount = countLegacyNameDuplicates(existingGenerals, name); if (duplicateCount === 0) { names.push(name); break; } if (loopCount >= 99 || duplicateCount < 2) { name += duplicateCount + 1; names.push(name); break; } loopCount += 1; } } return names; }; export const buildNpcStats = ( rng: RandUtil, env: TurnCommandEnv, weights: Readonly> = STAT_TYPE_WEIGHTS ): TurnGeneral['stats'] => { const totalStat = env.npcStatTotal ?? 150; const minStat = env.npcStatMin ?? 10; const maxStat = env.npcStatMax ?? 50; const pickType = rng.choiceUsingWeight(weights); let mainStat = maxStat - rng.nextRangeInt(0, minStat); let otherStat = minStat + rng.nextRangeInt(0, Math.trunc(minStat / 2)); let subStat = totalStat - mainStat - otherStat; if (subStat < minStat) { subStat = otherStat; otherStat = minStat; mainStat = totalStat - subStat - otherStat; // GeneralBuilder::fillRandomStat()의 기존 truthy 검사까지 보존한다. if (mainStat !== 0) { throw new Error('기본 스탯 설정값이 잘못되어 있음'); } } if (pickType === '무') { return { leadership: subStat, strength: mainStat, intelligence: otherStat }; } if (pickType === '지') { return { leadership: subStat, strength: otherStat, intelligence: mainStat }; } return { leadership: otherStat, strength: subStat, intelligence: mainStat }; }; const buildSpecialityAge = (retirementYear: number, age: number, relativeYear: number, divisor: number): number => Math.max(Math.round((retirementYear - age) / divisor - relativeYear / 2), 3) + age; const buildNpc = (options: { world: InMemoryTurnWorld; reservedTurns: InMemoryReservedTurnStore; rng: RandUtil; environment: MonthlyEventEnvironment; env: TurnCommandEnv; baseName: string; }): TurnGeneral => { const { world, reservedTurns, rng, environment, env } = options; const age = rng.nextRangeInt(20, 25); const bornYear = environment.year - age; const deadYear = environment.year + rng.nextRangeInt(10, 50); const stats = buildNpcStats(rng, env); const affinity = rng.nextRangeInt(1, 150); const relativeYear = Math.max(environment.year - environment.startyear, 0); const configValues = asRecord(world.getScenarioConfig().const); const retirementYear = readLegacyNumber(configValues.retirementYear, 80); const specAge = buildSpecialityAge(retirementYear, age, relativeYear, 12); const specAge2 = buildSpecialityAge(retirementYear, age, relativeYear, 6); const personality = rng.choice(env.availablePersonalities ?? ['che_안전']); const cities = world.listCities(); if (cities.length === 0) { throw new Error('CreateManyNPC requires at least one city.'); } const city = rng.choice(cities); const turnMinutes = world.getState().tickSeconds / 60; if (!(turnMinutes > 0)) { throw new Error('CreateManyNPC requires a positive turn term.'); } const turnSecond = rng.nextRangeInt(0, 60 * turnMinutes - 1); const turnFraction = rng.nextRangeInt(0, 999_999); const ticksPerSecond = GAME_TICKS_PER_TURN / world.getState().tickSeconds; const turnTick = world.dateToGameTick(environment.turnTime) + turnSecond * ticksPerSecond + Math.floor((turnFraction * ticksPerSecond) / 1_000_000); const turnTime = world.gameTickToDate(turnTick); const killturn = (deadYear - environment.year) * 12 + rng.nextRangeInt(0, 11) + environment.month - 1; const id = world.getNextGeneralId(); const general: TurnGeneral = { id, userId: null, name: `${NPC_NAME_PREFIX}${options.baseName}`, nationId: 0, cityId: city.id, troopId: 0, stats, experience: age * 100, dedication: age * 100, officerLevel: 0, role: { personality, specialDomestic: env.defaultSpecialDomestic, specialWar: env.defaultSpecialWar, items: { horse: null, weapon: null, book: null, item: null }, }, injury: 0, gold: 1_000, rice: 1_000, crew: 0, crewTypeId: env.defaultCrewTypeId, train: 0, atmos: 0, age, npcState: NPC_TYPE, bornYear, deadYear, affinity, picture: 'default.jpg', triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {}, }, lastTurn: { command: '휴식' }, turnTime, turnTick, recentWarTime: null, meta: { killturn, npcType: NPC_TYPE, npc_org: NPC_TYPE, belong: 0, dedlevel: 1, specage: specAge, specage2: specAge2, dex1: 0, dex2: 0, dex3: 0, dex4: 0, dex5: 0, }, }; if (!world.addGeneral(general)) { throw new Error(`CreateManyNPC generated a duplicate general id: ${id}`); } reservedTurns.ensureGeneralTurns(id); return general; }; export const createCreateManyNpcHandler = (options: { getWorld: () => InMemoryTurnWorld | null; reservedTurns: InMemoryReservedTurnStore; env: TurnCommandEnv; }): MonthlyEventActionHandler => { return (args, environment) => { const world = options.getWorld(); if (!world) { return; } const npcCount = readLegacyNumber(args[0], 10); const fillCount = readLegacyNumber(args[1], 0); if (npcCount <= 0 && fillCount <= 0) { return; } let moreGeneralCount = 0; if (fillCount !== 0) { const chiefs = world .listGenerals() .filter((general) => general.npcState < 3 && general.officerLevel === 12); const chiefNationIds = new Set(chiefs.map((general) => general.nationId)); const registeredGeneralCount = world .listGenerals() .filter((general) => chiefNationIds.has(general.nationId) && general.npcState < 4).length; moreGeneralCount = chiefs.length * fillCount - registeredGeneralCount; } const requestedCount = npcCount + moreGeneralCount; const rng = new RandUtil( new LiteHashDRBG( simpleSerialize(resolveHiddenSeed(world), 'CreateManyNPC', environment.year, environment.month) ) ); const baseNames = pickNpcNames(rng, requestedCount, world.listGenerals(), options.env); const created = baseNames.map((baseName) => buildNpc({ world, reservedTurns: options.reservedTurns, rng, environment, env: options.env, baseName, }) ); const count = created.length; const actionText = count === 1 ? `${created[0]!.name}${JosaUtil.pick(created[0]!.name, '라')}는 장수가 등장하였습니다.` : `장수 ${count}명이 등장하였습니다.`; world.pushLog({ scope: LogScope.SYSTEM, category: LogCategory.ACTION, text: actionText, format: LogFormat.MONTH, year: environment.year, month: environment.month, }); world.pushLog({ scope: LogScope.SYSTEM, category: LogCategory.HISTORY, text: `장수 ${count}명이 등장했습니다.`, format: LogFormat.NOTICE_YEAR_MONTH, year: environment.year, month: environment.month, }); }; };