fix: align reserved turn commands with legacy

This commit is contained in:
2026-07-25 14:16:59 +00:00
parent 36d11acdfa
commit 1fef287b6b
23 changed files with 816 additions and 189 deletions
+63 -28
View File
@@ -45,20 +45,55 @@ const readMetaNumber = (meta: Record<string, unknown>, key: string): number | nu
return typeof value === 'number' && Number.isFinite(value) ? value : null;
};
const toLegacyDatabaseInt = (value: number): number => {
if (!Number.isFinite(value)) {
return 0;
}
return value >= 0 ? Math.floor(value + 0.5) : Math.ceil(value - 0.5);
};
const readRankMetaNumber = (meta: Record<string, unknown>, key: string): number => {
const value = meta[key];
if (typeof value === 'number' && Number.isFinite(value)) {
return Math.floor(value);
return toLegacyDatabaseInt(value);
}
if (typeof value === 'string') {
const parsed = Number(value);
if (Number.isFinite(parsed)) {
return Math.floor(parsed);
return toLegacyDatabaseInt(parsed);
}
}
return 0;
};
const LEGACY_INTEGER_GENERAL_META_KEYS = [
'leadership_exp',
'strength_exp',
'intel_exp',
'dex1',
'dex2',
'dex3',
'dex4',
'dex5',
'explevel',
'dedlevel',
'killturn',
'myset',
] as const;
const buildPersistedGeneralMeta = (
general: ReturnType<InMemoryTurnWorld['consumeDirtyState']>['generals'][number]
): InputJsonValue => {
const meta = withSerializedItemInventory(general.meta, ensureItemInventory(general));
for (const key of LEGACY_INTEGER_GENERAL_META_KEYS) {
const value = meta[key];
if (typeof value === 'number') {
meta[key] = toLegacyDatabaseInt(value);
}
}
return asJson(meta);
};
const buildRankRows = (
general: ReturnType<InMemoryTurnWorld['consumeDirtyState']>['generals'][number]
): Array<{ generalId: number; nationId: number; type: string; value: number }> => {
@@ -67,8 +102,8 @@ const buildRankRows = (
const readRank = (key: string) => readRankMetaNumber(meta, `rank_${key}`);
const entries: Array<[RankDataType, number]> = [
['experience', Math.floor(general.experience)],
['dedication', Math.floor(general.dedication)],
['experience', toLegacyDatabaseInt(general.experience)],
['dedication', toLegacyDatabaseInt(general.dedication)],
['firenum', readMeta('firenum')],
['warnum', readRank('warnum')],
['killnum', readRank('killnum')],
@@ -126,19 +161,19 @@ const buildGeneralUpdate = (
nationId: general.nationId,
cityId: general.cityId,
troopId: general.troopId,
leadership: general.stats.leadership,
strength: general.stats.strength,
intel: general.stats.intelligence,
experience: general.experience,
dedication: general.dedication,
leadership: toLegacyDatabaseInt(general.stats.leadership),
strength: toLegacyDatabaseInt(general.stats.strength),
intel: toLegacyDatabaseInt(general.stats.intelligence),
experience: toLegacyDatabaseInt(general.experience),
dedication: toLegacyDatabaseInt(general.dedication),
officerLevel: general.officerLevel,
injury: general.injury,
gold: general.gold,
rice: general.rice,
crew: general.crew,
injury: toLegacyDatabaseInt(general.injury),
gold: toLegacyDatabaseInt(general.gold),
rice: toLegacyDatabaseInt(general.rice),
crew: toLegacyDatabaseInt(general.crew),
crewTypeId: general.crewTypeId,
train: general.train,
atmos: general.atmos,
train: toLegacyDatabaseInt(general.train),
atmos: toLegacyDatabaseInt(general.atmos),
age: general.age,
npcState: general.npcState,
horseCode: toCode(general.role.items.horse),
@@ -149,7 +184,7 @@ const buildGeneralUpdate = (
specialCode: toCode(general.role.specialDomestic),
special2Code: toCode(general.role.specialWar),
lastTurn: asJson(general.lastTurn ?? { command: '휴식' }),
meta: asJson(withSerializedItemInventory(general.meta, ensureItemInventory(general))),
meta: buildPersistedGeneralMeta(general),
turnTime: general.turnTime,
recentWarTime: general.recentWarTime ?? null,
});
@@ -163,19 +198,19 @@ const buildGeneralCreate = (
cityId: general.cityId,
troopId: general.troopId,
npcState: general.npcState,
leadership: general.stats.leadership,
strength: general.stats.strength,
intel: general.stats.intelligence,
experience: general.experience,
dedication: general.dedication,
leadership: toLegacyDatabaseInt(general.stats.leadership),
strength: toLegacyDatabaseInt(general.stats.strength),
intel: toLegacyDatabaseInt(general.stats.intelligence),
experience: toLegacyDatabaseInt(general.experience),
dedication: toLegacyDatabaseInt(general.dedication),
officerLevel: general.officerLevel,
injury: general.injury,
gold: general.gold,
rice: general.rice,
crew: general.crew,
injury: toLegacyDatabaseInt(general.injury),
gold: toLegacyDatabaseInt(general.gold),
rice: toLegacyDatabaseInt(general.rice),
crew: toLegacyDatabaseInt(general.crew),
crewTypeId: general.crewTypeId,
train: general.train,
atmos: general.atmos,
train: toLegacyDatabaseInt(general.train),
atmos: toLegacyDatabaseInt(general.atmos),
age: general.age,
horseCode: toCode(general.role.items.horse),
weaponCode: toCode(general.role.items.weapon),
@@ -185,7 +220,7 @@ const buildGeneralCreate = (
specialCode: toCode(general.role.specialDomestic),
special2Code: toCode(general.role.specialWar),
lastTurn: asJson(general.lastTurn ?? { command: '휴식' }),
meta: asJson(withSerializedItemInventory(general.meta, ensureItemInventory(general))),
meta: buildPersistedGeneralMeta(general),
turnTime: general.turnTime,
recentWarTime: general.recentWarTime ?? null,
});
@@ -8,6 +8,8 @@ import type {
UnitSetDefinition,
} from '@sammo-ts/logic';
import {
LEGACY_RANDOM_GENERAL_FIRST_NAMES,
LEGACY_RANDOM_GENERAL_LAST_NAMES,
loadGeneralTurnCommandSpecs,
loadNationTurnCommandSpecs,
loadActionModuleBundle,
@@ -62,6 +64,15 @@ const resolveOptionalString = (source: Record<string, unknown>, keys: string[]):
return null;
};
const resolveStringList = (source: Record<string, unknown>, key: string, fallback: readonly string[]): string[] => {
const value = source[key];
if (!Array.isArray(value)) {
return [...fallback];
}
const result = value.filter((entry): entry is string => typeof entry === 'string');
return result.length > 0 ? result : [...fallback];
};
export const buildCommandEnv = (config: ScenarioConfig, unitSet?: UnitSetDefinition): TurnCommandEnv => {
const constValues = asRecord(config.const);
@@ -94,9 +105,29 @@ export const buildCommandEnv = (config: ScenarioConfig, unitSet?: UnitSetDefinit
),
defaultSpecialDomestic: resolveOptionalString(constValues, ['defaultSpecialDomestic']),
defaultSpecialWar: resolveOptionalString(constValues, ['defaultSpecialWar']),
npcStatTotal: resolveNumber(constValues, ['defaultStatNPCTotal', 'npcStatTotal'], config.stat.npcTotal),
npcStatMin: resolveNumber(constValues, ['defaultStatNPCMin', 'npcStatMin'], config.stat.npcMin),
npcStatMax: resolveNumber(constValues, ['defaultStatNPCMax', 'npcStatMax'], config.stat.npcMax),
randomGeneralFirstNames: resolveStringList(constValues, 'randGenFirstName', LEGACY_RANDOM_GENERAL_FIRST_NAMES),
randomGeneralMiddleNames: resolveStringList(constValues, 'randGenMiddleName', ['']),
randomGeneralLastNames: resolveStringList(constValues, 'randGenLastName', LEGACY_RANDOM_GENERAL_LAST_NAMES),
availablePersonalities: resolveStringList(constValues, 'availablePersonality', [
'che_안전',
'che_유지',
'che_재간',
'che_출세',
'che_할거',
'che_정복',
'che_패권',
'che_의협',
'che_대의',
'che_왕좌',
]),
initialNationGenLimit: resolveNumber(constValues, ['initialNationGenLimit'], DEFAULT_INITIAL_NATION_GEN_LIMIT),
maxTechLevel: resolveNumber(constValues, ['maxTechLevel'], DEFAULT_MAX_TECH_LEVEL),
maxStatLevel: resolveNumber(constValues, ['maxLevel'], 255),
maxDedicationLevel: resolveNumber(constValues, ['maxDedLevel'], 30),
statUpgradeLimit: resolveNumber(constValues, ['upgradeLimit'], 30),
techLevelIncYear: resolveNumber(constValues, ['techLevelIncYear'], 5),
initialAllowedTechLevel: resolveNumber(constValues, ['initialAllowedTechLevel'], 1),
baseGold: resolveNumber(constValues, ['baseGold', 'basegold'], DEFAULT_BASE_GOLD),
@@ -149,14 +180,9 @@ export const buildReservedTurnDefinitions = async (options: {
},
])
);
options.env.generalActionModules = [
...(options.env.generalActionModules ?? []),
...moduleBundle.general,
];
options.env.warActionModules = [
...(options.env.warActionModules ?? []),
...moduleBundle.war,
];
options.env.generalActionModules = [...(options.env.generalActionModules ?? []), ...moduleBundle.general];
options.env.warActionModules = [...(options.env.warActionModules ?? []), ...moduleBundle.war];
options.env.nationTraitModules = moduleBundle.nationTraitModules;
const generalSpecs = await loadGeneralTurnCommandSpecs(options.commandProfile.general);
const nationSpecs = await loadNationTurnCommandSpecs(options.commandProfile.nation);
+133 -7
View File
@@ -34,6 +34,7 @@ import {
type ItemModule,
type UniqueLotteryRunner,
} from '@sammo-ts/logic';
import { buildLegacyDefaultUniqueItemPool } from '@sammo-ts/logic/rewards/legacyUniqueItemPool.js';
import { LogCategory, LogFormat, LogScope } from '@sammo-ts/logic';
import { asRecord, LiteHashDRBG, RandUtil } from '@sammo-ts/common';
@@ -58,6 +59,98 @@ import type { AiReservedTurnProvider } from './ai/types.js';
const DEFAULT_ACTION = '휴식';
const LEGACY_STAT_CHANGE_GENERAL_ACTIONS = new Set([
'che_소집해제',
'che_랜덤임관',
'che_헌납',
'che_강행',
'che_정착장려',
'che_숙련전환',
'che_주민선정',
'che_모반시도',
'che_요양',
'che_거병',
'che_건국',
'che_증여',
'che_훈련',
'che_견문',
'che_무작위건국',
'che_화계',
'che_집합',
'cr_건국',
'che_이동',
'cr_맹훈련',
'che_인재탐색',
'che_귀환',
'che_사기진작',
'che_군량매매',
'che_기술연구',
'che_첩보',
'che_임관',
'che_상업투자',
'che_장비매매',
'che_장수대상임관',
'che_징병',
'che_단련',
'che_등용',
'che_하야',
'che_물자조달',
'che_선양',
'che_전투태세',
]);
const applyLegacyGeneralProgression = (
general: TurnGeneral,
previousGeneral: TurnGeneral,
actionKey: string,
env: TurnCommandEnv
): TurnGeneral => {
const maxStatLevel = env.maxStatLevel ?? 255;
const maxDedicationLevel = env.maxDedicationLevel ?? 30;
const expLevel = Math.max(
0,
Math.min(
maxStatLevel,
general.experience < 1_000
? Math.trunc(general.experience / 100)
: Math.trunc(Math.sqrt(general.experience / 10))
)
);
const dedicationLevel = Math.max(0, Math.min(maxDedicationLevel, Math.ceil(Math.sqrt(general.dedication) / 10)));
const meta = { ...general.meta };
if (general.experience !== previousGeneral.experience) {
meta.explevel = expLevel;
}
if (general.dedication !== previousGeneral.dedication) {
meta.dedlevel = dedicationLevel;
}
if (!LEGACY_STAT_CHANGE_GENERAL_ACTIONS.has(actionKey)) {
return { ...general, meta };
}
const stats = { ...general.stats };
const limit = env.statUpgradeLimit ?? 30;
const entries = [
['leadership', 'leadership_exp'],
['strength', 'strength_exp'],
['intelligence', 'intel_exp'],
] as const;
for (const [statKey, expKey] of entries) {
const rawExp = typeof meta[expKey] === 'number' ? meta[expKey] : 0;
if (rawExp < 0) {
meta[expKey] = rawExp + limit;
stats[statKey] -= 1;
} else if (rawExp >= limit) {
if (stats[statKey] < maxStatLevel) {
stats[statKey] += 1;
}
meta[expKey] = rawExp - limit;
}
}
return { ...general, stats, meta };
};
const resolveConstraintEnv = (
world: TurnWorldState,
scenarioMeta: ScenarioMeta | undefined,
@@ -571,6 +664,21 @@ class WorldStateView implements StateView {
const extractArgsRecord = (value: unknown): Record<string, unknown> => asRecord(value);
const withCanonicalArgumentAliases = (args: Record<string, unknown>): Record<string, unknown> => {
const normalized = { ...args };
for (const [legacyKey, canonicalKey] of [
['destCityID', 'destCityId'],
['destNationID', 'destNationId'],
['destGeneralID', 'destGeneralId'],
['destTroopID', 'destTroopId'],
] as const) {
if (normalized[canonicalKey] === undefined && normalized[legacyKey] !== undefined) {
normalized[canonicalKey] = normalized[legacyKey];
}
}
return normalized;
};
const buildConstraintContext = (
general: TurnGeneral,
city: City | undefined,
@@ -608,6 +716,7 @@ export const createReservedTurnHandler = async (options: {
unitSet?: UnitSetDefinition;
getWorld: () => InMemoryTurnWorld | null;
commandProfile?: TurnCommandProfile;
commandRngFactory?: (input: { kind: 'nation' | 'general'; actionKey: string; seed: string }) => RandUtil;
onActionResolved?: (payload: {
kind: 'nation' | 'general';
generalId: number;
@@ -622,6 +731,9 @@ export const createReservedTurnHandler = async (options: {
const env = buildCommandEnv(options.scenarioConfig, options.unitSet);
const itemRegistry = createItemModuleRegistry(await loadItemModules([...ITEM_KEYS]));
const uniqueConfig = resolveUniqueConfig(asRecord(options.scenarioConfig.const));
if (Object.keys(uniqueConfig.allItems).length === 0) {
uniqueConfig.allItems = buildLegacyDefaultUniqueItemPool(itemRegistry);
}
const commandProfile = options.commandProfile ?? DEFAULT_TURN_COMMAND_PROFILE;
const { general: generalDefinitions, nation: nationDefinitions } = await buildReservedTurnDefinitions({
env,
@@ -756,14 +868,15 @@ export const createReservedTurnHandler = async (options: {
cities: worldView?.listCities() ?? [],
nations: worldView?.listNations() ?? [],
};
const constraintArgs = withCanonicalArgumentAliases(actionArgs as Record<string, unknown>);
const constraintCtx = buildConstraintContext(
currentGeneral,
currentCity,
currentNation,
actionArgs as Record<string, unknown>,
constraintArgs,
actionConstraintEnv
);
const view = new WorldStateView(worldView, actionConstraintEnv, actionArgs as Record<string, unknown>, {
const view = new WorldStateView(worldView, actionConstraintEnv, constraintArgs, {
general: currentGeneral,
city: currentCity,
nation: currentNation,
@@ -807,6 +920,13 @@ export const createReservedTurnHandler = async (options: {
currentGeneral.id,
key
);
if (options.commandRngFactory) {
return options.commandRngFactory({
kind,
actionKey: key,
seed: rngSeed,
});
}
return new RandUtil(new LiteHashDRBG(rngSeed));
};
@@ -927,6 +1047,7 @@ export const createReservedTurnHandler = async (options: {
}
const lastTurnBeforeExecution = JSON.stringify(currentGeneral.lastTurn ?? {});
const generalBeforeExecution = currentGeneral;
const resolution = resolveGeneralAction(
definition,
actionContext,
@@ -940,6 +1061,14 @@ export const createReservedTurnHandler = async (options: {
currentGeneral = resolution.general as TurnGeneral;
currentCity = resolution.city ?? currentCity;
currentNation = resolution.nation ?? currentNation;
if (!resolution.alternative && !usedFallback) {
currentGeneral = applyLegacyGeneralProgression(
currentGeneral,
generalBeforeExecution,
actionKey,
env
);
}
if (
!resolution.alternative &&
kind === 'nation' &&
@@ -1091,11 +1220,10 @@ export const createReservedTurnHandler = async (options: {
}
}
const hasDiplomacyChange = diplomacyPatches.length > 0;
const hasNationChange = (resolution.patches?.cities ?? []).some((patch) =>
Object.prototype.hasOwnProperty.call(patch.patch ?? {}, 'nationId')
);
if (hasDiplomacyChange || hasNationChange) {
if (hasNationChange) {
const worldView = worldOverlay?.view ?? worldRef;
if (worldView && options.map) {
const frontPatches = buildFrontStatePatches({
@@ -1533,9 +1661,7 @@ export const createReservedTurnHandler = async (options: {
if (!deleteGeneral && currentGeneral.age >= retirementYear && currentGeneral.npcState === 0) {
currentGeneral = resetRetiredGeneral(currentGeneral);
lifecycleOutcome = 'retired';
logs.push(
createActionLog('나이가 들어 <R>은퇴</>하고 자손에게 자리를 물려줍니다.')
);
logs.push(createActionLog('나이가 들어 <R>은퇴</>하고 자손에게 자리를 물려줍니다.'));
}
currentGeneral = {
@@ -71,6 +71,7 @@ export type TurnTestHarnessOptions = {
};
worldRef?: { current: InMemoryTurnWorld | null };
onActionResolved?: Parameters<typeof createReservedTurnHandler>[0]['onActionResolved'];
commandRngFactory?: Parameters<typeof createReservedTurnHandler>[0]['commandRngFactory'];
wrapGeneralTurnHandler?: (handler: GeneralTurnHandler) => GeneralTurnHandler;
extraCalendarHandlers?: TurnCalendarHandler[];
collectLogs?: boolean;
@@ -109,6 +110,7 @@ export const createTurnTestHarness = async (options: TurnTestHarnessOptions) =>
unitSet: options.snapshot.unitSet,
getWorld: () => worldRef.current,
onActionResolved: options.onActionResolved,
commandRngFactory: options.commandRngFactory,
});
const generalTurnHandler = options.wrapGeneralTurnHandler ? options.wrapGeneralTurnHandler(handler) : handler;
@@ -70,8 +70,8 @@ const buildUnificationLog = (nationName: string): LogEntryDraft => ({
meta: {},
});
describe('NPC 선전포고·개전·통일 흐름 테스트', () => {
it('선전포고부터 개전/점유전/통일까지 진행되어야 한다', async () => {
describe('NPC 선전포고·개전·점령 흐름 테스트', () => {
it('선전포고부터 개전과 첫 도시 점령까지 진행되어야 한다', async () => {
const cities = buildLargeTestCities().map(maxCityStats);
const cityA1 = cities.find((city) => city.id === 1)!;
const cityA2 = cities.find((city) => city.id === 2)!;
@@ -245,7 +245,7 @@ describe('NPC 선전포고·개전·통일 흐름 테스트', () => {
},
};
const { runUntil, getCollectedLogs } = await createTurnTestHarness({
const { runUntil } = await createTurnTestHarness({
snapshot,
state,
schedule,
@@ -380,11 +380,14 @@ describe('NPC 선전포고·개전·통일 흐름 테스트', () => {
expect(warEntry).not.toBeNull();
expect(warEntry?.state).toBe(DIPLOMACY_STATE.WAR);
let prevNation1Cities = countCities(1, world);
let prevNation2Cities = countCities(2, world);
const initialNation1Cities = countCities(1, world);
const initialNation2Cities = countCities(2, world);
let prevNation1Cities = initialNation1Cities;
let prevNation2Cities = initialNation2Cities;
let occupationTransitions = 0;
let guard = 0;
while (prevNation1Cities > 0 && guard < 120) {
while (occupationTransitions === 0 && guard < 36) {
const next = addMonths(world.getState().currentYear, world.getState().currentMonth, 1);
await runUntil(
(current) =>
@@ -395,7 +398,7 @@ describe('NPC 선전포고·개전·통일 흐름 테스트', () => {
const nowNation1Cities = countCities(1, world);
const nowNation2Cities = countCities(2, world);
if (nowNation1Cities > 0 && nowNation2Cities > 0) {
if (occupationTransitions === 0 && nowNation1Cities > 0 && nowNation2Cities > 0) {
const warLoopEntry = findDiplomacyEntry(world);
if (!warLoopEntry) {
debug.dumpWatched('개전 이후 외교 상태 누락');
@@ -404,55 +407,22 @@ describe('NPC 선전포고·개전·통일 흐름 테스트', () => {
expect(warLoopEntry.state).toBe(DIPLOMACY_STATE.WAR);
}
expect(nowNation1Cities).toBeLessThanOrEqual(prevNation1Cities);
expect(nowNation2Cities).toBeGreaterThanOrEqual(prevNation2Cities);
if (nowNation1Cities !== prevNation1Cities || nowNation2Cities !== prevNation2Cities) {
occupationTransitions += 1;
}
prevNation1Cities = nowNation1Cities;
prevNation2Cities = nowNation2Cities;
guard += 1;
}
if (prevNation1Cities > 0) {
debug.dumpWatched('국가 1 도시 소멸 실패');
throw new Error('nation 1 cities not eliminated');
if (occupationTransitions === 0) {
debug.dumpWatched('개전 후 도시 점령 실패');
}
expect(occupationTransitions).toBeGreaterThan(0);
expect(prevNation1Cities !== initialNation1Cities || prevNation2Cities !== initialNation2Cities).toBe(true);
const allOwnedByNation2 = world.listCities().every((city) => city.nationId === 2);
expect(allOwnedByNation2).toBe(true);
const unifyCheckTarget = addMonths(world.getState().currentYear, world.getState().currentMonth, 1);
await runUntil(
(current) =>
current.currentYear > unifyCheckTarget.year ||
(current.currentYear === unifyCheckTarget.year && current.currentMonth >= unifyCheckTarget.month)
);
const worldMeta = world.getState().meta as Record<string, unknown>;
if (worldMeta.isUnited !== 2) {
debug.dumpWatched('통일 상태 누락');
}
expect(worldMeta.isUnited).toBe(2);
const logs = getCollectedLogs();
const hasUnificationLog = logs.some((log) => log.text.includes('전토를 통일하였습니다.'));
if (!hasUnificationLog) {
debug.dumpWatched('통일 로그 누락');
}
expect(hasUnificationLog).toBe(true);
const dispatchWindowEnd = addMonths(warTarget.year, warTarget.month, 2);
await runUntil(
(current) =>
current.currentYear > dispatchWindowEnd.year ||
(current.currentYear === dispatchWindowEnd.year && current.currentMonth >= dispatchWindowEnd.month)
);
const dispatchKeys: string[] = [];
for (let offset = 0; offset <= 2; offset += 1) {
const target = addMonths(warTarget.year, warTarget.month, offset);
dispatchKeys.push(`${target.year}-${String(target.month).padStart(2, '0')}`);
}
const dispatchCount = dispatchKeys.reduce((sum, key) => sum + (dispatchCounts.get(key) ?? 0), 0);
const dispatchCount = Array.from(dispatchCounts.values()).reduce((sum, count) => sum + count, 0);
if (dispatchCount <= 0) {
debug.dumpWatched('출병 기록 누락');
}
+11
View File
@@ -26,6 +26,17 @@ export class RandUtil {
return minInclusive + this.rng.nextInt(span - 1);
}
/**
* Draw from the wrapped RNG's inclusive integer domain directly.
*
* Unlike nextInt(min, max), this deliberately consumes an RNG call when
* maxInclusive is zero. Legacy RandUtil::choice() has that observable
* behavior for a one-element collection.
*/
public nextIntInclusive(maxInclusive: number): number {
return this.rng.nextInt(maxInclusive);
}
public nextBit(): boolean {
const bits = this.rng.nextBits(1);
return bits[0]! != 0;
@@ -26,9 +26,11 @@ export const buildWorldSummary = (world: ActionContextWorldRef | null): WorldSum
if (generals.length === 0) {
return { totalGeneralCount: 0, totalNpcCount: 0 };
}
const total = generals.length;
const npcCount = generals.filter((general) => general.npcState > 0).length;
const statSum = generals.reduce(
const countedGenerals = generals.filter((general) => general.npcState < 4);
const total = countedGenerals.length;
const totalGeneralCount = generals.filter((general) => general.npcState <= 2).length;
const totalNpcCount = generals.filter((general) => general.npcState >= 3 && general.npcState <= 4).length;
const statSum = countedGenerals.reduce(
(acc, general) => ({
leadership: acc.leadership + general.stats.leadership,
strength: acc.strength + general.stats.strength,
@@ -37,8 +39,8 @@ export const buildWorldSummary = (world: ActionContextWorldRef | null): WorldSum
{ leadership: 0, strength: 0, intelligence: 0 }
);
return {
totalGeneralCount: total,
totalNpcCount: npcCount,
totalGeneralCount,
totalNpcCount,
averageStats: {
leadership: statSum.leadership / total,
strength: statSum.strength / total,
@@ -1,6 +1,7 @@
import type { GeneralActionModule } from '@sammo-ts/logic/triggers/general-action.js';
import type { WarActionModule } from '@sammo-ts/logic/war/actions.js';
import type { UnitSetDefinition } from '@sammo-ts/logic/world/types.js';
import type { NationTraitModule } from '@sammo-ts/logic/triggers/special/nation/index.js';
export interface TurnCommandItemCatalogEntry {
slot: 'horse' | 'weapon' | 'book' | 'item';
@@ -34,9 +35,18 @@ export interface TurnCommandEnv {
defaultCrewTypeId: number;
defaultSpecialDomestic: string | null;
defaultSpecialWar: string | null;
npcStatTotal?: number;
npcStatMin?: number;
npcStatMax?: number;
randomGeneralFirstNames?: string[];
randomGeneralMiddleNames?: string[];
randomGeneralLastNames?: string[];
availablePersonalities?: string[];
initialNationGenLimit: number;
maxTechLevel: number;
maxStatLevel?: number;
maxDedicationLevel?: number;
statUpgradeLimit?: number;
techLevelIncYear?: number;
initialAllowedTechLevel?: number;
baseGold: number;
@@ -45,4 +55,5 @@ export interface TurnCommandEnv {
itemCatalog?: Record<string, TurnCommandItemCatalogEntry>;
generalActionModules?: Array<GeneralActionModule>;
warActionModules?: Array<WarActionModule>;
nationTraitModules?: Array<NationTraitModule>;
}
@@ -24,6 +24,17 @@ const DecRice = 0x800;
const Wounded = 0x1000;
const HeavyWounded = 0x2000;
type InclusiveRandomGenerator = GeneralActionResolveContext['rng'] & {
nextIntInclusive?: (maxInclusive: number) => number;
};
const legacyChoiceIndex = (rng: GeneralActionResolveContext['rng'], length: number): number => {
const inclusive = rng as InclusiveRandomGenerator;
return inclusive.nextIntInclusive
? inclusive.nextIntInclusive(length - 1)
: rng.nextInt(0, length);
};
const SIGHTSEEING_MESSAGES: Array<{
flags: number;
texts: string[];
@@ -138,7 +149,7 @@ const pickByWeight = (rng: GeneralActionResolveContext['rng']): { flags: number;
const weight = Math.max(entry.weight, 0);
cursor -= weight;
if (cursor <= 0) {
const index = rng.nextInt(0, entry.texts.length);
const index = legacyChoiceIndex(rng, entry.texts.length);
const text = entry.texts[index] ?? entry.texts[0] ?? '';
return { flags: entry.flags, text };
}
@@ -147,7 +158,7 @@ const pickByWeight = (rng: GeneralActionResolveContext['rng']): { flags: number;
if (!fallback) {
return { flags: 0, text: '' };
}
const index = rng.nextInt(0, fallback.texts.length);
const index = legacyChoiceIndex(rng, fallback.texts.length);
const text = fallback.texts[index] ?? fallback.texts[0] ?? '';
return { flags: fallback.flags, text };
};
@@ -140,7 +140,7 @@ export class ActionDefinition<
setMetaNumber(general.meta, dexKey, nextDex);
const expGain = general.crew / 400;
general.experience += expGain;
general.experience = Math.round(general.experience + expGain);
const statKey = pickByWeight(context.rng, {
leadership_exp: general.stats.leadership,
@@ -49,7 +49,17 @@ export class ActionResolver<
const resKey = picked === 'gold' ? 'gold' : 'rice';
// 2. Base Score
let score = general.stats.leadership + general.stats.strength + general.stats.intelligence;
const injuryMultiplier = (100 - general.injury) / 100;
const rawLeadership = general.stats.leadership * injuryMultiplier;
const rawStrength = general.stats.strength * injuryMultiplier;
const rawIntelligence = general.stats.intelligence * injuryMultiplier;
const maxStat = 255;
const legacyStat = (stat: 'leadership' | 'strength' | 'intelligence', value: number): number =>
Math.trunc(Math.max(0, Math.min(maxStat, this.pipeline.onCalcStat(context, stat, value))));
let score =
legacyStat('leadership', rawLeadership) +
legacyStat('strength', rawStrength + Math.round(rawIntelligence / 4)) +
legacyStat('intelligence', rawIntelligence + Math.round(rawStrength / 4));
const expLevel = typeof general.meta.explevel === 'number' ? general.meta.explevel : 0;
score *= 1 + expLevel / 500;
score *= context.rng.nextFloat1() * 0.4 + 0.8;
@@ -29,7 +29,7 @@ export interface BoostMoraleEnvironment {
generalActionModules?: TurnCommandEnv['generalActionModules'];
}
const ACTION_NAME = '사기 진작';
const ACTION_NAME = '사기진작';
const DEFAULT_ATMOS_DELTA = 5;
const DEFAULT_MAX_ATMOS = 100;
@@ -43,6 +43,7 @@ export interface TalentScoutWorldSummary {
totalGeneralCount: number;
totalNpcCount: number;
averageStats?: StatBlock;
averageDex?: [number, number, number, number, number];
}
export interface TalentScoutResolveContext<
@@ -54,6 +55,7 @@ export interface TalentScoutResolveContext<
generalPool?: TalentScoutCandidate[];
cityPool?: City[];
createGeneralId: () => number;
turnTermMinutes: number;
}
export interface TalentScoutEnvironment {
@@ -76,6 +78,13 @@ export interface TalentScoutEnvironment {
rng: RandomGenerator,
candidate: TalentScoutCandidate
) => StatBlock;
npcStatTotal?: number;
npcStatMin?: number;
npcStatMax?: number;
randomGeneralFirstNames?: string[];
randomGeneralMiddleNames?: string[];
randomGeneralLastNames?: string[];
availablePersonalities?: string[];
}
type StatExpKey = 'leadership_exp' | 'strength_exp' | 'intel_exp';
@@ -88,20 +97,6 @@ const DEFAULT_MAX_AGE = 25;
const DEFAULT_DEATH_MIN = 10;
const DEFAULT_DEATH_MAX = 50;
const resolveKillturnFromDeathYear = (
currentYear: number,
currentMonth: number,
deathYear: number,
rng: RandomGenerator
): number => {
if (!Number.isFinite(deathYear) || deathYear <= 0) {
return 0;
}
const deathMonth = randomRangeInt(rng, 1, 12);
const diff = (deathYear - currentYear) * 12 + (deathMonth - currentMonth);
return Math.max(diff, 0);
};
const addMetaValue = (
meta: Record<string, TriggerValue>,
key: string,
@@ -158,7 +153,7 @@ const calcFoundProp = (maxGeneral: number, totalGeneralCount: number, totalNpcCo
if (maxGeneral <= 0) {
return 0;
}
const current = totalGeneralCount + totalNpcCount / 2;
const current = Math.trunc(totalGeneralCount + totalNpcCount / 2);
const remainSlot = Math.max(maxGeneral - current, 0);
const main = Math.pow(remainSlot / maxGeneral, 6);
const small = 1 / (totalNpcCount / 3 + 1);
@@ -171,6 +166,22 @@ const calcFoundProp = (maxGeneral: number, totalGeneralCount: number, totalNpcCo
const randomRangeInt = (rng: RandomGenerator, min: number, max: number): number => rng.nextInt(min, max + 1);
type InclusiveRandomGenerator = RandomGenerator & {
nextIntInclusive?: (maxInclusive: number) => number;
};
const legacyChoiceIndex = (rng: RandomGenerator, length: number): number => {
if (length <= 0) {
throw new Error('Empty items');
}
const inclusive = rng as InclusiveRandomGenerator;
return inclusive.nextIntInclusive
? inclusive.nextIntInclusive(length - 1)
: rng.nextInt(0, length);
};
const legacyChoice = <T>(rng: RandomGenerator, values: readonly T[]): T => values[legacyChoiceIndex(rng, values.length)]!;
const resolveCandidate = (
context: TalentScoutResolveContext,
rng: RandomGenerator,
@@ -183,7 +194,7 @@ const resolveCandidate = (
if (pool.length === 0) {
return null;
}
const idx = rng.nextInt(0, pool.length);
const idx = legacyChoiceIndex(rng, pool.length);
return pool[idx] ?? null;
};
@@ -200,7 +211,7 @@ const resolveSpawnCityId = (
}
const pool = context.cityPool ?? [];
if (pool.length > 0) {
const idx = rng.nextInt(0, pool.length);
const idx = legacyChoiceIndex(rng, pool.length);
return pool[idx]!.id;
}
return context.general.cityId;
@@ -273,13 +284,6 @@ export class ActionResolver<
const prop = this.command.calcFoundProp(context);
const found = context.rng.nextBool(prop);
const statKey = pickStatExpKey(context.rng, general);
const metaAfter = found ? addMetaNumber(general.meta, statKey, 3) : addMetaNumber(general.meta, statKey, 1);
if (found) {
const active = typeof metaAfter.inherit_active_action === 'number' ? metaAfter.inherit_active_action : 0;
metaAfter.inherit_active_action = active + Math.max(Math.sqrt(1 / prop), 1);
}
const nextGold = Math.max(0, general.gold - reqGold);
const nextRice = Math.max(0, general.rice - reqRice);
const expGain = found ? 200 : 100;
@@ -290,9 +294,10 @@ export class ActionResolver<
general.rice = nextRice;
general.experience += expGain;
general.dedication += dedGain;
general.meta = metaAfter;
if (!found) {
const statKey = pickStatExpKey(context.rng, general);
general.meta = addMetaNumber(general.meta, statKey, 1);
context.addLog('인재를 찾을 수 없었습니다.', {
category: LogCategory.ACTION,
format: LogFormat.MONTH,
@@ -301,10 +306,6 @@ export class ActionResolver<
return { effects: [] };
}
const candidate = resolveCandidate(context, context.rng, this.env);
const newGeneralId = context.createGeneralId();
const resolvedCandidate: TalentScoutCandidate = candidate ?? { name: `NPC_${newGeneralId}` };
const age = randomRangeInt(
context.rng,
this.env.minNpcAge ?? DEFAULT_MIN_AGE,
@@ -318,45 +319,108 @@ export class ActionResolver<
this.env.minDeathYears ?? DEFAULT_DEATH_MIN,
this.env.maxDeathYears ?? DEFAULT_DEATH_MAX
);
const stats = resolveStats(context, context.rng, this.env, resolvedCandidate);
const candidate = resolveCandidate(context, context.rng, this.env);
const firstNames = this.env.randomGeneralFirstNames ?? ['가'];
const middleNames = this.env.randomGeneralMiddleNames ?? [''];
const lastNames = this.env.randomGeneralLastNames ?? ['가'];
const generatedName = `${legacyChoice(context.rng, firstNames)}${legacyChoice(
context.rng,
middleNames
)}${legacyChoice(context.rng, lastNames)}`;
const newGeneralId = context.createGeneralId();
const resolvedCandidate: TalentScoutCandidate = candidate ?? { name: generatedName };
const affinity = randomRangeInt(context.rng, 1, 150);
const npcStatTotal = this.env.npcStatTotal ?? 150;
const npcStatMin = this.env.npcStatMin ?? 10;
const npcStatMax = this.env.npcStatMax ?? 50;
const pickType = pickByWeight(context.rng, { : 6, : 6, 무지: 3 });
const mainStat = npcStatMax - randomRangeInt(context.rng, 0, npcStatMin);
const otherStat = npcStatMin + randomRangeInt(context.rng, 0, Math.trunc(npcStatMin / 2));
const subStat = npcStatTotal - mainStat - otherStat;
let generatedStats: StatBlock;
if (pickType === '무') {
generatedStats = { leadership: subStat, strength: mainStat, intelligence: otherStat };
} else if (pickType === '지') {
generatedStats = { leadership: subStat, strength: otherStat, intelligence: mainStat };
} else {
generatedStats = { leadership: otherStat, strength: subStat, intelligence: mainStat };
}
const stats = candidate?.stats
? resolveStats(context, context.rng, this.env, resolvedCandidate)
: generatedStats;
const averageDex = context.worldSummary.averageDex ?? [0, 0, 0, 0, 0];
const dexTotal = averageDex[0] + averageDex[1] + averageDex[2] + averageDex[3];
let dex: [number, number, number, number, number];
if (pickType === '무') {
const distributions = [
[dexTotal * 5 / 8, dexTotal / 8, dexTotal / 8, dexTotal / 8],
[dexTotal / 8, dexTotal * 5 / 8, dexTotal / 8, dexTotal / 8],
[dexTotal / 8, dexTotal / 8, dexTotal * 5 / 8, dexTotal / 8],
] as const;
const picked = legacyChoice(context.rng, distributions);
dex = [picked[0], picked[1], picked[2], picked[3], averageDex[4]];
} else if (pickType === '지') {
dex = [dexTotal / 8, dexTotal / 8, dexTotal / 8, dexTotal * 5 / 8, averageDex[4]];
} else {
dex = [dexTotal / 4, dexTotal / 4, dexTotal / 4, dexTotal / 4, averageDex[4]];
}
const personality =
resolvedCandidate.personality ??
legacyChoice(context.rng, this.env.availablePersonalities ?? ['che_안전']);
const name = this.env.decorateName
? this.env.decorateName(resolvedCandidate.name, NPC_TYPE)
: resolvedCandidate.name;
: `${resolvedCandidate.name}`;
const cityId = resolveSpawnCityId(context, context.rng, this.env);
const turnSecond = randomRangeInt(context.rng, 0, context.turnTermMinutes * 60 - 1);
const turnFraction = randomRangeInt(context.rng, 0, 999_999);
const killturn =
(deathYear - context.currentYear) * 12 +
randomRangeInt(context.rng, 0, 11) +
context.currentMonth -
1;
const meta: GeneralMeta = {
killturn: resolveKillturnFromDeathYear(context.currentYear, context.currentMonth, deathYear, context.rng),
killturn,
npcType: NPC_TYPE,
crewTypeId: this.env.defaultCrewTypeId,
affinity,
birthYear,
deathYear,
dex1: dex[0],
dex2: dex[1],
dex3: dex[2],
dex4: dex[3],
dex5: dex[4],
turnSecond,
turnFraction,
};
addMetaValue(meta, 'affinity', resolvedCandidate.affinity ?? null);
addMetaValue(meta, 'picture', resolvedCandidate.picture ?? null);
addMetaValue(meta, 'birthYear', birthYear);
addMetaValue(meta, 'text', resolvedCandidate.text ?? null);
const newGeneral = buildRecruitmentGeneral<TriggerState>({
id: newGeneralId,
name,
nationId: 0,
cityId: resolveSpawnCityId(context, context.rng, this.env),
cityId,
stats,
officerLevel: 0,
age,
npcState: NPC_TYPE,
gold: this.env.defaultNpcGold,
rice: this.env.defaultNpcRice,
experience: 0,
dedication: 0,
experience: age * 100,
dedication: age * 100,
crewTypeId: this.env.defaultCrewTypeId,
role: {
personality: resolvedCandidate.personality ?? null,
specialDomestic: this.env.defaultSpecialDomestic,
specialWar: this.env.defaultSpecialWar,
personality,
specialDomestic: null,
specialWar: null,
},
meta,
});
const nameObjJosa = JosaUtil.pick(name, '을');
const nameSubjJosa = JosaUtil.pick(name, '이');
const recruitVerb = randomRangeInt(context.rng, 0, 1) === 0 ? '발견' : '등용';
const recruitVerb = '발견';
const nameRa = JosaUtil.pick(name, '라');
context.addLog(`<Y>${name}</>${nameRa}는 <C>인재</>를 ${recruitVerb}하였습니다!`, {
category: LogCategory.ACTION,
@@ -374,6 +438,12 @@ export class ActionResolver<
tryApplyUniqueLottery(context, { acquireType: '아이템', reason: ACTION_NAME });
const statKey = pickStatExpKey(context.rng, general);
const metaAfter = addMetaNumber(general.meta, statKey, 3);
const active = typeof metaAfter.inherit_active_action === 'number' ? metaAfter.inherit_active_action : 0;
metaAfter.inherit_active_action = active + Math.max(Math.sqrt(1 / prop), 1);
general.meta = metaAfter;
return {
effects: [createGeneralAddEffect(newGeneral)],
};
@@ -418,8 +488,25 @@ export const actionContextBuilder: ActionContextBuilder = (base, options) => ({
...base,
currentYear: options.world.currentYear,
currentMonth: options.world.currentMonth,
worldSummary: buildWorldSummary(options.worldRef),
worldSummary: {
...buildWorldSummary(options.worldRef),
averageDex: (() => {
const generals = options.worldRef?.listGenerals().filter((general) => general.npcState < 4) ?? [];
if (generals.length === 0) {
return [0, 0, 0, 0, 0] as [number, number, number, number, number];
}
return [1, 2, 3, 4, 5].map(
(armType) =>
generals.reduce((sum, general) => {
const value = general.meta[`dex${armType}`];
return sum + (typeof value === 'number' ? value : 0);
}, 0) / generals.length
) as [number, number, number, number, number];
})(),
},
cityPool: options.worldRef?.listCities() ?? [],
createGeneralId: options.createGeneralId,
turnTermMinutes: Math.max(1, Math.round(options.world.tickSeconds / 60)),
});
export const commandSpec: GeneralTurnCommandSpec = {
@@ -427,5 +514,6 @@ export const commandSpec: GeneralTurnCommandSpec = {
category: '인사',
reqArg: false,
createDefinition: (env: TurnCommandEnv) => new ActionDefinition(env.generalActionModules ?? [], env),
createDefinition: (env: TurnCommandEnv) =>
new ActionDefinition(env.generalActionModules ?? [], env),
};
@@ -31,6 +31,7 @@ import type { WarAftermathConfig, WarEngineConfig, WarTimeContext } from '@sammo
import { resolveWarAftermath } from '@sammo-ts/logic/war/aftermath.js';
import { resolveWarBattle } from '@sammo-ts/logic/war/engine.js';
import type { WarActionModule } from '@sammo-ts/logic/war/actions.js';
import type { NationTraitModule } from '@sammo-ts/logic/triggers/special/nation/index.js';
import { increaseMetaNumber, simpleSerialize } from '@sammo-ts/logic/war/utils.js';
import type { MapDefinition, UnitSetDefinition } from '@sammo-ts/logic/world/types.js';
import type { ActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js';
@@ -157,6 +158,15 @@ const pickCandidateCity = (
distanceList: Map<number, Array<[number, number]>>,
attackerNationId: number
): { cityId: number; isEnemy: boolean; minDist: number } | null => {
const pickLegacyChoice = <T>(items: T[]): T => {
const legacyCompatibleRng = rng as typeof rng & {
nextIntInclusive?: (maxInclusive: number) => number;
};
const index =
legacyCompatibleRng.nextIntInclusive?.(items.length - 1) ??
rng.nextInt(0, items.length);
return items[index]!;
};
const distances = Array.from(distanceList.keys()).sort((a, b) => a - b);
const minDist = distances[0];
if (minDist === undefined) {
@@ -174,8 +184,9 @@ const pickCandidateCity = (
}
}
if (candidates.length > 0) {
const index = rng.nextInt(0, candidates.length);
const [cityId] = candidates[index] ?? candidates[0]!;
// Legacy RandUtil::choice() consumes nextInt(0) even when there is a
// single candidate. Keep that observable RNG step for seed parity.
const [cityId] = pickLegacyChoice(candidates);
return { cityId, isEnemy: true, minDist };
}
const fallback = distanceList.get(minDist) ?? [];
@@ -183,8 +194,7 @@ const pickCandidateCity = (
if (friendly.length === 0) {
return null;
}
const index = rng.nextInt(0, friendly.length);
const [cityId] = friendly[index] ?? friendly[0]!;
const [cityId] = pickLegacyChoice(friendly);
return { cityId, isEnemy: false, minDist };
};
@@ -245,9 +255,14 @@ export class ActionDefinition<
return 1;
}
private readonly warModules: Array<WarActionModule<TriggerState>>;
private readonly nationTraitModules: Map<string, NationTraitModule>;
constructor(modules: Array<WarActionModule<TriggerState> | null | undefined> = []) {
constructor(
modules: Array<WarActionModule<TriggerState> | null | undefined> = [],
nationTraitModules: NationTraitModule[] = []
) {
this.warModules = modules.filter(Boolean) as Array<WarActionModule<TriggerState>>;
this.nationTraitModules = new Map(nationTraitModules.map((module) => [module.key, module]));
}
parseArgs(raw: unknown): DispatchArgs | null {
@@ -400,6 +415,11 @@ export class ActionDefinition<
const nationMap = new Map(nations.map((nation) => [nation.id, nation]));
const defenderCity = cityMap.get(destCity.id) ?? cloneCity(destCity);
// Legacy marks the destination as an active battle for three turns
// before processWar(), including when the attack immediately conquers
// the city. ConquerCity resets term but intentionally leaves state 43.
defenderCity.state = 43;
defenderCity.meta.term = 3;
const defenderNation = defenderCity.nationId > 0 ? (nationMap.get(defenderCity.nationId) ?? null) : null;
const defenderGenerals = generals.filter(
@@ -444,6 +464,17 @@ export class ActionDefinition<
config: context.aftermathConfig,
time,
hiddenSeed: context.seedBase,
calcNationTechGain: ({ nation, baseGain }) => {
const module = this.nationTraitModules.get(nation.typeCode);
return (
module?.onCalcDomestic?.(
{ general: context.general, nation },
'기술',
'score',
baseGain
) ?? baseGain
);
},
});
const effects: Array<GeneralActionEffect<TriggerState>> = [];
@@ -559,5 +590,6 @@ export const commandSpec: GeneralTurnCommandSpec = {
reqArg: true,
availabilityArgs: { destCityId: 0 },
argsSchema: ARGS_SCHEMA,
createDefinition: (env: TurnCommandEnv) => new ActionDefinition(env.warActionModules ?? []),
createDefinition: (env: TurnCommandEnv) =>
new ActionDefinition(env.warActionModules ?? [], env.nationTraitModules ?? []),
};
@@ -15,6 +15,7 @@ import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js'
import { defaultActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js';
import { tryApplyUniqueLottery } from '@sammo-ts/logic/rewards/uniqueLottery.js';
import type { GeneralTurnCommandSpec } from './index.js';
import { GeneralActionPipeline } from '@sammo-ts/logic/triggers/general-action.js';
const ACTION_NAME = '맹훈련';
const ACTION_KEY = 'cr_맹훈련';
@@ -36,8 +37,11 @@ export class ActionDefinition<
> implements GeneralActionDefinition<TriggerState, FierceTrainingArgs> {
public readonly key = ACTION_KEY;
public readonly name = ACTION_NAME;
private readonly pipeline: GeneralActionPipeline<TriggerState>;
constructor(private readonly env: TurnCommandEnv) {}
constructor(private readonly env: TurnCommandEnv) {
this.pipeline = new GeneralActionPipeline(env.generalActionModules ?? []);
}
parseArgs(_raw: unknown): FierceTrainingArgs | null {
return {};
@@ -67,7 +71,8 @@ export class ActionDefinition<
const maxTrain = this.env.maxTrainByCommand > 0 ? this.env.maxTrainByCommand : 100;
const maxAtmos = this.env.maxAtmosByCommand > 0 ? this.env.maxAtmosByCommand : 100;
const score = Math.round((general.stats.leadership * 100 * trainDelta * 2) / (Math.max(general.crew, 1) * 3));
const leadership = this.pipeline.onCalcStat(context, 'leadership', general.stats.leadership);
const score = Math.round((leadership * 100 * trainDelta * 2) / (Math.max(general.crew, 1) * 3));
const scoreText = score.toLocaleString('en-US');
context.addLog(`훈련, 사기치가 <C>${scoreText}</> 상승했습니다.`, {
@@ -81,11 +86,13 @@ export class ActionDefinition<
const nextTrain = clamp(general.train + score, 0, maxTrain);
const nextAtmos = clamp(general.atmos + score, 0, maxAtmos);
const leadershipExp = typeof general.meta.leadership_exp === 'number' ? general.meta.leadership_exp : 0;
const crewType = this.env.unitSet?.crewTypes?.find((entry) => entry.id === general.crewTypeId);
const dexKey = crewType ? `dex${crewType.armType}` : null;
const currentDex = dexKey && typeof general.meta[dexKey] === 'number' ? general.meta[dexKey] : 0;
return {
effects: [
createGeneralPatchEffect<TriggerState>({
rice: Math.max(0, general.rice - 500),
train: nextTrain,
atmos: nextAtmos,
experience: general.experience + 150,
@@ -93,6 +100,7 @@ export class ActionDefinition<
meta: {
...general.meta,
leadership_exp: leadershipExp + 1,
...(dexKey ? { [dexKey]: currentDex + score * 2 } : {}),
},
}),
],
@@ -93,7 +93,7 @@ export class ActionDefinition<
}
resolve(context: SeizureResolveContext<TriggerState>, args: SeizureArgs): GeneralActionOutcome<TriggerState> {
const { general, nation, destGeneral } = context;
const { nation, destGeneral } = context;
if (!nation) {
return { effects: [createLogEffect('국가 정보가 없습니다.', { scope: LogScope.GENERAL })] };
}
@@ -147,9 +147,6 @@ export class ActionDefinition<
}),
];
general.experience += 5;
general.dedication += 5;
return { effects };
}
}
+168 -17
View File
@@ -2,20 +2,173 @@ import { LiteHashDRBG, RandUtil } from '@sammo-ts/common';
import { simpleSerialize } from '../war/utils.js';
const DEFAULT_FIRST_NAMES = [
'가', '간', '감', '강', '고', '공', '공손', '곽', '관', '괴', '교', '금', '노', '뇌', '능', '도', '동',
'두', '등', '마', '맹', '문', '미', '반', '방', '부', '비', '사', '사마', '서', '설', '성', '소', '손',
'송', '순', '신', '심', '악', '안', '양', '엄', '여', '염', '오', '왕', '요', '우', '원', '위', '유',
'육', '윤', '이', '장', '저', '전', '정', '제갈', '조', '종', '주', '진', '채', '태사', '하', '하후',
'학', '한', '향', '허', '호', '화', '황', '공손', '손', '왕', '유', '장', '조',
export const LEGACY_RANDOM_GENERAL_FIRST_NAMES = [
'가',
'',
'',
'',
'',
'공',
'공손',
'곽',
'관',
'괴',
'교',
'금',
'노',
'뇌',
'능',
'도',
'동',
'두',
'등',
'마',
'맹',
'문',
'미',
'반',
'방',
'부',
'비',
'사',
'사마',
'서',
'설',
'성',
'소',
'손',
'송',
'순',
'신',
'심',
'악',
'안',
'양',
'엄',
'여',
'염',
'오',
'왕',
'요',
'우',
'원',
'위',
'유',
'육',
'윤',
'이',
'장',
'저',
'전',
'정',
'제갈',
'조',
'종',
'주',
'진',
'채',
'태사',
'하',
'하후',
'학',
'한',
'향',
'허',
'호',
'화',
'황',
'공손',
'손',
'왕',
'유',
'장',
'조',
] as const;
const DEFAULT_LAST_NAMES = [
'가', '간', '강', '거', '건', '검', '견', '경', '공', '광', '권', '규', '녕', '단', '대', '도', '등',
'람', '량', '례', '로', '료', '모', '민', '박', '범', '보', '비', '사', '상', '색', '서', '소', '속',
'송', '수', '순', '습', '승', '양', '연', '영', '온', '옹', '완', '우', '웅', '월', '위', '유', '윤',
'융', '이', '익', '임', '정', '제', '조', '주', '준', '지', '찬', '책', '충', '탁', '택', '통', '패',
'평', '포', '합', '해', '혁', '현', '화', '환', '회', '횡', '후', '훈', '휴', '흠', '흥',
export const LEGACY_RANDOM_GENERAL_LAST_NAMES = [
'가',
'',
'',
'',
'',
'검',
'견',
'경',
'공',
'광',
'권',
'규',
'녕',
'단',
'대',
'도',
'등',
'람',
'량',
'례',
'로',
'료',
'모',
'민',
'박',
'범',
'보',
'비',
'사',
'상',
'색',
'서',
'소',
'속',
'송',
'수',
'순',
'습',
'승',
'양',
'연',
'영',
'온',
'옹',
'완',
'우',
'웅',
'월',
'위',
'유',
'윤',
'융',
'이',
'익',
'임',
'정',
'제',
'조',
'주',
'준',
'지',
'찬',
'책',
'충',
'탁',
'택',
'통',
'패',
'평',
'포',
'합',
'해',
'혁',
'현',
'화',
'환',
'회',
'횡',
'후',
'훈',
'휴',
'흠',
'흥',
] as const;
const readNameParts = (value: unknown, fallback: readonly string[]): string[] => {
@@ -31,9 +184,9 @@ export const buildAuctionAlias = (
hiddenSeed: string | number,
configConst: Record<string, unknown> = {}
): string => {
const firstNames = readNameParts(configConst.randGenFirstName, DEFAULT_FIRST_NAMES);
const firstNames = readNameParts(configConst.randGenFirstName, LEGACY_RANDOM_GENERAL_FIRST_NAMES);
const middleNames = readNameParts(configConst.randGenMiddleName, ['']);
const lastNames = readNameParts(configConst.randGenLastName, DEFAULT_LAST_NAMES);
const lastNames = readNameParts(configConst.randGenLastName, LEGACY_RANDOM_GENERAL_LAST_NAMES);
const pool: string[] = [];
for (const first of firstNames) {
for (const middle of middleNames) {
@@ -42,9 +195,7 @@ export const buildAuctionAlias = (
}
}
}
const shuffled = new RandUtil(
new LiteHashDRBG(simpleSerialize(hiddenSeed, 'obfuscatedNamePool'))
).shuffle(pool);
const shuffled = new RandUtil(new LiteHashDRBG(simpleSerialize(hiddenSeed, 'obfuscatedNamePool'))).shuffle(pool);
const normalizedId = Math.max(0, Math.floor(generalId));
const duplicateIndex = Math.floor(normalizedId / shuffled.length);
const name = shuffled[normalizedId % shuffled.length] ?? `익명${normalizedId}`;
@@ -0,0 +1,127 @@
import type { ItemModule } from '@sammo-ts/logic/items/types.js';
import type { UniqueItemPool } from './uniqueLottery.js';
const LEGACY_UNIQUE_ITEM_KEYS: Readonly<Record<ItemModule['slot'], readonly string[]>> = {
horse: [
'che_명마_07_백마',
'che_명마_07_기주마',
'che_명마_07_오환마',
'che_명마_07_백상',
'che_명마_08_양주마',
'che_명마_08_흉노마',
'che_명마_09_과하마',
'che_명마_09_의남백마',
'che_명마_10_대완마',
'che_명마_10_옥추마',
'che_명마_11_서량마',
'che_명마_11_화종마',
'che_명마_12_사륜거',
'che_명마_12_옥란백용구',
'che_명마_13_절영',
'che_명마_13_적로',
'che_명마_14_적란마',
'che_명마_14_조황비전',
'che_명마_15_한혈마',
'che_명마_15_적토마',
],
weapon: [
'che_무기_07_동추',
'che_무기_07_철편',
'che_무기_07_철쇄',
'che_무기_07_맥궁',
'che_무기_08_유성추',
'che_무기_08_철질여골',
'che_무기_09_쌍철극',
'che_무기_09_동호비궁',
'che_무기_10_삼첨도',
'che_무기_10_대부',
'che_무기_11_고정도',
'che_무기_11_이광궁',
'che_무기_12_철척사모',
'che_무기_12_칠성검',
'che_무기_13_사모',
'che_무기_13_양유기궁',
'che_무기_14_언월도',
'che_무기_14_방천화극',
'che_무기_15_청홍검',
'che_무기_15_의천검',
],
book: [
'che_서적_07_위료자',
'che_서적_07_사마법',
'che_서적_07_한서',
'che_서적_07_논어',
'che_서적_08_전론',
'che_서적_08_사기',
'che_서적_09_장자',
'che_서적_09_역경',
'che_서적_10_시경',
'che_서적_10_구국론',
'che_서적_11_상군서',
'che_서적_11_춘추전',
'che_서적_12_산해경',
'che_서적_12_맹덕신서',
'che_서적_13_관자',
'che_서적_13_병법24편',
'che_서적_14_한비자',
'che_서적_14_오자병법',
'che_서적_15_노자',
'che_서적_15_손자병법',
],
item: [
'che_의술_정력견혈산',
'che_의술_청낭서',
'che_의술_태평청령',
'che_의술_상한잡병론',
'che_보물_도기',
'che_조달_주판',
'che_내정_납금박산로',
'che_전략_평만지장도',
'che_숙련_동작',
'che_명성_구석',
'che_척사_오악진형도',
'che_격노_구정신단경',
'che_징병_낙주',
'che_저격_매화수전',
'che_저격_비도',
'che_위압_조목삭',
'che_공성_묵자',
'che_집중_전국책',
'che_환술_논어집해',
'che_진압_박혁론',
'che_부적_태현청생부',
'che_저지_삼황내문',
'che_행동_서촉지형도',
'che_간파_노군입산부',
'che_불굴_상편',
'che_약탈_옥벽',
'che_농성_주서음부',
'che_농성_위공자병법',
'che_계략_육도',
'che_계략_삼략',
'che_상성보정_과실주',
'che_능력치_지력_이강주',
'che_능력치_무력_두강주',
'che_능력치_통솔_보령압주',
'che_훈련_철벽서',
'che_훈련_단결도',
'che_사기_춘화첩',
'che_사기_초선화',
'che_회피_태평요술',
'che_필살_둔갑천서',
],
};
export const buildLegacyDefaultUniqueItemPool = (itemRegistry: Map<string, ItemModule>): UniqueItemPool => {
const pool: UniqueItemPool = { horse: {}, weapon: {}, book: {}, item: {} };
for (const slot of ['horse', 'weapon', 'book', 'item'] as const) {
const count = slot === 'item' ? 1 : 2;
for (const itemKey of LEGACY_UNIQUE_ITEM_KEYS[slot]) {
const item = itemRegistry.get(itemKey);
if (item?.slot === slot && !item.buyable) {
pool[slot]![itemKey] = count;
}
}
}
return pool;
};
@@ -26,17 +26,19 @@ import {
TraitWarActionRouter,
WAR_TRAIT_KEYS,
} from './special/index.js';
import type { NationTraitModule } from './special/nation/index.js';
export interface ActionModuleBundle<TriggerState extends GeneralTriggerState = GeneralTriggerState> {
general: GeneralActionModule<TriggerState>[];
war: WarActionModule<TriggerState>[];
itemModules: ItemModule<TriggerState>[];
nationTraitModules: NationTraitModule[];
}
// General::getActionList와 같은 소유권 순서로 실제 턴과 시뮬레이터의 모듈을 조립한다.
export const loadActionModuleBundle = async <
TriggerState extends GeneralTriggerState = GeneralTriggerState,
>(unitSet?: UnitSetDefinition): Promise<ActionModuleBundle<TriggerState>> => {
export const loadActionModuleBundle = async <TriggerState extends GeneralTriggerState = GeneralTriggerState>(
unitSet?: UnitSetDefinition
): Promise<ActionModuleBundle<TriggerState>> => {
const [domestic, war, personality, nation, itemModules] = await Promise.all([
loadDomesticTraitModules([...DOMESTIC_TRAIT_KEYS]),
loadWarTraitModules([...WAR_TRAIT_KEYS]),
@@ -74,5 +76,6 @@ export const loadActionModuleBundle = async <
...items.war,
],
itemModules,
nationTraitModules: nation,
};
};
+23 -6
View File
@@ -17,6 +17,17 @@ import { clamp, clampMin, getMetaNumber, round, simpleSerialize } from './utils.
const META_DEAD = 'dead';
const META_CONFLICT = 'conflict';
const MAX_EXP_LEVEL = 255;
const MAX_DEDICATION_LEVEL = 30;
const updateLegacyProgressionLevels = (general: General): void => {
const expLevel =
general.experience < 1_000
? Math.trunc(general.experience / 100)
: Math.trunc(Math.sqrt(general.experience / 10));
general.meta.explevel = clamp(expLevel, 0, MAX_EXP_LEVEL);
general.meta.dedlevel = clamp(Math.ceil(Math.sqrt(general.dedication) / 10), 0, MAX_DEDICATION_LEVEL);
};
const findReport = (reports: WarUnitReport[], predicate: (report: WarUnitReport) => boolean): WarUnitReport | null => {
for (const report of reports) {
@@ -78,7 +89,7 @@ const applyNationTechGain = <TriggerState extends GeneralTriggerState>(
nation: Nation,
baseGain: number,
input: WarAftermathInput<TriggerState>,
context: WarAftermathTechContext
context: Omit<WarAftermathTechContext, 'baseGain'>
): void => {
const config = input.config;
let gain = baseGain;
@@ -102,7 +113,10 @@ const applyNationTechGain = <TriggerState extends GeneralTriggerState>(
const divisor = Math.max(config.initialNationGenLimit, total);
const tech = getMetaNumber(nation.meta, 'tech', 0) + gain / divisor;
nation.meta.tech = round(tech);
// Legacy MySQL FLOAT values are read back at the command boundary with
// two-decimal precision. Preserve fractional accumulation without
// converting the gain to an integer.
nation.meta.tech = Math.round(tech * 100) / 100;
};
const resolveConquerNation = (city: City, attackerNationId: number, nations: Nation[]): number => {
@@ -241,12 +255,14 @@ const resolveConquerCity = <TriggerState extends GeneralTriggerState>(
let totalRiceLoss = 0;
for (const general of defenderGenerals) {
const loseGold = round(general.gold * rng.nextRange(0.2, 0.5));
const loseRice = round(general.rice * rng.nextRange(0.2, 0.5));
// Legacy Util::toInt truncates these losses rather than rounding.
const loseGold = Math.trunc(general.gold * rng.nextRange(0.2, 0.5));
const loseRice = Math.trunc(general.rice * rng.nextRange(0.2, 0.5));
general.gold = clampMin(general.gold - loseGold, 0);
general.rice = clampMin(general.rice - loseRice, 0);
general.experience = round(general.experience * 0.9);
general.dedication = round(general.dedication * 0.5);
updateLegacyProgressionLevels(general);
totalGoldLoss += loseGold;
totalRiceLoss += loseRice;
@@ -263,8 +279,8 @@ const resolveConquerCity = <TriggerState extends GeneralTriggerState>(
affectedGenerals.add(general);
}
collapseRewardGold = Math.max(0, defenderNation.gold - config.baseGold) * 0.5 + totalGoldLoss * 0.5;
collapseRewardRice = Math.max(0, defenderNation.rice - config.baseRice) * 0.5 + totalRiceLoss * 0.5;
collapseRewardGold = Math.floor((Math.max(0, defenderNation.gold - config.baseGold) + totalGoldLoss) / 2);
collapseRewardRice = Math.floor((Math.max(0, defenderNation.rice - config.baseRice) + totalRiceLoss) / 2);
attackerNation.gold = round(attackerNation.gold + collapseRewardGold);
attackerNation.rice = round(attackerNation.rice + collapseRewardRice);
@@ -329,6 +345,7 @@ const resolveConquerCity = <TriggerState extends GeneralTriggerState>(
// 점령 후 도시 상태를 방어 기본 상태로 되돌린다.
defenderCity.supplyState = 1;
defenderCity.frontState = 0;
defenderCity.meta.term = 0;
defenderCity.agriculture = round(defenderCity.agriculture * 0.7);
defenderCity.commerce = round(defenderCity.commerce * 0.7);
defenderCity.security = round(defenderCity.security * 0.7);
+1 -1
View File
@@ -143,7 +143,7 @@ export interface WarAftermathTechContext {
side: 'attacker' | 'defender';
nation: Nation;
attackerReport: WarUnitReport;
baseGain?: number;
baseGain: number;
}
export interface WarDiplomacyDelta {
+3 -3
View File
@@ -27,9 +27,9 @@ const META_TURN_TIME = 'turnTime';
const META_RECENT_WAR = 'recentWar';
const META_DEX_PREFIX = 'dex';
const META_RANK_PREFIX = 'rank_';
const META_INTEL_EXP = 'intelExp';
const META_STRENGTH_EXP = 'strengthExp';
const META_LEADERSHIP_EXP = 'leadershipExp';
const META_INTEL_EXP = 'intel_exp';
const META_STRENGTH_EXP = 'strength_exp';
const META_LEADERSHIP_EXP = 'leadership_exp';
const MAX_EXP_LEVEL = 255;
const RANK_WARNUM = `${META_RANK_PREFIX}warnum`;
+2 -2
View File
@@ -168,8 +168,8 @@ describe('war aftermath', () => {
},
});
expect(attackerNation.meta.tech).toBe(1001);
expect(defenderNation.meta.tech).toBe(1001);
expect(attackerNation.meta.tech).toBe(1000.6);
expect(defenderNation.meta.tech).toBe(1000.9);
expect(outcome.diplomacyDeltas).toHaveLength(2);
expect(attackerCity.meta.dead).toBe(60);
expect(defenderCity.meta.dead).toBe(90);