diff --git a/app/game-api/src/battleSim/environment.ts b/app/game-api/src/battleSim/environment.ts index dde3c81b..9e95fcfb 100644 --- a/app/game-api/src/battleSim/environment.ts +++ b/app/game-api/src/battleSim/environment.ts @@ -102,6 +102,7 @@ export const buildBattleSimEnvironment = async ( maxTrainByWar: resolveNumber(constValues, ['maxTrainByWar'], DEFAULT_WAR_CONFIG.maxTrainByWar), maxAtmosByWar: resolveNumber(constValues, ['maxAtmosByWar'], DEFAULT_WAR_CONFIG.maxAtmosByWar), maxGeneralStat: resolveNumber(constValues, ['maxLevel'], 255), + statUpgradeLimit: resolveNumber(constValues, ['upgradeLimit'], 30), castleCrewTypeId, armTypes: { footman: 1, diff --git a/app/game-api/src/tournament/workerHelpers.ts b/app/game-api/src/tournament/workerHelpers.ts index eda261bd..70adf3d7 100644 --- a/app/game-api/src/tournament/workerHelpers.ts +++ b/app/game-api/src/tournament/workerHelpers.ts @@ -37,12 +37,17 @@ export const nextStage = (stage: number): number => { } }; +const resolveScheduledBaseMs = (state: TournamentState): number => { + const scheduled = new Date(state.nextAt).getTime(); + return Number.isFinite(scheduled) ? scheduled : Date.now(); +}; + export const resolveNextAt = (state: TournamentState): string => - new Date(Date.now() + Math.max(1, state.termSeconds) * 1000).toISOString(); + new Date(resolveScheduledBaseMs(state) + Math.max(1, state.termSeconds) * 1000).toISOString(); export const resolveBettingCloseAt = (state: TournamentState): string => { const bettingTermMs = Math.min(state.termSeconds * 60, 3600) * 1000; - return new Date(Date.now() + Math.max(1000, bettingTermMs)).toISOString(); + return new Date(resolveScheduledBaseMs(state) + Math.max(1000, bettingTermMs)).toISOString(); }; export const resolveStatValue = ( diff --git a/app/game-api/test/tournamentWorker.test.ts b/app/game-api/test/tournamentWorker.test.ts index 420cfa79..fbe43972 100644 --- a/app/game-api/test/tournamentWorker.test.ts +++ b/app/game-api/test/tournamentWorker.test.ts @@ -12,7 +12,7 @@ import type { TournamentState, } from '../src/tournament/types.js'; import { applyBattle, applyPreBattleStage, settleTournamentOutcome } from '../src/tournament/worker.js'; -import { buildBettingPayouts } from '../src/tournament/workerHelpers.js'; +import { buildBettingPayouts, resolveBettingCloseAt, resolveNextAt } from '../src/tournament/workerHelpers.js'; import type { TurnDaemonTransport } from '../src/daemon/transport.js'; class MemoryRedis { @@ -210,6 +210,18 @@ const runTournamentToCompletion = async (options: { const delayTick = async (): Promise => new Promise((resolve) => setTimeout(resolve, 0)); +describe('tournament worker schedule compatibility', () => { + it('catches up from the stored schedule instead of discarding elapsed legacy phases', () => { + const state = createTournamentState({ + termSeconds: 600, + nextAt: '2026-08-02T10:00:00.000Z', + }); + + expect(resolveNextAt(state)).toBe('2026-08-02T10:10:00.000Z'); + expect(resolveBettingCloseAt(state)).toBe('2026-08-02T11:00:00.000Z'); + }); +}); + describe('tournament worker (in-memory)', () => { it('당첨자가 없으면 레거시와 같이 베팅금을 지급하거나 환불하지 않는다', () => { expect( diff --git a/app/game-engine/src/lifecycle/turnDaemonLifecycle.ts b/app/game-engine/src/lifecycle/turnDaemonLifecycle.ts index d8e2e1f3..877913a4 100644 --- a/app/game-engine/src/lifecycle/turnDaemonLifecycle.ts +++ b/app/game-engine/src/lifecycle/turnDaemonLifecycle.ts @@ -167,7 +167,11 @@ export class TurnDaemonLifecycle { const nowMs = this.clock.nowMs(); const nextTurnMs = nextRunTime.getTime(); if (nowMs >= nextTurnMs) { - await this.runOnce({ reason: 'schedule', targetTime: nextRunTime }); + // Ref checkDelay() executes every turn due at the observed + // wall-clock time in one snapshot. Using only the oldest due + // timestamp lets generals created by that batch run before a + // monthly boundary, although Ref defers them to the next pass. + await this.runOnce({ reason: 'schedule', targetTime: new Date(nowMs) }); continue; } @@ -195,9 +199,10 @@ export class TurnDaemonLifecycle { const lastTurnTime = new Date(this.status.lastTurnTime); const nextGeneralTurnTime = await this.stateStore.loadNextGeneralTurnTime(); const nextTickTime = this.getNextTickTime(lastTurnTime); - // 가장 빠른 장수 턴과 현재 틱 경계 중 먼저 오는 시각을 선택한다. + // 같은 시각이면 Ref처럼 월 경계를 먼저 처리한다. 해당 장수는 월 + // 처리 직후 다음 daemon pass에서 과거 due turn으로 실행된다. const nextTurnTime = - nextGeneralTurnTime && nextGeneralTurnTime.getTime() <= nextTickTime.getTime() + nextGeneralTurnTime && nextGeneralTurnTime.getTime() < nextTickTime.getTime() ? nextGeneralTurnTime : nextTickTime; diff --git a/app/game-engine/src/scenario/scenarioSeeder.ts b/app/game-engine/src/scenario/scenarioSeeder.ts index df370a59..756268b4 100644 --- a/app/game-engine/src/scenario/scenarioSeeder.ts +++ b/app/game-engine/src/scenario/scenarioSeeder.ts @@ -153,7 +153,7 @@ const resolveKillturnFromDeathYear = ( if (!Number.isFinite(deathYear) || deathYear <= 0) { return fallback; } - const diff = (deathYear - currentYear) * 12 + (deathMonth - currentMonth); + const diff = (deathYear - currentYear) * 12 + (deathMonth - 1) + currentMonth - 1; return Math.max(diff, 0); }; @@ -209,18 +209,6 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom const hiddenSeed = integrationSeed && integrationSeed.length > 0 ? integrationSeed : randomBytes(16).toString('hex'); - const { seed, warnings } = buildScenarioBootstrap({ - scenario: scenarioDefinition, - map, - unitSet, - options: { - includeNeutralNationInSeed: options.includeNeutralNationInSeed ?? true, - hiddenSeed, - }, - }); - seed.cities = applyInitialChangeCityEvents(seed.cities, seed.initialEvents); - - const connector = createGamePostgresConnector({ url: options.databaseUrl }); const now = options.now ?? new Date(); const tickSeconds = install?.turnTermMinutes !== undefined @@ -229,6 +217,22 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom const turnTermMinutes = Math.max(1, Math.round(tickSeconds / 60)); const sync = install?.sync ?? false; const startState = resolveStartState(scenario.startYear ?? null, now, turnTermMinutes, sync); + + const { seed, warnings } = buildScenarioBootstrap({ + scenario: scenarioDefinition, + map, + unitSet, + options: { + includeNeutralNationInSeed: options.includeNeutralNationInSeed ?? true, + hiddenSeed, + initialYear: startState.currentYear, + initialMonth: startState.currentMonth, + turnTermMinutes, + }, + }); + seed.cities = applyInitialChangeCityEvents(seed.cities, seed.initialEvents); + + const connector = createGamePostgresConnector({ url: options.databaseUrl }); const generalGold = options.defaultGeneralGold ?? DEFAULT_GENERAL_GOLD; const generalRice = options.defaultGeneralRice ?? DEFAULT_GENERAL_RICE; @@ -257,7 +261,15 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom const worldMeta: Record = { scenarioId: options.scenarioId, scenarioMeta: seed.scenarioMeta, + // ResetHelper persists the actual pre-opening calendar separately from + // scenario.startyear. NPC wandering-nation AI and founding constraints + // derive their opening-month gates from these values. + initYear: startState.currentYear, + initMonth: startState.currentMonth, genius: Math.max(0, Math.floor(asNumber(scenarioConst.defaultMaxGenius, 5))), + // Ref seeds game_env.develcost before the first general turn. The + // monthly pre-handler recalculates the same value at each boundary. + develcost: (startState.currentYear - (scenario.startYear ?? startState.currentYear) + 10) * 2, starttime: formatDateTime(startState.startTime), turntime: formatDateTime(now), opentime: formatDateTime(now), @@ -493,6 +505,8 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom leadership: general.stats.leadership, strength: general.stats.strength, intel: general.stats.intelligence, + experience: general.experience ?? 0, + dedication: general.dedication ?? 0, officerLevel: general.officerLevel, gold: generalGold, rice: generalRice, @@ -501,9 +515,17 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom weaponCode: general.weapon ?? 'None', bookCode: general.book ?? 'None', itemCode: general.item ?? 'None', - turnTime: now, - age: resolveGeneralAge(scenario.startYear ?? null, general.birthYear), - startAge: resolveGeneralAge(scenario.startYear ?? null, general.birthYear), + turnTime: new Date( + now.getTime() + + Math.floor( + (typeof general.meta.initialTurnOffsetMicros === 'number' + ? general.meta.initialTurnOffsetMicros + : 0) / 1_000 + ) + ), + age: resolveGeneralAge(startState.currentYear, general.birthYear), + // Legacy GeneralBuilder leaves startage at the schema default on install. + startAge: 20, personalCode: general.personality ?? 'None', specialCode: general.special ?? 'None', special2Code: general.specialWar ?? 'None', diff --git a/app/game-engine/src/turn/ai/aiUtils.ts b/app/game-engine/src/turn/ai/aiUtils.ts index 70782f16..cd80b35c 100644 --- a/app/game-engine/src/turn/ai/aiUtils.ts +++ b/app/game-engine/src/turn/ai/aiUtils.ts @@ -85,6 +85,21 @@ export const parseYearMonth = (value: number): [number, number] => { return [year, month]; }; +export const withCanonicalArgumentAliases = (args: Record): Record => { + 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; +}; + export const calcCityDevRatio = (city: City): number => { const total = city.agriculture + city.commerce + city.security + city.defence + city.wall; const max = city.agricultureMax + city.commerceMax + city.securityMax + city.defenceMax + city.wallMax; diff --git a/app/game-engine/src/turn/ai/generalAi/core.ts b/app/game-engine/src/turn/ai/generalAi/core.ts index bfcc84f8..d3a3310e 100644 --- a/app/game-engine/src/turn/ai/generalAi/core.ts +++ b/app/game-engine/src/turn/ai/generalAi/core.ts @@ -13,6 +13,7 @@ import type { ConstraintContext } from '@sammo-ts/logic'; import { LiteHashDRBG, RandUtil } from '@sammo-ts/common'; import { simpleSerialize } from '@sammo-ts/logic/war/utils.js'; import { resolveStartYear, resolveTurnTermMinutes } from '@sammo-ts/logic/actions/turn/actionContextHelpers.js'; +import { NATION_TRAIT_KEYS } from '@sammo-ts/logic/actionModules/traits/nation/index.js'; import type { ReservedTurnEntry } from '../../reservedTurnStore.js'; import type { TurnGeneral, TurnWorldState } from '../../types.js'; @@ -26,6 +27,7 @@ import { readRequiredMetaNumber, roundTo, valueFit, + withCanonicalArgumentAliases, } from '../aiUtils.js'; import { searchAllDistanceByNationList } from '../distance.js'; import { generalActionHandlers } from '../generalAiGeneralActions.js'; @@ -48,10 +50,36 @@ const d징병 = 2; const d직전 = 3; const d전쟁 = 4; +export const resolveLegacyAiStats = ( + general: Pick, + nation: Nation | null | undefined, + maxStatLevel: number +) => { + const maxLevel = Math.max(1, maxStatLevel); + const clampStat = (value: number): number => Math.max(0, Math.min(value, maxLevel)); + const injuryRatio = (100 - Math.max(0, Math.min(general.injury, 100))) / 100; + const nationLevel = nation?.level ?? 0; + const leadershipBonus = general.officerLevel === 12 ? nationLevel * 2 : general.officerLevel >= 5 ? nationLevel : 0; + const leadershipWithBonus = (value: number): number => clampStat(clampStat(value) + leadershipBonus); + + const injuredLeadership = general.stats.leadership * injuryRatio; + const injuredStrength = general.stats.strength * injuryRatio; + const injuredIntelligence = general.stats.intelligence * injuryRatio; + + return { + fullLeadership: leadershipWithBonus(general.stats.leadership), + fullStrength: clampStat(general.stats.strength + Math.round(general.stats.intelligence / 4)), + fullIntelligence: clampStat(general.stats.intelligence + Math.round(general.stats.strength / 4)), + effectiveLeadership: Math.trunc(leadershipWithBonus(injuredLeadership)), + effectiveStrength: Math.trunc(clampStat(injuredStrength + Math.round(injuredIntelligence / 4))), + effectiveIntelligence: Math.trunc(clampStat(injuredIntelligence + Math.round(injuredStrength / 4))), + }; +}; + export class GeneralAI { - public readonly general: TurnGeneral; - public readonly city?: City; - public readonly nation?: Nation | null; + public general: TurnGeneral; + public city?: City; + public nation?: Nation | null; public readonly world: TurnWorldState; public readonly worldRef: AiWorldView | null; public readonly map?: MapDefinition; @@ -125,11 +153,15 @@ export class GeneralAI { private devRate: Record | null = null; private categorizedCities = false; private categorizedGenerals = false; + private promotionPatches: Array<{ generalId: number; officerLevel: number; officerCity: number }> = []; + private promotionNationMeta: Record | null = null; + private readonly initialGeneralMeta: Record; private readonly reservedTurnProvider: AiReservedTurnProvider; constructor(options: GeneralAIOptions) { this.general = { ...options.general, meta: { ...options.general.meta } }; + this.initialGeneralMeta = { ...options.general.meta }; this.city = options.city; const nation = options.nation ?? @@ -160,7 +192,51 @@ export class GeneralAI { this.world.currentMonth, this.general.id ); - this.rng = new RandUtil(LiteHashDRBG.build(seed)); + if ((process.env.CORE_AI_TRACE_GENERAL_IDS?.split(',') ?? []).includes(String(this.general.id))) { + process.stdout.write( + `AI_GENERAL_SEED_TRACE ${JSON.stringify({ + generalId: this.general.id, + year: this.world.currentYear, + month: this.world.currentMonth, + seedHex: Buffer.from(seed).toString('hex'), + })}\n` + ); + } + const baseRng = new RandUtil(LiteHashDRBG.build(seed)); + const traceRng = (process.env.CORE_AI_TRACE_GENERAL_IDS?.split(',') ?? []).includes(String(this.general.id)); + let traceSequence = 0; + this.rng = traceRng + ? new Proxy(baseRng, { + get: (target, property, receiver) => { + const value = Reflect.get(target, property, receiver); + if (typeof value !== 'function') return value; + return (...args: unknown[]) => { + const result = Reflect.apply(value, receiver, args); + if ( + ['nextFloat1', 'nextRangeInt', 'nextInt', 'nextBit', 'nextBool', 'choice', 'choiceUsingWeight', 'choiceUsingWeightPair'].includes( + String(property) + ) + ) { + process.stdout.write( + `AI_RNG_TRACE ${JSON.stringify({ + generalId: this.general.id, + sequence: traceSequence++, + method: String(property), + caller: new Error().stack?.split('\n')[2]?.trim() ?? null, + result: + result === null || ['string', 'number', 'boolean'].includes(typeof result) + ? result + : Array.isArray(result) + ? `[array:${result.length}]` + : '[object]', + })}\n` + ); + } + return result; + }; + }, + }) + : baseRng; const constValues = asRecord(this.scenarioConfig.const); this.aiConst = { @@ -176,7 +252,7 @@ export class GeneralAI { npcMessageFreqByDay: readNumber(constValues.npcMessageFreqByDay, 0), availableNationTypes: Array.isArray(constValues.availableNationType) ? constValues.availableNationType.filter((value) => typeof value === 'string') - : [], + : NATION_TRAIT_KEYS.filter((value) => value !== 'che_중립'), }; const generalPolicy = new AutorunGeneralPolicy( @@ -223,6 +299,14 @@ export class GeneralAI { this.categorizeNationCities(); this.categorizeNationGeneral(); + if (this.general.npcState >= 2 && [3, 6, 9, 12].includes(this.world.currentMonth)) { + if (this.general.officerLevel === 12) { + this.chooseNpcPromotion(); + } else { + this.chooseNonLordPromotion(); + } + } + if (reservedTurn.action !== ACTION_REST) { const reservedCandidate = this.buildNationCandidate(reservedTurn.action, reservedTurn.args, 'reserved'); if (reservedCandidate) { @@ -243,6 +327,16 @@ export class GeneralAI { } const result = handler(this); if (result) { + // Ref refreshes the cached AI state after these selected nation + // commands, before choosing the general command with the same + // RNG. The refresh includes another mixed-general type draw. + if ( + ['유저장긴급포상', 'NPC긴급포상', '선전포고', '천도'].includes( + actionName + ) + ) { + this.reqUpdateInstance = true; + } return result; } } @@ -250,13 +344,47 @@ export class GeneralAI { return this.buildNationCandidate(ACTION_REST, {}, 'neutral'); } + consumePromotionPatches(): { + generals: Array<{ generalId: number; officerLevel: number; officerCity: number }>; + nationMeta: Record | null; + } { + const result = { + generals: this.promotionPatches, + nationMeta: this.promotionNationMeta, + }; + this.promotionPatches = []; + this.promotionNationMeta = null; + return result; + } + + consumePersistentGeneralMetaPatch(): { set: Record; unset: string[] } { + const transientKeys = new Set([ + 'fullLeadership', + 'fullStrength', + 'fullIntelligence', + 'effectiveLeadership', + 'effectiveStrength', + 'effectiveIntelligence', + ]); + const set: Record = {}; + for (const [key, value] of Object.entries(this.general.meta)) { + if (transientKeys.has(key) || Object.is(this.initialGeneralMeta[key], value)) continue; + set[key] = value; + } + const unset = Object.keys(this.initialGeneralMeta).filter( + (key) => !transientKeys.has(key) && !Object.prototype.hasOwnProperty.call(this.general.meta, key) + ); + return { set, unset }; + } + chooseGeneralTurn(reservedTurn: ReservedTurnEntry): AiCommandCandidate | null { this.updateInstance(); if (!this.worldRef) { return null; } - const npcMessage = asRecord(this.general.meta).npcmsg; + const generalMeta = asRecord(this.general.meta); + const npcMessage = generalMeta.npcmsg ?? generalMeta.text; if (npcMessage && this.rng.nextBool((this.aiConst.npcMessageFreqByDay * this.turnTermMinutes) / (60 * 24))) { // 메시지 영속화는 turn handler가 담당한다. 여기서는 레거시와 같은 RNG 소비를 보존한다. } @@ -336,7 +464,13 @@ export class GeneralAI { } for (const actionName of this.generalPolicy.priority) { - if (!this.generalPolicy.can(actionName)) { + const allowed = this.generalPolicy.can(actionName); + if (!allowed) { + if ((process.env.CORE_AI_TRACE_GENERAL_IDS?.split(',') ?? []).includes(String(this.general.id))) { + process.stdout.write( + `AI_GENERAL_PRIORITY_TRACE ${JSON.stringify({ generalId: this.general.id, actionName, allowed, result: null })}\n` + ); + } continue; } const handler = generalActionHandlers[actionName]; @@ -344,6 +478,11 @@ export class GeneralAI { continue; } const result = handler(this); + if ((process.env.CORE_AI_TRACE_GENERAL_IDS?.split(',') ?? []).includes(String(this.general.id))) { + process.stdout.write( + `AI_GENERAL_PRIORITY_TRACE ${JSON.stringify({ generalId: this.general.id, actionName, allowed, result })}\n` + ); + } if (result) { return result; } @@ -354,7 +493,6 @@ export class GeneralAI { } getDebugState(): GeneralAiDebugState { - this.updateInstance(); this.categorizeNationCities(); const yearMonth = joinYearMonth(this.world.currentYear, this.world.currentMonth); const startYearMonth = joinYearMonth(this.startYear + 2, 5); @@ -626,10 +764,35 @@ export class GeneralAI { } this.reqUpdateInstance = false; - const nation = this.nation; - if (!nation) { - return; + const refreshedGeneral = this.worldRef?.getGeneralById(this.general.id); + if (refreshedGeneral) { + this.general = { ...refreshedGeneral, meta: { ...refreshedGeneral.meta } }; } + const refreshedCity = this.worldRef?.getCityById(this.general.cityId); + if (refreshedCity) { + this.city = refreshedCity; + } + const refreshedNation = + this.general.nationId > 0 ? (this.worldRef?.getNationById(this.general.nationId) ?? null) : null; + if (refreshedNation !== undefined) { + this.nation = refreshedNation ? { ...refreshedNation, meta: { ...refreshedNation.meta } } : refreshedNation; + } + + const nation = + this.nation ?? + ({ + id: 0, + name: '재야', + color: '#000000', + capitalCityId: null, + chiefGeneralId: null, + gold: 0, + rice: 0, + power: 0, + level: 0, + typeCode: 'neutral', + meta: {}, + } satisfies Nation); const baseDevelCost = this.commandEnv.develCost * 12; const nationMeta = asRecord(nation.meta); @@ -654,6 +817,7 @@ export class GeneralAI { } this.calcDiplomacyState(); + this.refreshLegacyFullStats(); this.genType = this.calcGenType(); void baseDevelCost; @@ -671,16 +835,17 @@ export class GeneralAI { if (parsedArgs === null) { return null; } + const constraintArgs = withCanonicalArgumentAliases(parsedArgs as Record); const constraintEnv = this.buildConstraintEnv(); const ctx: ConstraintContext = { actorId: this.general.id, cityId: this.city?.id, nationId: this.general.nationId, - args: parsedArgs as Record, + args: constraintArgs, env: constraintEnv, mode: 'full', }; - const view = new WorldStateView(this.worldRef, constraintEnv, parsedArgs as Record, { + const view = new WorldStateView(this.worldRef, constraintEnv, constraintArgs, { general: this.general, city: this.city, nation: this.nation ?? null, @@ -688,6 +853,11 @@ export class GeneralAI { const constraints = definition.buildConstraints(ctx, parsedArgs as never); const result = evaluateConstraints(constraints, ctx, view); if (result.kind !== 'allow') { + if ((process.env.CORE_AI_TRACE_GENERAL_IDS?.split(',') ?? []).includes(String(this.general.id))) { + process.stdout.write( + `AI_GENERAL_CONSTRAINT_TRACE ${JSON.stringify({ generalId: this.general.id, action, args: parsedArgs, result })}\n` + ); + } return null; } return { @@ -708,9 +878,10 @@ export class GeneralAI { } private calcGenType(): number { - const leadership = this.general.stats.leadership; - const strength = Math.max(this.general.stats.strength, 1); - const intel = Math.max(this.general.stats.intelligence, 1); + const meta = asRecord(this.general.meta); + const leadership = readMetaNumber(meta, 'fullLeadership', this.general.stats.leadership); + const strength = Math.max(readMetaNumber(meta, 'fullStrength', this.general.stats.strength), 1); + const intel = Math.max(readMetaNumber(meta, 'fullIntelligence', this.general.stats.intelligence), 1); let genType: number; if (strength >= intel) { @@ -736,6 +907,153 @@ export class GeneralAI { return genType; } + private refreshLegacyFullStats(): void { + const stats = resolveLegacyAiStats( + this.general, + this.nation, + this.commandEnv.maxStatLevel ?? this.scenarioConfig.stat.max + ); + this.general.meta = { + ...this.general.meta, + ...stats, + }; + } + + private chooseNpcPromotion(): void { + if (!this.nation || !this.worldRef) { + return; + } + const minChiefLevel = this.nation.level >= 6 ? 5 : this.nation.level >= 4 ? 7 : this.nation.level >= 2 ? 9 : 11; + let chiefSet = readMetaNumber(asRecord(this.nation.meta), 'chief_set', 0); + const generals = this.worldRef + .listGenerals() + .filter((candidate) => candidate.nationId === this.nation!.id) + .sort((left, right) => { + const leftScore = left.stats.leadership * 2 + left.stats.strength + left.stats.intelligence; + const rightScore = right.stats.leadership * 2 + right.stats.strength + right.stats.intelligence; + // Ref's nation query returns primary-key order and uasort keeps + // that order when the raw-stat score ties. + return rightScore - leftScore || left.id - right.id; + }); + const effectiveOfficerLevel = new Map(generals.map((candidate) => [candidate.id, candidate.officerLevel])); + + for (let chiefLevel = 11; chiefLevel >= minChiefLevel; chiefLevel -= 1) { + if ((chiefSet & (1 << chiefLevel)) !== 0 || this.general.officerLevel === chiefLevel) { + continue; + } + const oldChief = generals.find((candidate) => candidate.officerLevel === chiefLevel); + if (oldChief) { + const newChiefProbability = this.rng.nextBool(0.1) ? 1 : 0; + // GeneralAI.php performs a second nextBool(0) call on the + // rejection path. Preserve that consumption for the shared + // nation/general AI RNG stream. + if (newChiefProbability < 1 && !this.rng.nextBool(newChiefProbability)) { + continue; + } + } + const nextChief = generals.find((candidate) => { + if ((effectiveOfficerLevel.get(candidate.id) ?? candidate.officerLevel) > 4 || candidate.npcState < 2) { + return false; + } + const killturn = readRequiredMetaNumber( + asRecord(candidate.meta), + 'killturn', + `generalId=${candidate.id}` + ); + if (killturn < 36) { + return false; + } + if (chiefLevel !== 11 && chiefLevel % 2 === 0 && candidate.stats.strength < this.aiConst.chiefStatMin) { + return false; + } + if ( + chiefLevel !== 11 && + chiefLevel % 2 === 1 && + candidate.stats.intelligence < this.aiConst.chiefStatMin + ) { + return false; + } + return true; + }); + if (!nextChief) { + continue; + } + if (oldChief) { + this.promotionPatches.push({ generalId: oldChief.id, officerLevel: 1, officerCity: 0 }); + } + this.promotionPatches.push({ generalId: nextChief.id, officerLevel: chiefLevel, officerCity: 0 }); + if (process.env.CORE_AI_TRACE_SEQUENCE === '1') { + process.stdout.write( + `AI_PROMOTION_TRACE ${JSON.stringify({ engine: 'core', mode: 'lord', actor: this.general.id, chiefLevel, picked: nextChief.id })}\n` + ); + } + effectiveOfficerLevel.set(nextChief.id, chiefLevel); + chiefSet |= 1 << chiefLevel; + } + + if (this.promotionPatches.length > 0) { + this.promotionNationMeta = { ...this.nation.meta, chief_set: chiefSet }; + } + } + + private chooseNonLordPromotion(): void { + if (!this.nation) { + return; + } + const minChiefLevel = this.nation.level >= 6 ? 5 : this.nation.level >= 4 ? 7 : this.nation.level >= 2 ? 9 : 11; + let chiefSet = readMetaNumber(asRecord(this.nation.meta), 'chief_set', 0); + const pools = [this.npcWarGenerals, this.npcCivilGenerals, this.userWarGenerals, this.userCivilGenerals]; + + for (let chiefLevel = minChiefLevel; chiefLevel <= 12; chiefLevel += 1) { + if ( + (chiefSet & (1 << chiefLevel)) !== 0 || + this.chiefGenerals[chiefLevel] || + this.general.officerLevel === chiefLevel + ) { + continue; + } + + let picked: TurnGeneral | null = null; + for (let trial = 0; trial < 5; trial += 1) { + const pool = pools.find((candidatePool) => Object.keys(candidatePool).length > 0); + if (!pool) { + break; + } + const candidate = this.rng.choice(Object.values(pool)); + if (candidate.officerLevel !== 1) { + continue; + } + if ( + chiefLevel !== 11 && + ((chiefLevel % 2 === 0 && candidate.stats.strength < this.aiConst.chiefStatMin) || + (chiefLevel % 2 === 1 && candidate.stats.intelligence < this.aiConst.chiefStatMin)) + ) { + continue; + } + picked = candidate; + break; + } + if (!picked) { + continue; + } + + picked.officerLevel = chiefLevel; + picked.meta = { ...picked.meta, officer_city: 0 }; + this.promotionPatches.push({ generalId: picked.id, officerLevel: chiefLevel, officerCity: 0 }); + if (process.env.CORE_AI_TRACE_SEQUENCE === '1') { + process.stdout.write( + `AI_PROMOTION_TRACE ${JSON.stringify({ engine: 'core', mode: 'non-lord', actor: this.general.id, chiefLevel, picked: picked.id })}\n` + ); + } + this.chiefGenerals[chiefLevel] = picked; + chiefSet |= 1 << chiefLevel; + } + + if (this.promotionPatches.length > 0) { + this.promotionNationMeta = { ...this.nation.meta, chief_set: chiefSet }; + } + } + private calcDiplomacyState(): void { if (!this.nation || !this.worldRef) { return; @@ -815,6 +1133,11 @@ export class GeneralAI { } // legacy GeneralAI.php 기준: 평화/선포 상태에서 병력 보유 여부로 d징병 전환하지 않음. + if ((process.env.CORE_AI_TRACE_GENERAL_IDS?.split(',') ?? []).includes(String(this.general.id))) { + process.stdout.write( + `AI_DIPLOMACY_TRACE ${JSON.stringify({ generalId: this.general.id, warTargets, dipState: this.dipState })}\n` + ); + } } private calcRecentWarTurn(general: TurnGeneral): number { diff --git a/app/game-engine/src/turn/ai/generalAi/general/devActions.ts b/app/game-engine/src/turn/ai/generalAi/general/devActions.ts index c2c03442..b3cdab67 100644 --- a/app/game-engine/src/turn/ai/generalAi/general/devActions.ts +++ b/app/game-engine/src/turn/ai/generalAi/general/devActions.ts @@ -27,6 +27,10 @@ export const do일반내정 = (ai: GeneralAI) => { } const develRate = ai.calcCityDevelRate(city); + const generalMeta = asRecord(ai.general.meta); + const leadership = readMetaNumber(generalMeta, 'effectiveLeadership', ai.general.stats.leadership); + const strength = readMetaNumber(generalMeta, 'effectiveStrength', ai.general.stats.strength); + const intelligence = readMetaNumber(generalMeta, 'effectiveIntelligence', ai.general.stats.intelligence); const tech = readMetaNumber(asRecord(nation.meta), 'tech', 0); const isSpringSummer = ai.world.currentMonth <= 6; const cmdList: Array<[ReturnType, number]> = []; @@ -35,18 +39,18 @@ export const do일반내정 = (ai: GeneralAI) => { if (develRate.trust[0] < 0.98) { cmdList.push([ ai.buildGeneralCandidate('che_주민선정', {}, '일반내정'), - (ai.general.stats.leadership / valueFit(develRate.trust[0] / 2 - 0.2, 0.001)) * 2, + (leadership / valueFit(develRate.trust[0] / 2 - 0.2, 0.001)) * 2, ]); } if (develRate.pop[0] < 0.8) { cmdList.push([ ai.buildGeneralCandidate('che_정착장려', {}, '일반내정'), - ai.general.stats.leadership / valueFit(develRate.pop[0], 0.001), + leadership / valueFit(develRate.pop[0], 0.001), ]); } else if (develRate.pop[0] < 0.99) { cmdList.push([ ai.buildGeneralCandidate('che_정착장려', {}, '일반내정'), - ai.general.stats.leadership / valueFit(develRate.pop[0] / 4, 0.001), + leadership / valueFit(develRate.pop[0] / 4, 0.001), ]); } } @@ -55,51 +59,69 @@ export const do일반내정 = (ai: GeneralAI) => { if (develRate.def[0] < 1) { cmdList.push([ ai.buildGeneralCandidate('che_수비강화', {}, '일반내정'), - ai.general.stats.strength / valueFit(develRate.def[0], 0.001), + strength / valueFit(develRate.def[0], 0.001), ]); } if (develRate.wall[0] < 1) { cmdList.push([ ai.buildGeneralCandidate('che_성벽보수', {}, '일반내정'), - ai.general.stats.strength / valueFit(develRate.wall[0], 0.001), + strength / valueFit(develRate.wall[0], 0.001), ]); } if (develRate.secu[0] < 0.9) { cmdList.push([ ai.buildGeneralCandidate('che_치안강화', {}, '일반내정'), - ai.general.stats.strength / valueFit(develRate.secu[0] / 0.8, 0.001, 1), + strength / valueFit(develRate.secu[0] / 0.8, 0.001, 1), ]); } else if (develRate.secu[0] < 1) { cmdList.push([ ai.buildGeneralCandidate('che_치안강화', {}, '일반내정'), - ai.general.stats.strength / 2 / valueFit(develRate.secu[0], 0.001), + strength / 2 / valueFit(develRate.secu[0], 0.001), ]); } } if (ai.genType & t지장) { if (!isTechLimited(ai, tech)) { - const nextTech = (tech % 1000) + 1; - const weight = !isTechLimited(ai, tech + 1000) - ? ai.general.stats.intelligence / (nextTech / 2000) - : ai.general.stats.intelligence; + // PHP's `%` coerces the FLOAT nation tech value to an integer + // before taking the remainder. Keeping the fraction here can move + // a weighted draw across a candidate boundary. + const nextTech = (Math.trunc(tech) % 1000) + 1; + const weight = !isTechLimited(ai, tech + 1000) ? intelligence / (nextTech / 2000) : intelligence; cmdList.push([ai.buildGeneralCandidate('che_기술연구', {}, '일반내정'), weight]); } if (develRate.agri[0] < 1) { cmdList.push([ ai.buildGeneralCandidate('che_농지개간', {}, '일반내정'), - ((isSpringSummer ? 1.2 : 0.8) * ai.general.stats.intelligence) / valueFit(develRate.agri[0], 0.001, 1), + ((isSpringSummer ? 1.2 : 0.8) * intelligence) / valueFit(develRate.agri[0], 0.001, 1), ]); } if (develRate.comm[0] < 1) { cmdList.push([ ai.buildGeneralCandidate('che_상업투자', {}, '일반내정'), - ((isSpringSummer ? 0.8 : 1.2) * ai.general.stats.intelligence) / valueFit(develRate.comm[0], 0.001, 1), + ((isSpringSummer ? 0.8 : 1.2) * intelligence) / valueFit(develRate.comm[0], 0.001, 1), ]); } } - return pickWeightedCandidate(ai, cmdList); + const picked = pickWeightedCandidate(ai, cmdList); + const traceGeneralIds = process.env.CORE_AI_TRACE_GENERAL_IDS?.split(',') ?? []; + if (traceGeneralIds.includes(String(ai.general.id))) { + process.stdout.write( + `AI_DEVEL_TRACE ${JSON.stringify({ + generalId: ai.general.id, + year: ai.world.currentYear, + month: ai.world.currentMonth, + genType: ai.genType, + city, + candidates: cmdList.flatMap(([candidate, weight]) => + candidate ? [{ action: candidate.action, weight }] : [] + ), + picked: picked?.action ?? null, + })}\n` + ); + } + return picked; }; export const do긴급내정 = (ai: GeneralAI) => { @@ -111,12 +133,13 @@ export const do긴급내정 = (ai: GeneralAI) => { return null; } const trust = resolveCityTrust(city); - if (trust < 70 && ai.rng.nextBool(ai.general.stats.leadership / ai.aiConst.chiefStatMin)) { + const leadership = readMetaNumber(asRecord(ai.general.meta), 'effectiveLeadership', ai.general.stats.leadership); + if (trust < 70 && ai.rng.nextBool(leadership / ai.aiConst.chiefStatMin)) { return ai.buildGeneralCandidate('che_주민선정', {}, '긴급내정'); } if ( city.population < ai.nationPolicy.minNpcRecruitCityPopulation && - ai.rng.nextBool(ai.general.stats.leadership / ai.aiConst.chiefStatMin / 2) + ai.rng.nextBool(leadership / ai.aiConst.chiefStatMin / 2) ) { return ai.buildGeneralCandidate('che_정착장려', {}, '긴급내정'); } @@ -139,6 +162,10 @@ export const do전쟁내정 = (ai: GeneralAI) => { return null; } const develRate = ai.calcCityDevelRate(city); + const generalMeta = asRecord(ai.general.meta); + const leadership = readMetaNumber(generalMeta, 'effectiveLeadership', ai.general.stats.leadership); + const strength = readMetaNumber(generalMeta, 'effectiveStrength', ai.general.stats.strength); + const intelligence = readMetaNumber(generalMeta, 'effectiveIntelligence', ai.general.stats.intelligence); const tech = readMetaNumber(asRecord(nation.meta), 'tech', 0); const isSpringSummer = ai.world.currentMonth <= 6; const cmdList: Array<[ReturnType, number]> = []; @@ -147,13 +174,13 @@ export const do전쟁내정 = (ai: GeneralAI) => { if (develRate.trust[0] < 0.98) { cmdList.push([ ai.buildGeneralCandidate('che_주민선정', {}, '전쟁내정'), - (ai.general.stats.leadership / valueFit(develRate.trust[0] / 2 - 0.2, 0.001)) * 2, + (leadership / valueFit(develRate.trust[0] / 2 - 0.2, 0.001)) * 2, ]); } if (develRate.pop[0] < 0.8) { const weight = [1, 3].includes(city.frontState) - ? ai.general.stats.leadership / valueFit(develRate.pop[0], 0.001) - : ai.general.stats.leadership / valueFit(develRate.pop[0], 0.001) / 2; + ? leadership / valueFit(develRate.pop[0], 0.001) + : leadership / valueFit(develRate.pop[0], 0.001) / 2; cmdList.push([ai.buildGeneralCandidate('che_정착장려', {}, '전쟁내정'), weight]); } } @@ -162,52 +189,62 @@ export const do전쟁내정 = (ai: GeneralAI) => { if (develRate.def[0] < 0.5) { cmdList.push([ ai.buildGeneralCandidate('che_수비강화', {}, '전쟁내정'), - ai.general.stats.strength / valueFit(develRate.def[0], 0.001) / 2, + strength / valueFit(develRate.def[0], 0.001) / 2, ]); } if (develRate.wall[0] < 0.5) { cmdList.push([ ai.buildGeneralCandidate('che_성벽보수', {}, '전쟁내정'), - ai.general.stats.strength / valueFit(develRate.wall[0], 0.001) / 2, + strength / valueFit(develRate.wall[0], 0.001) / 2, ]); } if (develRate.secu[0] < 0.5) { cmdList.push([ ai.buildGeneralCandidate('che_치안강화', {}, '전쟁내정'), - ai.general.stats.strength / valueFit(develRate.secu[0] / 0.8, 0.001, 1) / 4, + strength / valueFit(develRate.secu[0] / 0.8, 0.001, 1) / 4, ]); } } if (ai.genType & t지장) { if (!isTechLimited(ai, tech)) { - const nextTech = (tech % 1000) + 1; - const weight = !isTechLimited(ai, tech + 1000) - ? ai.general.stats.intelligence / (nextTech / 3000) - : ai.general.stats.intelligence; + const nextTech = (Math.trunc(tech) % 1000) + 1; + const weight = !isTechLimited(ai, tech + 1000) ? intelligence / (nextTech / 3000) : intelligence; cmdList.push([ai.buildGeneralCandidate('che_기술연구', {}, '전쟁내정'), weight]); } if (develRate.agri[0] < 0.5) { const weight = [1, 3].includes(city.frontState) - ? ((isSpringSummer ? 1.2 : 0.8) * ai.general.stats.intelligence) / - 4 / - valueFit(develRate.agri[0], 0.001, 1) - : ((isSpringSummer ? 1.2 : 0.8) * ai.general.stats.intelligence) / - 2 / - valueFit(develRate.agri[0], 0.001, 1); + ? ((isSpringSummer ? 1.2 : 0.8) * intelligence) / 4 / valueFit(develRate.agri[0], 0.001, 1) + : ((isSpringSummer ? 1.2 : 0.8) * intelligence) / 2 / valueFit(develRate.agri[0], 0.001, 1); cmdList.push([ai.buildGeneralCandidate('che_농지개간', {}, '전쟁내정'), weight]); } if (develRate.comm[0] < 0.5) { const weight = [1, 3].includes(city.frontState) - ? ((isSpringSummer ? 0.8 : 1.2) * ai.general.stats.intelligence) / - 4 / - valueFit(develRate.comm[0], 0.001, 1) - : ((isSpringSummer ? 0.8 : 1.2) * ai.general.stats.intelligence) / - 2 / - valueFit(develRate.comm[0], 0.001, 1); + ? ((isSpringSummer ? 0.8 : 1.2) * intelligence) / 4 / valueFit(develRate.comm[0], 0.001, 1) + : ((isSpringSummer ? 0.8 : 1.2) * intelligence) / 2 / valueFit(develRate.comm[0], 0.001, 1); cmdList.push([ai.buildGeneralCandidate('che_상업투자', {}, '전쟁내정'), weight]); } } - return pickWeightedCandidate(ai, cmdList); + const picked = pickWeightedCandidate(ai, cmdList); + const traceGeneralIds = process.env.CORE_AI_TRACE_GENERAL_IDS?.split(',') ?? []; + if (traceGeneralIds.includes(String(ai.general.id))) { + process.stdout.write( + `AI_DEVEL_TRACE ${JSON.stringify({ + generalId: ai.general.id, + year: ai.world.currentYear, + month: ai.world.currentMonth, + mode: '전쟁내정', + genType: ai.genType, + dipState: ai.dipState, + city, + nationTech: tech, + candidates: cmdList.flatMap(([candidate, weight]) => + candidate ? [{ action: candidate.action, weight }] : [] + ), + picked: picked?.action ?? null, + })}\n` + ); + } + return picked; }; diff --git a/app/game-engine/src/turn/ai/generalAi/general/economyActions.ts b/app/game-engine/src/turn/ai/generalAi/general/economyActions.ts index 9363994c..601d3f6d 100644 --- a/app/game-engine/src/turn/ai/generalAi/general/economyActions.ts +++ b/app/game-engine/src/turn/ai/generalAi/general/economyActions.ts @@ -1,11 +1,22 @@ +import { GeneralActionPipeline } from '@sammo-ts/logic'; import { findCrewTypeById, getTechCost } from '@sammo-ts/logic/world/unitSet.js'; import type { GeneralAI } from '../core.js'; import { asRecord, readMetaNumber, valueFit } from '../../aiUtils.js'; export const do금쌀구매 = (ai: GeneralAI) => { + const traceEnabled = (process.env.CORE_AI_TRACE_GENERAL_IDS?.split(',') ?? []).includes(String(ai.general.id)); + const trace = (stage: string, values: Record = {}) => { + if (!traceEnabled) { + return; + } + process.stderr.write( + `AI_ECONOMY_TRACE ${JSON.stringify({ generalId: ai.general.id, stage, ...values })}\n` + ); + }; const city = ai.city; if (!city) { + trace('no-city'); return null; } @@ -14,8 +25,9 @@ export const do금쌀구매 = (ai: GeneralAI) => { return null; } - const kill = readMetaNumber(asRecord(ai.general.meta), 'killcrew', 50000) + 50000; - const death = readMetaNumber(asRecord(ai.general.meta), 'deathcrew', 50000) + 50000; + const generalMeta = asRecord(ai.general.meta); + const kill = readMetaNumber(generalMeta, 'rank_killcrew', readMetaNumber(generalMeta, 'killcrew', 0)) + 50000; + const death = readMetaNumber(generalMeta, 'rank_deathcrew', readMetaNumber(generalMeta, 'deathcrew', 0)) + 50000; const deathRate = death / kill; const absGold = ai.general.gold; @@ -24,16 +36,64 @@ export const do금쌀구매 = (ai: GeneralAI) => { const relRice = absRice * deathRate; const baseDevelCost = ai.commandEnv.develCost * 12; + trace('resources', { + absGold, + absRice, + relGold, + relRice, + deathRate, + baseDevelCost, + canIgnoreTrader: ai.generalPolicy.can('상인무시'), + trade, + }); if (absGold + absRice < baseDevelCost * 2) { + trace('insufficient-base-resource'); return null; } const crewType = findCrewTypeById(ai.unitSet, ai.general.crewTypeId ?? ai.commandEnv.defaultCrewTypeId); const tech = readMetaNumber(asRecord(ai.nation?.meta ?? {}), 'tech', 0); - const fullLeadership = ai.general.stats.leadership; + const fullLeadership = readMetaNumber(generalMeta, 'fullLeadership', ai.general.stats.leadership); const crewAmount = fullLeadership * 100; - const goldCost = crewType ? (crewType.cost * getTechCost(tech) * crewAmount) / 100 : 0; - const riceCost = crewAmount / 100; + const rawGoldCost = crewType ? (crewType.cost * getTechCost(tech) * crewAmount) / 100 : 0; + const actionPipeline = new GeneralActionPipeline(ai.commandEnv.generalActionModules ?? []); + const goldCost = Math.round( + actionPipeline.onCalcDomestic( + { + general: ai.general, + nation: ai.nation ?? undefined, + ...(ai.worldRef + ? { + worldView: { + listGenerals: () => ai.worldRef!.listGenerals(), + listGeneralsByCity: (cityId: number) => + ai.worldRef!.listGenerals().filter((candidate) => candidate.cityId === cityId), + listNations: () => ai.worldRef!.listNations(), + }, + } + : {}), + time: { + year: ai.world.currentYear, + month: ai.world.currentMonth, + startYear: ai.startYear, + }, + }, + '징병', + 'cost', + rawGoldCost, + { armType: crewType?.armType ?? 0 } + ) * (ai.generalPolicy.can('모병') ? 2 : 1) + ); + const riceCost = crewType ? (crewType.rice * getTechCost(tech) * crewAmount) / 100 : 0; + trace('recruit-cost', { + crewTypeId: crewType?.id ?? null, + crewCost: crewType?.cost ?? null, + crewRice: crewType?.rice ?? null, + tech, + crewAmount, + goldCost, + riceCost, + }); if ((relGold + relRice) * 1.5 <= goldCost + riceCost) { return null; @@ -55,9 +115,15 @@ export const do금쌀구매 = (ai: GeneralAI) => { } if (tryBuying) { - const amount = valueFit(Math.floor((relGold - relRice) / (1 + deathRate)), 100, ai.maxResourceActionAmount); + const amount = valueFit( + Math.floor((relGold - relRice) / (1 + deathRate)), + 100, + ai.aiConst.maxResourceActionAmount + ); if (amount >= ai.nationPolicy.minimumResourceActionAmount) { - return ai.buildGeneralCandidate('che_군량매매', { buyRice: true, amount }, '금쌀구매'); + const result = ai.buildGeneralCandidate('che_군량매매', { buyRice: true, amount }, '금쌀구매'); + trace('buy', { amount, minimumResourceActionAmount: ai.nationPolicy.minimumResourceActionAmount, result }); + return result; } } @@ -73,7 +139,11 @@ export const do금쌀구매 = (ai: GeneralAI) => { } if (trySelling) { - const amount = valueFit(Math.floor((relRice - relGold) / (1 + deathRate)), 100, ai.maxResourceActionAmount); + const amount = valueFit( + Math.floor((relRice - relGold) / (1 + deathRate)), + 100, + ai.aiConst.maxResourceActionAmount + ); if (amount >= ai.nationPolicy.minimumResourceActionAmount) { return ai.buildGeneralCandidate('che_군량매매', { buyRice: false, amount }, '금쌀구매'); } diff --git a/app/game-engine/src/turn/ai/generalAi/general/politicsActions.ts b/app/game-engine/src/turn/ai/generalAi/general/politicsActions.ts index b9f094a8..c8ba6d4b 100644 --- a/app/game-engine/src/turn/ai/generalAi/general/politicsActions.ts +++ b/app/game-engine/src/turn/ai/generalAi/general/politicsActions.ts @@ -1,4 +1,4 @@ -import { searchDistance } from '@sammo-ts/logic/world/distance.js'; +import { searchDistance, searchDistanceEntries } from '@sammo-ts/logic/world/distance.js'; import type { GeneralAI } from '../core.js'; import { asRecord, readMetaNumber, valueFit } from '../../aiUtils.js'; @@ -23,7 +23,8 @@ export const do국가선택 = (ai: GeneralAI) => { return null; } if (ai.world.currentYear < ai.startYear + 3) { - const nations = ai.worldRef.listNations(); + // Ref queries the nation table, which has no synthetic neutral row. + const nations = ai.worldRef.listNations().filter((nation) => nation.id > 0); const nationCount = nations.length; const notFullNationCount = nations.filter((nation) => { const count = ai.worldRef!.listGenerals().filter((general) => general.nationId === nation.id).length; @@ -104,18 +105,16 @@ export const do거병 = (ai: GeneralAI) => { .map((c) => c.id) ); for (const general of ai.worldRef.listGenerals()) { - if (general.officerLevel === 12 && general.nationId === 0) { + // Ref joins through city and checks city.nation=0. A ruler of a newly + // raised wandering nation therefore still occupies its neutral city. + if (general.officerLevel === 12 && ai.worldRef.getCityById(general.cityId)?.nationId === 0) { occupied.add(general.cityId); } } let availableNearCity = false; - const nearby = searchDistance(ai.map, ai.general.cityId, 3); - for (const [targetCityId, dist] of Object.entries(nearby)) { - const cityId = Number(targetCityId); - if (!Number.isFinite(cityId)) { - continue; - } + const nearby = searchDistanceEntries(ai.map, ai.general.cityId, 3); + for (const [cityId, dist] of nearby) { if (occupied.has(cityId)) { continue; } @@ -159,7 +158,10 @@ export const do건국 = (ai: GeneralAI) => { ? (ai.rng.choice(ai.aiConst.availableNationTypes) as string) : 'che_도적'; const colorType = ai.rng.nextRangeInt(0, 32); - const nationName = `㉿${Array.from(ai.general.name).slice(1).join('')}`; + // Ref stores the NPC display prefix in general.name and removes it here. + // Core's installed scenario rows keep the prefix in npcState instead. + const characters = Array.from(ai.general.name); + const nationName = `㉿${characters[0] === 'ⓝ' ? characters.slice(1).join('') : ai.general.name}`; const result = ai.buildGeneralCandidate('che_건국', { nationName, nationType, colorType }, '건국'); if (result) { @@ -198,13 +200,13 @@ export const do방랑군이동 = (ai: GeneralAI) => { if (!city || !ai.map || !ai.worldRef) { return null; } - const lordCities = ai.worldRef - .listGenerals() - .filter((general) => general.officerLevel === 12 && general.nationId === 0) - .map((general) => general.cityId); - if (lordCities.filter((cityId) => cityId === city.id).length <= 1 && [5, 6].includes(city.level)) { + const rulers = ai.worldRef.listGenerals().filter((general) => general.officerLevel === 12); + if (rulers.filter((general) => general.cityId === city.id).length <= 1 && [5, 6].includes(city.level)) { return null; } + const lordCities = rulers + .filter((general) => ai.worldRef!.getCityById(general.cityId)?.nationId === 0) + .map((general) => general.cityId); const occupied = new Set( ai.worldRef @@ -222,11 +224,10 @@ export const do방랑군이동 = (ai: GeneralAI) => { } if (movingTargetCityId === null) { - const nearby = searchDistance(ai.map, city.id, 4); + const nearby = searchDistanceEntries(ai.map, city.id, 4); const candidates: Array<[number, number]> = []; - for (const [cityIdRaw, dist] of Object.entries(nearby)) { - const cityId = Number(cityIdRaw); - if (!Number.isFinite(cityId) || occupied.has(cityId)) { + for (const [cityId, dist] of nearby) { + if (occupied.has(cityId)) { continue; } const target = ai.worldRef.getCityById(cityId); diff --git a/app/game-engine/src/turn/ai/generalAi/general/recruitActions.ts b/app/game-engine/src/turn/ai/generalAi/general/recruitActions.ts index 1c68b204..e70a682f 100644 --- a/app/game-engine/src/turn/ai/generalAi/general/recruitActions.ts +++ b/app/game-engine/src/turn/ai/generalAi/general/recruitActions.ts @@ -6,6 +6,7 @@ import { } from '@sammo-ts/logic/world/unitSet.js'; import { buildWarConfig } from '@sammo-ts/logic/actions/turn/actionContextHelpers.js'; import type { CrewTypeDefinition, General, WarArmTypes } from '@sammo-ts/logic'; +import { GeneralActionPipeline } from '@sammo-ts/logic/actionModules/general.js'; import type { GeneralAI } from '../core.js'; import { asRecord, readMetaNumber, roundTo } from '../../aiUtils.js'; @@ -40,26 +41,46 @@ const getRequiredTech = (crewType: CrewTypeDefinition): number | null => { }; export const do징병 = (ai: GeneralAI) => { + const traceEnabled = (process.env.CORE_AI_TRACE_GENERAL_IDS?.split(',') ?? []).includes(String(ai.general.id)); + const trace = (stage: string, values: Record = {}) => { + if (traceEnabled) { + process.stdout.write( + `AI_RECRUIT_TRACE ${JSON.stringify({ generalId: ai.general.id, stage, ...values })}\n` + ); + } + }; const city = ai.city; const nation = ai.nation; if (!city || !nation || !ai.unitSet || !ai.map) { + trace('missing-context'); return null; } if ([0, 1].includes(ai.dipState)) { + trace('diplomacy', { dipState: ai.dipState }); return null; } if (!(ai.genType & t통솔장)) { + trace('general-type', { genType: ai.genType }); return null; } if (ai.general.crew >= ai.nationPolicy.minWarCrew) { + trace('existing-crew', { crew: ai.general.crew, minWarCrew: ai.nationPolicy.minWarCrew }); return null; } const generalMeta = asRecord(ai.general.meta); const fullLeadership = readMetaNumber(generalMeta, 'fullLeadership', ai.general.stats.leadership); + trace('population-policy', { + population: city.population, + populationMax: city.populationMax, + safeRatio: ai.nationPolicy.safeRecruitCityPopulationRatio, + minPopulation: ai.nationPolicy.minNpcRecruitCityPopulation, + canLimitRecruit: ai.generalPolicy.can('한계징병'), + }); if (!ai.generalPolicy.can('한계징병')) { const remainPop = city.population - ai.nationPolicy.minNpcRecruitCityPopulation - fullLeadership * 100; if (remainPop <= 0) { + trace('population-floor', { remainPop, fullLeadership }); return null; } const maxPop = city.populationMax - ai.nationPolicy.minNpcRecruitCityPopulation; @@ -67,6 +88,7 @@ export const do징병 = (ai: GeneralAI) => { city.population / city.populationMax < ai.nationPolicy.safeRecruitCityPopulationRatio && ai.rng.nextBool(remainPop / Math.max(1, maxPop)) ) { + trace('population-random', { remainPop, maxPop, fullLeadership }); return null; } } @@ -82,10 +104,22 @@ export const do징병 = (ai: GeneralAI) => { ) { forcedArmType = 0; } - const armType = - forcedArmType > 0 - ? forcedArmType - : ai.rng.choiceUsingWeightPair(buildRecruitArmTypeWeights(ai.general, warConfig.armTypes)); + const armTypeWeights = forcedArmType > 0 ? [] : buildRecruitArmTypeWeights(ai.general, warConfig.armTypes); + let armTypeDraw: number | null = null; + const armType = forcedArmType > 0 + ? forcedArmType + : traceEnabled + ? (() => { + armTypeDraw = ai.rng.nextFloat1(); + let cursor = armTypeDraw * armTypeWeights.reduce((sum, [, weight]) => sum + Math.max(0, weight), 0); + for (const [candidate, weight] of armTypeWeights) { + if (cursor <= weight) return candidate; + cursor -= Math.max(0, weight); + } + return armTypeWeights.at(-1)![0]; + })() + : ai.rng.choiceUsingWeightPair(armTypeWeights); + trace('arm-type', { forcedArmType, armType, armTypeDraw, armTypeWeights }); const candidates = (ai.unitSet?.crewTypes ?? []) .filter((crew) => crew.armType === armType) @@ -100,11 +134,17 @@ export const do징병 = (ai: GeneralAI) => { }) ); if (candidates.length === 0) { + trace('no-crew-type', { armType }); return null; } let picked = ai.rng.choiceUsingWeightPair( candidates.map((crew) => [crew, getCrewTypePickScore(crew, tech, warConfig.armPerPhase)]) ); + trace('crew-type', { + armType, + candidates: candidates.map((crew) => [crew.id, getCrewTypePickScore(crew, tech, warConfig.armPerPhase)]), + picked: picked.id, + }); if (ai.generalPolicy.can('고급병종')) { const currentCrewType = findCrewTypeById(ai.unitSet, ai.general.crewTypeId); if ( @@ -130,7 +170,39 @@ export const do징병 = (ai: GeneralAI) => { const crewTypeId = picked.id; let crewAmount = crewAmountBase; - const goldCost = (picked.cost * getTechCost(tech) * crewAmount) / 100; + const rawGoldCost = (picked.cost * getTechCost(tech) * crewAmount) / 100; + // Ref asks the concrete che_징병 command for getCost() before deciding + // whether to halve the requested crew. That path includes personality, + // traits, items, and the final integer rounding; using the raw unit price + // makes che_출세 (+20% cost) recruit a full stack incorrectly. + const actionPipeline = new GeneralActionPipeline(ai.commandEnv.generalActionModules ?? []); + const goldCost = Math.round( + actionPipeline.onCalcDomestic( + { + general: ai.general, + nation, + ...(ai.worldRef + ? { + worldView: { + listGenerals: () => ai.worldRef!.listGenerals(), + listGeneralsByCity: (cityId: number) => + ai.worldRef!.listGenerals().filter((candidate) => candidate.cityId === cityId), + listNations: () => ai.worldRef!.listNations(), + }, + } + : {}), + time: { + year: ai.world.currentYear, + month: ai.world.currentMonth, + startYear: ai.startYear, + }, + }, + '징병', + 'cost', + rawGoldCost, + { armType: picked.armType } + ) + ); const killCrew = readMetaNumber(generalMeta, 'rank_killcrew', readMetaNumber(generalMeta, 'killcrew', 0)); const deathCrew = readMetaNumber(generalMeta, 'rank_deathcrew', readMetaNumber(generalMeta, 'deathcrew', 0)); const expectedCrewLoss = Math.floor((crewAmount * killCrew * 1.2) / Math.max(deathCrew, 1)); @@ -139,6 +211,7 @@ export const do징병 = (ai: GeneralAI) => { const remainingGold = ai.general.gold - fullLeadership * 3; const remainingRice = ai.general.rice - fullLeadership * 4; if (remainingGold <= 0 || remainingRice <= 0) { + trace('reserve-floor', { remainingGold, remainingRice, fullLeadership }); return null; } @@ -156,8 +229,18 @@ export const do징병 = (ai: GeneralAI) => { } if (!ai.generalPolicy.can('한계징병') && remainingRice * 1.1 <= riceCost) { + trace('rice-cost', { remainingGold, remainingRice, goldCost, riceCost, crewAmount, crewTypeId }); return null; } - return ai.buildGeneralCandidate('che_징병', { crewType: crewTypeId, amount: crewAmount }, '징병'); + const result = ai.buildGeneralCandidate('che_징병', { crewType: crewTypeId, amount: crewAmount }, '징병'); + trace(result ? 'selected' : 'constraint', { + remainingGold, + remainingRice, + goldCost, + riceCost, + crewAmount, + crewTypeId, + }); + return result; }; diff --git a/app/game-engine/src/turn/ai/generalAi/general/warpActions.ts b/app/game-engine/src/turn/ai/generalAi/general/warpActions.ts index 2948059f..98161ba4 100644 --- a/app/game-engine/src/turn/ai/generalAi/general/warpActions.ts +++ b/app/game-engine/src/turn/ai/generalAi/general/warpActions.ts @@ -1,5 +1,5 @@ import type { GeneralAI } from '../core.js'; -import { asRecord, readRequiredMetaNumber } from '../../aiUtils.js'; +import { asRecord, readMetaNumber, readRequiredMetaNumber } from '../../aiUtils.js'; import { t통솔장 } from './helpers.js'; export const do후방워프 = (ai: GeneralAI) => { @@ -20,11 +20,13 @@ export const do후방워프 = (ai: GeneralAI) => { return null; } - let minRecruitPop = ai.general.stats.leadership * 100 + ai.aiConst.minAvailableRecruitPop; + // Ref uses getLeadership(false): item/officer bonuses included, injury ignored. + const fullLeadership = readMetaNumber(asRecord(ai.general.meta), 'fullLeadership', ai.general.stats.leadership); + let minRecruitPop = fullLeadership * 100 + ai.aiConst.minAvailableRecruitPop; if (!ai.generalPolicy.can('한계징병')) { minRecruitPop = Math.max( minRecruitPop, - ai.general.stats.leadership * 100 + ai.nationPolicy.minNpcRecruitCityPopulation + fullLeadership * 100 + ai.nationPolicy.minNpcRecruitCityPopulation ); } @@ -82,6 +84,12 @@ export const do후방워프 = (ai: GeneralAI) => { return null; } + if ((process.env.CORE_AI_TRACE_GENERAL_IDS?.split(',') ?? []).includes(String(ai.general.id))) { + process.stdout.write( + `AI_WARP_TRACE ${JSON.stringify({ generalId: ai.general.id, kind: 'rear', fullLeadership, minRecruitPop, recruitable })}\n` + ); + } + return ai.buildGeneralCandidate( 'che_NPC능동', { optionText: '순간이동', destCityId: ai.rng.choiceUsingWeight(recruitable) }, @@ -158,7 +166,6 @@ export const do내정워프 = (ai: GeneralAI) => { } ai.categorizeNationCities(); - ai.categorizeNationGeneral(); const candidateCities: Record = {}; for (const candidate of Object.values(ai.supplyCities)) { if (candidate.id === city.id) { diff --git a/app/game-engine/src/turn/ai/generalAi/nation/assignments/npcAssignments.ts b/app/game-engine/src/turn/ai/generalAi/nation/assignments/npcAssignments.ts index 74f80314..d0cbf62b 100644 --- a/app/game-engine/src/turn/ai/generalAi/nation/assignments/npcAssignments.ts +++ b/app/game-engine/src/turn/ai/generalAi/nation/assignments/npcAssignments.ts @@ -1,5 +1,6 @@ import type { GeneralAI } from '../../core.js'; -import { buildAssignmentCandidate, pickFrontCityWeight, pickRandomCityId, resolveCityPopRatio, selectRecruitableCity } from '../helpers.js'; +import { GeneralActionPipeline } from '@sammo-ts/logic/actionModules/general.js'; +import { buildAssignmentCandidate, pickFrontCityWeight, pickRandomCityId, resolveCityPopRatio } from '../helpers.js'; export const doNPC후방발령 = (ai: GeneralAI) => { if (!ai.nation || !ai.nation.capitalCityId) { @@ -12,6 +13,26 @@ export const doNPC후방발령 = (ai: GeneralAI) => { return null; } + const actionPipeline = new GeneralActionPipeline(ai.commandEnv.generalActionModules ?? []); + const actionContext = (general: GeneralAI['general']) => ({ + general, + nation: ai.nation, + ...(ai.worldRef + ? { + worldView: { + listGenerals: () => ai.worldRef!.listGenerals(), + listGeneralsByCity: (cityId: number) => + ai.worldRef!.listGenerals().filter((candidate) => candidate.cityId === cityId), + listNations: () => ai.worldRef!.listNations(), + }, + } + : {}), + time: { + year: ai.world.currentYear, + month: ai.world.currentMonth, + startYear: ai.startYear, + }, + }); const candidates = Object.values(ai.npcWarGenerals).filter((general) => { if (general.id === ai.general.id) { return false; @@ -29,16 +50,56 @@ export const doNPC후방발령 = (ai: GeneralAI) => { if (general.crew >= ai.nationPolicy.minWarCrew) { return false; } + if (actionPipeline.onCalcDomestic(actionContext(general), '징집인구', 'score', 100) <= 1) { + return false; + } return true; }); if (candidates.length === 0) { return null; } + if (Object.keys(ai.supplyCities).length === 1) { + return null; + } const picked = ai.rng.choice(candidates); - const minPop = picked.stats.leadership * 100 + ai.aiConst.minAvailableRecruitPop; - const destCityCandidates = selectRecruitableCity(ai, minPop); + const fullLeadership = actionPipeline.onCalcStat( + actionContext(picked), + 'leadership', + picked.stats.leadership + ) as number; + const minPop = Math.max( + fullLeadership * 100 + ai.aiConst.minAvailableRecruitPop, + fullLeadership * 100 + ai.nationPolicy.minNpcRecruitCityPopulation + ); + const destCityCandidates: Record = {}; + for (const city of Object.values(ai.backupCities)) { + const ratio = resolveCityPopRatio(city); + if ( + city.id !== ai.city?.id && + city.population >= ai.nationPolicy.minNpcRecruitCityPopulation && + city.population >= minPop && + ratio >= ai.nationPolicy.safeRecruitCityPopulationRatio + ) { + destCityCandidates[city.id] = ratio; + } + } + if (Object.keys(destCityCandidates).length === 0) { + for (const city of Object.values(ai.supplyCities)) { + const ratio = resolveCityPopRatio(city); + if ( + city.id !== ai.city?.id && + city.population >= ai.nationPolicy.minNpcRecruitCityPopulation && + city.population > minPop && + ratio >= ai.nationPolicy.safeRecruitCityPopulationRatio + ) { + // Ref contains a non-assigning `pop_ratio / 2` expression for + // front cities, so the persisted behavior keeps this weight. + destCityCandidates[city.id] = ratio; + } + } + } if (Object.keys(destCityCandidates).length === 0) { return null; } @@ -102,12 +163,14 @@ export const doNPC전방발령 = (ai: GeneralAI) => { return null; } + // Ref consumes the general draw before the weighted front-city draw. + // Reversing these two calls keeps the seed but assigns a different person. + const destGeneral = ai.rng.choice(candidates); const cityCandidates = pickFrontCityWeight(ai); const destCityId = Number(ai.rng.choiceUsingWeight(cityCandidates)); if (!Number.isFinite(destCityId)) { return null; } - const destGeneral = ai.rng.choice(candidates); return buildAssignmentCandidate(ai, destGeneral.id, destCityId, 'NPC전방발령'); }; diff --git a/app/game-engine/src/turn/ai/generalAi/nation/rewards.ts b/app/game-engine/src/turn/ai/generalAi/nation/rewards.ts index 2f8a5a43..f75d0e51 100644 --- a/app/game-engine/src/turn/ai/generalAi/nation/rewards.ts +++ b/app/game-engine/src/turn/ai/generalAi/nation/rewards.ts @@ -2,9 +2,40 @@ import type { GeneralAI } from '../core.js'; import { findCrewTypeById, getTechCost } from '@sammo-ts/logic/world/unitSet.js'; import type { TurnGeneral } from '../../../types.js'; import { asRecord, readMetaNumber, readRequiredMetaNumber } from '../../aiUtils.js'; -import { buildAwardCandidate, buildSeizureCandidate, pickWeightedCandidate } from './helpers.js'; +import { buildAwardCandidate, buildSeizureCandidate } from './helpers.js'; type ResourceName = 'gold' | 'rice'; +type ResourceCandidate = [{ destGeneralId: number; amount: number; isGold: boolean }, number]; + +const pickResourceCandidate = ( + ai: GeneralAI, + action: 'award' | 'seizure', + candidates: ResourceCandidate[], + reason: string +) => { + if (candidates.length === 0) { + return null; + } + // Ref chooses the raw argument tuple first and only then checks the + // command's full constraints. The draw is therefore observable even + // when the selected command is rejected. Building/filtering every + // command before the draw shifts the shared nation/general AI stream. + const selected = ai.rng.choiceUsingWeightPair(candidates); + if ((process.env.CORE_AI_TRACE_GENERAL_IDS?.split(',') ?? []).includes(String(ai.general.id))) { + process.stdout.write( + `AI_REWARD_SELECTION_TRACE ${JSON.stringify({ + engine: 'core', + actor: ai.general.id, + reason, + candidates, + selected, + })}\n` + ); + } + return action === 'award' + ? buildAwardCandidate(ai, selected.destGeneralId, selected.amount, selected.isGold, reason) + : buildSeizureCandidate(ai, selected.destGeneralId, selected.amount, selected.isGold, reason); +}; const clampLegacy = (value: number, min: number | null, max: number | null): number => { if (min !== null && max !== null && max < min) { @@ -13,13 +44,30 @@ const clampLegacy = (value: number, min: number | null, max: number | null): num return Math.max(min ?? -Infinity, Math.min(max ?? Infinity, value)); }; -const getFullLeadership = (general: TurnGeneral): number => - readMetaNumber(asRecord(general.meta), 'fullLeadership', general.stats.leadership); +const getFullLeadership = (ai: GeneralAI, general: TurnGeneral): number => { + const nationLevel = ai.nation?.level ?? 0; + const officerBonus = general.officerLevel === 12 ? nationLevel * 2 : general.officerLevel >= 5 ? nationLevel : 0; + const maxStat = ai.commandEnv.maxStatLevel ?? ai.scenarioConfig.stat.max; + return Math.max(0, Math.min(general.stats.leadership + officerBonus, maxStat)); +}; -const getCrewGoldCost = (ai: GeneralAI, general: TurnGeneral, multiplier: number): number => { +const getCrewGoldCost = ( + ai: GeneralAI, + general: TurnGeneral, + baseMultiplier: number, + finalMultiplier = 1 +): number => { const crewType = findCrewTypeById(ai.unitSet, general.crewTypeId ?? ai.commandEnv.defaultCrewTypeId); const tech = readMetaNumber(asRecord(ai.nation?.meta), 'tech', 0); - return (crewType?.cost ?? 0) * getTechCost(tech) * getFullLeadership(general) * multiplier; + // Ref evaluates costWithTech() first, including its `/ 100`, and then + // applies `* 100 * baseMultiplier * finalMultiplier` from GeneralAI. + // Keeping that operation order is observable at exact resource boundaries + // (for example 3036 versus 3036.0000000000005). + return ( + ((((crewType?.cost ?? 0) * getTechCost(tech) * getFullLeadership(ai, general)) / 100) * 100 * + baseMultiplier) * + finalMultiplier + ); }; const sortedByResource = (generals: Record, resource: ResourceName, descending = false) => @@ -35,7 +83,7 @@ export const do유저장긴급포상 = (ai: GeneralAI) => { if (!nation) { return null; } - const candidates: Array<[ReturnType, number]> = []; + const candidates: ResourceCandidate[] = []; const resourceMap: Array<[ResourceName, number]> = [ ['gold', ai.nationPolicy.reqHumanWarUrgentGold], ['rice', ai.nationPolicy.reqHumanWarUrgentRice], @@ -50,7 +98,7 @@ export const do유저장긴급포상 = (ai: GeneralAI) => { if (!canUseGeneral(general)) { continue; } - let required = getCrewGoldCost(ai, general, 3 * 1.1); + let required = getCrewGoldCost(ai, general, 3, 1.1); if (ai.world.currentYear > ai.startYear + 3) { required = Math.max(required, minimum); } @@ -64,14 +112,11 @@ export const do유저장긴급포상 = (ai: GeneralAI) => { continue; } amount = clampLegacy(amount, 100, ai.maxResourceActionAmount); - candidates.push([ - buildAwardCandidate(ai, general.id, amount, resKey === 'gold', '유저장긴급포상'), - generals.length - index, - ]); + candidates.push([{ destGeneralId: general.id, amount, isGold: resKey === 'gold' }, generals.length - index]); } } - return pickWeightedCandidate(ai, candidates); + return pickResourceCandidate(ai, 'award', candidates, '유저장긴급포상'); }; export const do유저장포상 = (ai: GeneralAI) => { @@ -79,7 +124,7 @@ export const do유저장포상 = (ai: GeneralAI) => { if (!nation) { return null; } - const candidates: Array<[ReturnType, number]> = []; + const candidates: ResourceCandidate[] = []; const resourceMap: Array<[ResourceName, number, number, number]> = [ [ 'gold', @@ -109,7 +154,7 @@ export const do유저장포상 = (ai: GeneralAI) => { } let enough: number; if (ai.userWarGenerals[general.id]) { - let required = getCrewGoldCost(ai, general, 6 * 1.1); + let required = getCrewGoldCost(ai, general, 6, 1.1); if (ai.world.currentYear > ai.startYear + 3) { required = Math.max(required, warMinimum); } @@ -126,14 +171,11 @@ export const do유저장포상 = (ai: GeneralAI) => { continue; } amount = clampLegacy(amount, 100, ai.maxResourceActionAmount); - candidates.push([ - buildAwardCandidate(ai, general.id, amount, resKey === 'gold', '유저장포상'), - generals.length - index, - ]); + candidates.push([{ destGeneralId: general.id, amount, isGold: resKey === 'gold' }, generals.length - index]); } } - return pickWeightedCandidate(ai, candidates); + return pickResourceCandidate(ai, 'award', candidates, '유저장포상'); }; export const doNPC긴급포상 = (ai: GeneralAI) => { @@ -141,7 +183,7 @@ export const doNPC긴급포상 = (ai: GeneralAI) => { if (!nation) { return null; } - const candidates: Array<[ReturnType, number]> = []; + const candidates: ResourceCandidate[] = []; const resourceMap: Array<[ResourceName, number, number]> = [ ['gold', ai.nationPolicy.reqNationGold, ai.nationPolicy.reqNpcWarGold / 2], ['rice', ai.nationPolicy.reqNationRice, ai.nationPolicy.reqNpcWarRice / 2], @@ -173,14 +215,11 @@ export const doNPC긴급포상 = (ai: GeneralAI) => { continue; } amount = clampLegacy(amount, 100, ai.maxResourceActionAmount); - candidates.push([ - buildAwardCandidate(ai, general.id, amount, resKey === 'gold', 'NPC긴급포상'), - generals.length - index, - ]); + candidates.push([{ destGeneralId: general.id, amount, isGold: resKey === 'gold' }, generals.length - index]); } } - return pickWeightedCandidate(ai, candidates); + return pickResourceCandidate(ai, 'award', candidates, 'NPC긴급포상'); }; export const doNPC포상 = (ai: GeneralAI) => { @@ -188,7 +227,7 @@ export const doNPC포상 = (ai: GeneralAI) => { if (!nation) { return null; } - const candidates: Array<[ReturnType, number]> = []; + const candidates: ResourceCandidate[] = []; const resourceMap: Array<[ResourceName, number, number, number]> = [ ['gold', ai.nationPolicy.reqNationGold, ai.nationPolicy.reqNpcWarGold, ai.nationPolicy.reqNpcDevelGold], ['rice', ai.nationPolicy.reqNationRice, ai.nationPolicy.reqNpcWarRice, ai.nationPolicy.reqNpcDevelRice], @@ -208,7 +247,7 @@ export const doNPC포상 = (ai: GeneralAI) => { if (!canUseGeneral(general)) { continue; } - let required = getCrewGoldCost(ai, general, 3 * 1.1); + let required = getCrewGoldCost(ai, general, 3, 1.1); if (ai.world.currentYear > ai.startYear + 5) { required = Math.max(required, warMinimum); } @@ -222,10 +261,23 @@ export const doNPC포상 = (ai: GeneralAI) => { continue; } amount = clampLegacy(amount, 100, ai.maxResourceActionAmount); - candidates.push([ - buildAwardCandidate(ai, general.id, amount, resKey === 'gold', 'NPC포상'), - weightBase - index, - ]); + if ((process.env.CORE_AI_TRACE_GENERAL_IDS?.split(',') ?? []).includes(String(ai.general.id))) { + process.stdout.write( + `AI_REWARD_TRACE ${JSON.stringify({ + engine: 'core', + actor: ai.general.id, + target: general.id, + resource: resKey, + nationResource: nation[resKey], + targetResource: general[resKey], + required, + enough, + maxResourceActionAmount: ai.maxResourceActionAmount, + amount, + })}\n` + ); + } + candidates.push([{ destGeneralId: general.id, amount, isGold: resKey === 'gold' }, weightBase - index]); } for (const [index, general] of civilGenerals.entries()) { if (general[resKey] >= civilMinimum) { @@ -239,14 +291,11 @@ export const doNPC포상 = (ai: GeneralAI) => { continue; } amount = clampLegacy(amount, 100, ai.maxResourceActionAmount); - candidates.push([ - buildAwardCandidate(ai, general.id, amount, resKey === 'gold', 'NPC포상'), - weightBase - index, - ]); + candidates.push([{ destGeneralId: general.id, amount, isGold: resKey === 'gold' }, weightBase - index]); } } - return pickWeightedCandidate(ai, candidates); + return pickResourceCandidate(ai, 'award', candidates, 'NPC포상'); }; export const doNPC몰수 = (ai: GeneralAI) => { @@ -254,7 +303,7 @@ export const doNPC몰수 = (ai: GeneralAI) => { if (!nation) { return null; } - const candidates: Array<[ReturnType, number]> = []; + const candidates: ResourceCandidate[] = []; const resourceMap: Array<[ResourceName, number, number, number]> = [ ['gold', ai.nationPolicy.reqNationGold, ai.nationPolicy.reqNpcWarGold, ai.nationPolicy.reqNpcDevelGold], ['rice', ai.nationPolicy.reqNationRice, ai.nationPolicy.reqNpcWarRice, ai.nationPolicy.reqNpcDevelRice], @@ -269,7 +318,7 @@ export const doNPC몰수 = (ai: GeneralAI) => { if (amount < ai.nationPolicy.minimumResourceActionAmount) { break; } - candidates.push([buildSeizureCandidate(ai, general.id, amount, resKey === 'gold', 'NPC몰수'), amount]); + candidates.push([{ destGeneralId: general.id, amount, isGold: resKey === 'gold' }, amount]); } const nationDelta = nationMinimum * 1.5 - nation[resKey]; @@ -294,9 +343,9 @@ export const doNPC몰수 = (ai: GeneralAI) => { break; } amount = clampLegacy(amount, 100, ai.maxResourceActionAmount); - candidates.push([buildSeizureCandidate(ai, general.id, amount, resKey === 'gold', 'NPC몰수'), amount]); + candidates.push([{ destGeneralId: general.id, amount, isGold: resKey === 'gold' }, amount]); } } - return pickWeightedCandidate(ai, candidates); + return pickResourceCandidate(ai, 'seizure', candidates, 'NPC몰수'); }; diff --git a/app/game-engine/src/turn/databaseHooks.ts b/app/game-engine/src/turn/databaseHooks.ts index ca982116..8d25dbd0 100644 --- a/app/game-engine/src/turn/databaseHooks.ts +++ b/app/game-engine/src/turn/databaseHooks.ts @@ -474,9 +474,11 @@ const buildNationUpdate = ( color: nation.color, capitalCityId: nation.capitalCityId, chiefGeneralId: nation.chiefGeneralId, - gold: nation.gold, - rice: nation.rice, - tech: typeof nation.meta.tech === 'number' && Number.isFinite(nation.meta.tech) ? Math.trunc(nation.meta.tech) : 0, + gold: toLegacyDatabaseInt(nation.gold), + rice: toLegacyDatabaseInt(nation.rice), + // Ref persists nation.tech as FLOAT. Domestic research commonly produces + // tenths, and truncating here changes the following month's state/power. + tech: typeof nation.meta.tech === 'number' && Number.isFinite(nation.meta.tech) ? nation.meta.tech : 0, level: nation.level, typeCode: nation.typeCode, meta: asJson({ @@ -755,11 +757,11 @@ export const createDatabaseTurnHooks = async ( color: nation.color, capitalCityId: nation.capitalCityId, chiefGeneralId: nation.chiefGeneralId, - gold: nation.gold, - rice: nation.rice, + gold: toLegacyDatabaseInt(nation.gold), + rice: toLegacyDatabaseInt(nation.rice), tech: typeof nation.meta.tech === 'number' && Number.isFinite(nation.meta.tech) - ? Math.trunc(nation.meta.tech) + ? nation.meta.tech : 0, level: nation.level, typeCode: nation.typeCode, diff --git a/app/game-engine/src/turn/inMemoryTurnProcessor.ts b/app/game-engine/src/turn/inMemoryTurnProcessor.ts index ee473d8e..c851d842 100644 --- a/app/game-engine/src/turn/inMemoryTurnProcessor.ts +++ b/app/game-engine/src/turn/inMemoryTurnProcessor.ts @@ -57,7 +57,18 @@ export class InMemoryTurnProcessor implements TurnProcessor { let generalPartial = false; let nextCheckpoint: TurnCheckpoint | undefined = undefined; - const dueGenerals = this.world.listDueGenerals(targetTime, checkpoint); + const previousLastTurnTime = this.world.getState().lastTurnTime; + const firstTickTime = getNextTickTime(previousLastTurnTime, this.tickMinutes); + // Ref processes `turntime < monthlyBoundary` before the monthly turn. A + // general exactly on the boundary therefore runs only after that month + // has advanced, on the daemon's following pass. + const useStrictGeneralCutoff = + firstTickTime.getTime() === targetTime.getTime() || targetTime.getTime() <= previousLastTurnTime.getTime(); + const generalCutoff = + useStrictGeneralCutoff + ? new Date(targetTime.getTime() - 1) + : targetTime; + const dueGenerals = this.world.listDueGenerals(generalCutoff, checkpoint); for (const general of dueGenerals) { if (processedGenerals >= budget.maxGenerals || isBudgetExpired()) { partial = true; diff --git a/app/game-engine/src/turn/inMemoryWorld.ts b/app/game-engine/src/turn/inMemoryWorld.ts index 378cfc13..02fb35d9 100644 --- a/app/game-engine/src/turn/inMemoryWorld.ts +++ b/app/game-engine/src/turn/inMemoryWorld.ts @@ -233,15 +233,76 @@ const mergeTriggerState = ( meta: { ...base.meta, ...(patch.meta ?? {}) }, }); -const applyGeneralPatch = (base: TurnGeneral, patch: Partial): TurnGeneral => ({ - ...base, - ...patch, - stats: patch.stats ? mergeStats(base.stats, patch.stats) : base.stats, - role: patch.role ? mergeRole(base.role, patch.role) : base.role, - triggerState: patch.triggerState ? mergeTriggerState(base.triggerState, patch.triggerState) : base.triggerState, - meta: patch.meta ? { ...base.meta, ...patch.meta } : base.meta, +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 LEGACY_INTEGER_GENERAL_META_KEYS = [ + 'leadership_exp', + 'strength_exp', + 'intel_exp', + 'dex1', + 'dex2', + 'dex3', + 'dex4', + 'dex5', + 'explevel', + 'dedlevel', + 'killturn', + 'myset', +] as const; + +const normalizeGeneralMetaDatabaseIntegers = (meta: TurnGeneral['meta']): TurnGeneral['meta'] => { + const normalized = { ...meta }; + for (const key of LEGACY_INTEGER_GENERAL_META_KEYS) { + const value = normalized[key]; + if (typeof value === 'number') { + normalized[key] = toLegacyDatabaseInt(value); + } + } + return normalized; +}; + +// Ref writes these values to MariaDB integer columns after every general turn. +// Keeping fractional action results in memory until the monthly flush changes +// later aggregation (notably nation power), even if the eventual DB rows look +// identical after they are rounded. +const normalizeGeneralDatabaseIntegers = (general: TurnGeneral): TurnGeneral => ({ + ...general, + nationId: toLegacyDatabaseInt(general.nationId), + cityId: toLegacyDatabaseInt(general.cityId), + troopId: toLegacyDatabaseInt(general.troopId), + stats: { + leadership: toLegacyDatabaseInt(general.stats.leadership), + strength: toLegacyDatabaseInt(general.stats.strength), + intelligence: toLegacyDatabaseInt(general.stats.intelligence), + }, + experience: toLegacyDatabaseInt(general.experience), + dedication: toLegacyDatabaseInt(general.dedication), + officerLevel: toLegacyDatabaseInt(general.officerLevel), + injury: toLegacyDatabaseInt(general.injury), + gold: toLegacyDatabaseInt(general.gold), + rice: toLegacyDatabaseInt(general.rice), + crew: toLegacyDatabaseInt(general.crew), + crewTypeId: toLegacyDatabaseInt(general.crewTypeId), + train: toLegacyDatabaseInt(general.train), + atmos: toLegacyDatabaseInt(general.atmos), + age: toLegacyDatabaseInt(general.age), + npcState: toLegacyDatabaseInt(general.npcState), + meta: normalizeGeneralMetaDatabaseIntegers(general.meta), }); +const applyGeneralPatch = (base: TurnGeneral, patch: Partial): TurnGeneral => + normalizeGeneralDatabaseIntegers({ + ...base, + ...patch, + stats: patch.stats ? mergeStats(base.stats, patch.stats) : base.stats, + role: patch.role ? mergeRole(base.role, patch.role) : base.role, + triggerState: patch.triggerState ? mergeTriggerState(base.triggerState, patch.triggerState) : base.triggerState, + meta: patch.meta ? { ...base.meta, ...patch.meta } : base.meta, + }); + const applyCityPatch = (base: City, patch: Partial): City => ({ ...base, ...patch, @@ -690,7 +751,7 @@ export class InMemoryTurnWorld { } const worldKillturn = resolveWorldKillturn(this.state.meta); const normalized = normalizeGeneralTurnTime({ ...general }, this.state.lastTurnTime); - const ensured = ensureGeneralKillturn(normalized, worldKillturn); + const ensured = normalizeGeneralDatabaseIntegers(ensureGeneralKillturn(normalized, worldKillturn)); this.generals.set(general.id, ensured); this.dirtyGeneralIds.add(general.id); this.createdGeneralIds.add(general.id); @@ -994,10 +1055,10 @@ export class InMemoryTurnWorld { const nextTurnAt = result.nextTurnAt ?? getNextTurnAt(currentGeneral.turnTime, this.schedule); if (!result.deleted?.general) { - const nextGeneral = { + const nextGeneral = normalizeGeneralDatabaseIntegers({ ...(result.general ?? currentGeneral), turnTime: nextTurnAt, - }; + }); this.generals.set(nextGeneral.id, nextGeneral); this.dirtyGeneralIds.add(nextGeneral.id); } @@ -1067,7 +1128,7 @@ export class InMemoryTurnWorld { } const worldKillturn = resolveWorldKillturn(this.state.meta); const normalized = normalizeGeneralTurnTime({ ...createdGeneral }, this.state.lastTurnTime); - const ensured = ensureGeneralKillturn(normalized, worldKillturn); + const ensured = normalizeGeneralDatabaseIntegers(ensureGeneralKillturn(normalized, worldKillturn)); this.generals.set(createdGeneral.id, ensured); this.dirtyGeneralIds.add(createdGeneral.id); this.createdGeneralIds.add(createdGeneral.id); diff --git a/app/game-engine/src/turn/incomeHandler.ts b/app/game-engine/src/turn/incomeHandler.ts index bbbb8461..33205ce2 100644 --- a/app/game-engine/src/turn/incomeHandler.ts +++ b/app/game-engine/src/turn/incomeHandler.ts @@ -21,6 +21,9 @@ import type { InMemoryTurnWorld, TurnCalendarHandler, TurnCalendarContext } from import { resolveAppliedNationRate } from './nationTaxRate.js'; import type { TurnGeneral } from './types.js'; +const DEFAULT_BASE_GOLD = 0; +const DEFAULT_BASE_RICE = 2_000; + const resolveNumber = (source: Record, keys: string[], fallback: number): number => { for (const key of keys) { const value = source[key]; @@ -97,8 +100,6 @@ const pushLogs = (world: InMemoryTurnWorld, logs: ReturnType Math.round(value); - const applyIncomeOutcome = ( current: number, income: number, @@ -144,7 +145,11 @@ const processIncomeForNation = ( : getRiceIncome(incomeContext, nationCities, officerCounts, nation.capitalCityId ?? 0, nation.level) + getWallIncome(incomeContext, nationCities, officerCounts, nation.capitalCityId ?? 0, nation.level); - const incomeValue = roundResource(income); + // Ref calculates the payout ratio from the pre-persistence income value. + // Half-unit income (for example 943.5) is therefore not rounded before + // salaries are distributed, even though the integer nation column is + // rounded when the final state is flushed to MariaDB. + const incomeValue = income; const originOutcome = getOutcome(100, nationGenerals); const bill = resolveNationBill(nation); const outcome = Math.round((bill / 100) * originOutcome); @@ -167,6 +172,14 @@ const processIncomeForNation = ( type === 'gold' ? `이번 수입은 금 ${incomeText}입니다.` : `이번 수입은 쌀 ${incomeText}입니다.`; for (const general of nationGenerals) { const pay = Math.round(getBill(general.dedication) * ratio); + if ( + process.env.SEED_PARITY_MONTHLY_RESOURCE_TRACE === '1' && + (process.env.AI_TRACE_GENERAL_IDS ?? '').split(',').includes(String(general.id)) + ) { + process.stdout.write( + `MONTHLY_RESOURCE_CORE ${JSON.stringify({ action: 'ProcessIncome', type, generalId: general.id, current: general[type], pay, ratio, originOutcome })}\n` + ); + } if (type === 'gold') { world.updateGeneral(general.id, { gold: general.gold + pay }); } else { @@ -197,8 +210,8 @@ export const createIncomeHandler = (options: { nationTraits: Map; }): IncomeHandler => { const constValues = asRecord(options.scenarioConfig.const); - const baseGold = resolveNumber(constValues, ['baseGold', 'basegold'], 0); - const baseRice = resolveNumber(constValues, ['baseRice', 'baserice'], 0); + const baseGold = resolveNumber(constValues, ['baseGold', 'basegold'], DEFAULT_BASE_GOLD); + const baseRice = resolveNumber(constValues, ['baseRice', 'baserice'], DEFAULT_BASE_RICE); const runResource = (type: 'gold' | 'rice'): void => { const world = options.getWorld(); @@ -217,6 +230,9 @@ export const createIncomeHandler = (options: { } for (const nation of nations) { + if (nation.id <= 0) { + continue; + } const nationGenerals = byNation.get(nation.id) ?? []; const officerCounts = buildOfficerCountMap(nationGenerals); processIncomeForNation( diff --git a/app/game-engine/src/turn/monthlyChangeCityAction.ts b/app/game-engine/src/turn/monthlyChangeCityAction.ts index 5b2e492b..8783a05b 100644 --- a/app/game-engine/src/turn/monthlyChangeCityAction.ts +++ b/app/game-engine/src/turn/monthlyChangeCityAction.ts @@ -241,6 +241,11 @@ export const applyInitialChangeCityEvents = ( throw new Error('Only unconditional initial events are supported.'); } for (const rawAction of rawEvent.slice(1)) { + if (Array.isArray(rawAction) && rawAction[0] === 'NoticeToHistoryLog') { + // Initial history logging is handled by the install history + // boundary; it has no city-state effect here. + continue; + } if (!Array.isArray(rawAction) || rawAction[0] !== 'ChangeCity') { throw new Error('Only ChangeCity initial actions are supported.'); } diff --git a/app/game-engine/src/turn/monthlyDisasterAction.ts b/app/game-engine/src/turn/monthlyDisasterAction.ts index 08b5b311..99a341ef 100644 --- a/app/game-engine/src/turn/monthlyDisasterAction.ts +++ b/app/game-engine/src/turn/monthlyDisasterAction.ts @@ -134,6 +134,7 @@ export const createRaiseDisasterHandler = (options: { const securityRatio = clamp(city.security / city.securityMax / 0.8, 0, 1); const affectRatio = isGood ? 1.01 + securityRatio * 0.04 : 0.8 + securityRatio * 0.15; const trust = typeof city.meta.trust === 'number' ? city.meta.trust : 0; + const storedTrust = Math.fround(isGood ? Math.min(trust * affectRatio, 100) : trust * affectRatio); world.updateCity(city.id, { state: picked.stateCode, population: roundLegacyIntegerColumn( @@ -158,9 +159,25 @@ export const createRaiseDisasterHandler = (options: { ), meta: { ...city.meta, - trust: isGood ? Math.min(trust * affectRatio, 100) : trust * affectRatio, + // Ref assigns the SQL expression to a MariaDB FLOAT + // column at each disaster event boundary. + trust: storedTrust, }, }); + if ((process.env.CORE_AI_TRACE_CITY_IDS?.split(',') ?? []).includes(String(city.id))) { + process.stdout.write( + `MONTHLY_FLOAT_TRACE ${JSON.stringify({ + engine: 'core', + cityId: city.id, + year: environment.year, + month: environment.month, + isGood, + inputTrust: trust, + affectRatio, + storedTrust, + })}\n` + ); + } if (isGood) { continue; diff --git a/app/game-engine/src/turn/monthlyEventHandler.ts b/app/game-engine/src/turn/monthlyEventHandler.ts index 5fa49163..db7f1809 100644 --- a/app/game-engine/src/turn/monthlyEventHandler.ts +++ b/app/game-engine/src/turn/monthlyEventHandler.ts @@ -86,7 +86,9 @@ export const createRandomizeCityTradeRateHandler = (options: { ) ); - for (const city of world.listCities()) { + // Ref's `SELECT city, level FROM city` walks the primary city key. + // Make RNG consumption independent from PostgreSQL snapshot/insertion order. + for (const city of world.listCities().sort((left, right) => left.id - right.id)) { const probability = CITY_TRADE_PROBABILITY_BY_LEVEL[city.level]; if (probability === undefined) { throw new Error(`Unsupported city level for RandomizeCityTradeRate: ${city.level} (cityId=${city.id})`); @@ -226,6 +228,11 @@ export const createMonthlyEventHandler = (options: { const year = targetCode === 'pre_month' ? context.previousYear : context.currentYear; const month = targetCode === 'pre_month' ? context.previousMonth : context.currentMonth; const remainingNationCount = world.listNations().filter((nation) => nation.id > 0).length; + // Ref does not write game_env.turntime until every event and + // postUpdateMonthly step has completed. Event actions therefore see + // the previous monthly boundary even after turnDate() has advanced + // year/month. Generated general turn times depend on this distinction. + const legacyTurnTime = new Date(context.turnTime.getTime() - world.getState().tickSeconds * 1_000); for (const event of world.listEvents(targetCode)) { const environment: MonthlyEventEnvironment = { @@ -233,7 +240,7 @@ export const createMonthlyEventHandler = (options: { month, startyear: options.startYear, currentEventID: event.id, - turnTime: context.turnTime, + turnTime: legacyTurnTime, }; if (!evaluateCondition(event.condition, environment, remainingNationCount)) { continue; diff --git a/app/game-engine/src/turn/monthlyNationLevelAction.ts b/app/game-engine/src/turn/monthlyNationLevelAction.ts index ba4491bc..f70781d3 100644 --- a/app/game-engine/src/turn/monthlyNationLevelAction.ts +++ b/app/game-engine/src/turn/monthlyNationLevelAction.ts @@ -249,6 +249,12 @@ export const createUpdateNationLevelHandler = (options: { const hiddenSeed = resolveHiddenSeed(world); for (const nation of world.listNations().sort((left, right) => left.id - right.id)) { + // Legacy persists only founded nations in `nation`; Core also keeps + // an id=0 sentinel so neutral cities can retain a Nation reference. + // The sentinel must never participate in title promotion. + if (nation.id <= 0) { + continue; + } const cityCount = cityCounts.get(nation.id) ?? 0; let newLevel = 0; for (let level = 0; level < NATION_LEVEL_CITY_COUNTS.length; level += 1) { diff --git a/app/game-engine/src/turn/monthlyNationStatsHandler.ts b/app/game-engine/src/turn/monthlyNationStatsHandler.ts index 453ffc64..62dc8f24 100644 --- a/app/game-engine/src/turn/monthlyNationStatsHandler.ts +++ b/app/game-engine/src/turn/monthlyNationStatsHandler.ts @@ -7,6 +7,11 @@ import type { InMemoryTurnWorld, TurnCalendarHandler } from './inMemoryWorld.js' const MAX_AVAILABLE_WAR_SETTING_COUNT = 10; const MONTHLY_AVAILABLE_WAR_SETTING_INCREMENT = 2; +export const roundLegacyNationPowerValue = (value: number): number => { + const stabilized = Number(value.toPrecision(15)); + return stabilized >= 0 ? Math.floor(stabilized + 0.5) : Math.ceil(stabilized - 0.5); +}; + const readNumber = (value: unknown, fallback = 0): number => { if (typeof value === 'number' && Number.isFinite(value)) { return value; @@ -32,15 +37,16 @@ const calculateNationPower = ( ): { power: number; totalCrew: number; + trace: Record; } => { const nation = world.getNationById(nationId); if (!nation) { - return { power: 0, totalCrew: 0 }; + return { power: 0, totalCrew: 0, trace: {} }; } const generals = world.listGenerals().filter((general) => general.nationId === nationId); const suppliedCities = world.listCities().filter((city) => city.nationId === nationId && city.supplyState === 1); const generalResources = generals.reduce((sum, general) => sum + general.gold + general.rice, 0); - const resourcePower = Math.round((nation.gold + nation.rice + generalResources) / 100); + const resourcePower = roundLegacyNationPowerValue((nation.gold + nation.rice + generalResources) / 100); const techPower = readNumber(asRecord(nation.meta).tech); let cityPower = 0; @@ -62,7 +68,7 @@ const calculateNationPower = ( city.defenceMax, 0 ); - cityPower = maximum > 0 ? Math.round((population * current) / maximum / 100) : 0; + cityPower = maximum > 0 ? roundLegacyNationPowerValue((population * current) / maximum / 100) : 0; } let generalPower = 0; @@ -90,16 +96,29 @@ const calculateNationPower = ( totalCrew += general.crew; } - const power = Math.round( + const power = roundLegacyNationPowerValue( (resourcePower + techPower + cityPower + generalPower + - Math.round(dexterityPower / 1000) + - Math.round(experiencePower / 100)) / + roundLegacyNationPowerValue(dexterityPower / 1000) + + roundLegacyNationPowerValue(experiencePower / 100)) / 10 ); - return { power, totalCrew }; + return { + power, + totalCrew, + trace: { + resourcePower, + techPower, + cityPower, + generalPower, + dexterityPowerRaw: dexterityPower / 1000, + dexterityPower: roundLegacyNationPowerValue(dexterityPower / 1000), + experiencePowerRaw: experiencePower / 100, + experiencePower: roundLegacyNationPowerValue(experiencePower / 100), + }, + }; }; const updateNationPower = (world: InMemoryTurnWorld, rng: RandUtil): number => { @@ -110,10 +129,25 @@ const updateNationPower = (world: InMemoryTurnWorld, rng: RandUtil): number => { citiesByNation.set(city.nationId, names); } - const nations = world.listNations().sort((left, right) => left.id - right.id); + // Core keeps an internal nation 0 record, while Ref's nation table/query + // starts at 1. It must not consume a monthly RNG draw or receive power. + const nations = world + .listNations() + .filter((nation) => nation.id > 0) + .sort((left, right) => left.id - right.id); for (const nation of nations) { const calculated = calculateNationPower(world, nation.id); - const power = Math.round(calculated.power * rng.nextRange(0.95, 1.05)); + const multiplier = rng.nextRange(0.95, 1.05); + const power = roundLegacyNationPowerValue(calculated.power * multiplier); + const traceIds = (process.env.SEED_PARITY_TRACE_NATION_POWER_IDS ?? '') + .split(',') + .map((value) => Number(value.trim())) + .filter(Number.isFinite); + if (traceIds.includes(nation.id)) { + process.stdout.write( + `NATION_POWER_TRACE ${JSON.stringify({ nationId: nation.id, ...calculated.trace, basePower: calculated.power, multiplier, power })}\n` + ); + } const meta = asRecord(nation.meta); const previousMax = asRecord(meta.max_power); const previousCities = Array.isArray(previousMax.maxCities) diff --git a/app/game-engine/src/turn/monthlySemiAnnualAction.ts b/app/game-engine/src/turn/monthlySemiAnnualAction.ts index 595b4e7a..523f30a9 100644 --- a/app/game-engine/src/turn/monthlySemiAnnualAction.ts +++ b/app/game-engine/src/turn/monthlySemiAnnualAction.ts @@ -7,7 +7,12 @@ import { resolveAppliedNationRate } from './nationTaxRate.js'; type SemiAnnualResource = 'gold' | 'rice'; -const roundLegacyIntegerColumn = (value: number): number => Math.round(value); +const roundLegacyIntegerColumn = (value: number): number => { + // MariaDB evaluates the decimal rate expression before ROUND(). Binary + // arithmetic can instead produce values such as 2029.4999999999998. + const stabilized = Number(value.toPrecision(15)); + return stabilized >= 0 ? Math.floor(stabilized + 0.5) : Math.ceil(stabilized - 0.5); +}; const parseResource = (args: readonly unknown[]): SemiAnnualResource => { const resource = args[0]; @@ -24,6 +29,8 @@ const resolveBasePopulationIncrease = (world: InMemoryTurnWorld): number => { const decayDomesticValue = (value: number): number => roundLegacyIntegerColumn(value * 0.99); +export const storeLegacySemiAnnualTrust = (value: number): number => Math.fround(Math.max(0, Math.min(100, value))); + const applyResourceMaintenance = (value: number, ratios: readonly [number, number][]): number => { if (value <= 1_000) { return value; @@ -81,6 +88,9 @@ export const createProcessSemiAnnualHandler = (options: { const basePopulationIncrease = resolveBasePopulationIncrease(world); for (const nation of world.listNations()) { + if (nation.id <= 0) { + continue; + } const rate = resolveAppliedNationRate(nation.meta); let populationRatio = (30 - rate) / 200; const trait = options.nationTraits?.get(nation.typeCode); @@ -115,7 +125,10 @@ export const createProcessSemiAnnualHandler = (options: { wall: roundLegacyIntegerColumn(Math.min(city.wallMax, city.wall * (1 + genericRatio))), meta: { ...city.meta, - trust: Math.max(0, Math.min(100, trust + trustDiff)), + // Ref's UPDATE persists trust to a MariaDB FLOAT before + // the next monthly action reads it. Core stores this + // field in JSON, so emulate that binary32 boundary here. + trust: storeLegacySemiAnnualTrust(trust + trustDiff), }, }); } @@ -124,6 +137,14 @@ export const createProcessSemiAnnualHandler = (options: { for (const general of world.listGenerals()) { const current = general[resource]; const next = applyResourceMaintenance(current, [[10_000, 0.97]]); + if ( + process.env.SEED_PARITY_MONTHLY_RESOURCE_TRACE === '1' && + (process.env.AI_TRACE_GENERAL_IDS ?? '').split(',').includes(String(general.id)) + ) { + process.stdout.write( + `MONTHLY_RESOURCE_CORE ${JSON.stringify({ action: 'ProcessSemiAnnual', resource, generalId: general.id, current, next })}\n` + ); + } if (next !== current) { world.updateGeneral(general.id, { [resource]: next }); } diff --git a/app/game-engine/src/turn/monthlyWarIncomeAction.ts b/app/game-engine/src/turn/monthlyWarIncomeAction.ts index 9733cb40..79de78bd 100644 --- a/app/game-engine/src/turn/monthlyWarIncomeAction.ts +++ b/app/game-engine/src/turn/monthlyWarIncomeAction.ts @@ -69,6 +69,8 @@ export const createProcessWarIncomeHandler = (options: { for (const city of cities) { const dead = asNumber(city.meta.dead, 0); world.updateCity(city.id, { + // MariaDB rounds the SQL expression when assigning it to + // Ref's integer pop column (including an exact .5 result). population: Math.round(city.population + dead * 0.2), meta: { ...city.meta, diff --git a/app/game-engine/src/turn/npcTaxHandler.ts b/app/game-engine/src/turn/npcTaxHandler.ts index b2c9748e..8b67e935 100644 --- a/app/game-engine/src/turn/npcTaxHandler.ts +++ b/app/game-engine/src/turn/npcTaxHandler.ts @@ -1,6 +1,24 @@ +import { asRecord } from '@sammo-ts/common'; +import { + createIncomeActionContext, + getGoldIncome, + getOutcome, + getRiceIncome, + getWallIncome, + getWarGoldIncome, + type City, + type CityIncomeSource, + type Nation, + type NationIncomeContext, + type NationTraitModule, + type ScenarioConfig, + type TurnCommandEnv, + type UnitSetDefinition, +} from '@sammo-ts/logic'; + import type { TurnCalendarContext, TurnCalendarHandler, InMemoryTurnWorld } from './inMemoryWorld.js'; -import type { City, Nation } from '@sammo-ts/logic'; -import { joinYearMonth, readNumber } from './ai/aiUtils.js'; +import { readNumber } from './ai/aiUtils.js'; +import { AutorunNationPolicy } from './ai/policies.js'; const calcNationDevelopedRate = (cities: City[]): { pop: number; all: number } => { if (cities.length === 0) { @@ -51,15 +69,129 @@ const resolveNpcTaxRate = (cities: City[]): number => { const shouldUpdateRate = (month: number): boolean => month === 6 || month === 12; -const isNpcMonarchNation = (nation: Nation, world: InMemoryTurnWorld): boolean => { - if (!nation.capitalCityId || !nation.chiefGeneralId) { - return false; +const toIncomeCity = (city: City): CityIncomeSource => ({ + id: city.id, + population: city.population, + populationMax: city.populationMax, + agriculture: city.agriculture, + agricultureMax: city.agricultureMax, + commerce: city.commerce, + commerceMax: city.commerceMax, + security: city.security, + securityMax: city.securityMax, + trust: readNumber(asRecord(city.meta).trust, 50), + supplyState: city.supplyState, + defence: city.defence, + defenceMax: city.defenceMax, + wall: city.wall, + wallMax: city.wallMax, + meta: asRecord(city.meta), +}); + +const buildOfficerCounts = (world: InMemoryTurnWorld, nationId: number): Map => { + const result = new Map(); + for (const general of world.listGenerals()) { + if (general.nationId !== nationId || general.officerLevel < 2 || general.officerLevel > 4) continue; + const officerCity = readNumber(asRecord(general.meta).officer_city, 0); + if (officerCity > 0 && general.cityId === officerCity) { + result.set(officerCity, (result.get(officerCity) ?? 0) + 1); + } } - const chief = world.getGeneralById(nation.chiefGeneralId); - return Boolean(chief && chief.npcState >= 2); + return result; }; -export const createNpcTaxHandler = (options: { getWorld: () => InMemoryTurnWorld | null }): TurnCalendarHandler => { +const clampBill = (value: number): number => Math.max(20, Math.min(200, Math.trunc(value))); + +const resolveNpcMonarch = (nation: Nation, world: InMemoryTurnWorld) => { + const chief = nation.chiefGeneralId + ? world.getGeneralById(nation.chiefGeneralId) + : world + .listGenerals() + .find((general) => general.nationId === nation.id && general.officerLevel === 12) ?? null; + return chief && chief.npcState >= 2 ? chief : null; +}; + +type NpcFinanceOptions = { + commandEnv?: TurnCommandEnv; + scenarioConfig?: ScenarioConfig; + unitSet?: UnitSetDefinition; + nationTraits?: ReadonlyMap; +}; + +export const calculateNpcNationFinance = ( + world: InMemoryTurnWorld, + nation: Nation, + currentMonth: number, + options: NpcFinanceOptions +): Nation['meta'] | null => { + if (!shouldUpdateRate(currentMonth)) { + return null; + } + const chief = resolveNpcMonarch(nation, world); + if (!chief) return null; + const cities = world.listCities(); + const rawNationCities = cities.filter((city) => city.nationId === nation.id && city.supplyState > 0); + const rate = resolveNpcTaxRate(rawNationCities); + // Ref chooses the default rate during the ruler turn even when a newly + // founded nation has not supplied its first city yet. In that state it + // leaves the existing bill untouched because there is no income basis. + if (rawNationCities.length === 0) { + return { ...nation.meta, rate }; + } + if (!options.commandEnv || !options.scenarioConfig) { + return { ...nation.meta, rate }; + } + const trait = options.nationTraits?.get(nation.typeCode); + const actionContext = createIncomeActionContext(nation); + const incomeContext: NationIncomeContext = { + rate, + ...(trait?.onCalcNationalIncome + ? { modifyIncome: (type, amount) => trait.onCalcNationalIncome!(actionContext, type, amount) } + : {}), + }; + const nationCities = rawNationCities.map(toIncomeCity); + const officerCounts = buildOfficerCounts(world, nation.id); + const generals = world + .listGenerals() + // Ref's GeneralAI::$nationGenerals is built with `no != current ruler`. + // chooseGoldBillRate therefore omits the ruler's own stipend from the + // outcome used to choose bill, despite a dead local append that looks + // as if it intended to include the ruler. + .filter((general) => general.nationId === nation.id && general.id !== chief.id && general.npcState !== 5); + const outcome = Math.max(1, getOutcome(100, generals)); + const policy = new AutorunNationPolicy({ + general: chief, + aiOptions: null, + nationPolicy: asRecord(nation.meta).npc_nation_policy as Record | null, + serverPolicy: asRecord(world.getState().meta).npc_nation_policy as Record | null, + nation, + env: options.commandEnv, + scenarioConfig: options.scenarioConfig, + ...(options.unitSet ? { unitSet: options.unitSet } : {}), + }); + const income = + currentMonth === 12 + ? getGoldIncome(incomeContext, nationCities, officerCounts, nation.capitalCityId, nation.level) + + getWarGoldIncome(incomeContext, nationCities) + : getRiceIncome(incomeContext, nationCities, officerCounts, nation.capitalCityId, nation.level) + + getWallIncome(incomeContext, nationCities, officerCounts, nation.capitalCityId, nation.level); + const currentResource = currentMonth === 12 ? nation.gold : nation.rice; + const requiredResource = currentMonth === 12 ? policy.reqNationGold : policy.reqNationRice; + let bill = Math.trunc((income / outcome) * 90); + if (currentResource + income - outcome > requiredResource * 2) { + const moreBill = ((currentResource + income - requiredResource * 2) / outcome) * 80; + if (moreBill > bill) bill = Math.trunc((moreBill + bill) / 2); + } + return { ...nation.meta, rate, bill: clampBill(bill) }; +}; + +export const createNpcTaxHandler = (options: { + getWorld: () => InMemoryTurnWorld | null; + commandEnv?: TurnCommandEnv; + scenarioConfig?: ScenarioConfig; + unitSet?: UnitSetDefinition; + nationTraits?: ReadonlyMap; +}): TurnCalendarHandler => { return { onMonthChanged: (context: TurnCalendarContext) => { if (!shouldUpdateRate(context.currentMonth)) { @@ -69,30 +201,9 @@ export const createNpcTaxHandler = (options: { getWorld: () => InMemoryTurnWorld if (!world) { return; } - const worldState = world.getState(); - const meta = worldState.meta ?? {}; - const initYear = readNumber(meta.initYear, NaN); - const initMonth = readNumber(meta.initMonth, NaN); - const hasInit = Number.isFinite(initYear) && Number.isFinite(initMonth); - const monthsSinceStart = hasInit - ? joinYearMonth(context.currentYear, context.currentMonth) - joinYearMonth(initYear, initMonth) - : null; - const cities = world.listCities(); for (const nation of world.listNations()) { - if (!isNpcMonarchNation(nation, world)) { - continue; - } - const nationCities = cities.filter((city) => city.nationId === nation.id && city.supplyState > 0); - let rate = resolveNpcTaxRate(nationCities); - if (monthsSinceStart !== null && monthsSinceStart <= 4) { - rate = Math.min(rate, 10); - } - world.updateNation(nation.id, { - meta: { - ...nation.meta, - rate, - }, - }); + const nextMeta = calculateNpcNationFinance(world, nation, context.currentMonth, options); + if (nextMeta) world.updateNation(nation.id, { meta: nextMeta }); } }, }; diff --git a/app/game-engine/src/turn/reservedTurnCommands.ts b/app/game-engine/src/turn/reservedTurnCommands.ts index efe17b87..4201bc9c 100644 --- a/app/game-engine/src/turn/reservedTurnCommands.ts +++ b/app/game-engine/src/turn/reservedTurnCommands.ts @@ -129,7 +129,7 @@ export const buildCommandEnv = (config: ScenarioConfig, unitSet?: UnitSetDefinit ]), initialNationGenLimit: resolveNumber(constValues, ['initialNationGenLimit'], DEFAULT_INITIAL_NATION_GEN_LIMIT), maxTechLevel: resolveNumber(constValues, ['maxTechLevel'], DEFAULT_MAX_TECH_LEVEL), - maxStatLevel: resolveNumber(constValues, ['maxLevel'], 255), + maxStatLevel: resolveNumber(constValues, ['maxLevel'], config.stat.max), maxDedicationLevel: resolveNumber(constValues, ['maxDedLevel'], 30), statUpgradeLimit: resolveNumber(constValues, ['upgradeLimit'], 30), techLevelIncYear: resolveNumber(constValues, ['techLevelIncYear'], 5), diff --git a/app/game-engine/src/turn/reservedTurnHandler.ts b/app/game-engine/src/turn/reservedTurnHandler.ts index a730f211..ae757065 100644 --- a/app/game-engine/src/turn/reservedTurnHandler.ts +++ b/app/game-engine/src/turn/reservedTurnHandler.ts @@ -10,6 +10,7 @@ import type { ScenarioConfig, ScenarioMeta, Troop, + GeneralTurnCommandKey, TurnCommandProfile, TurnCommandEnv, UnitSetDefinition, @@ -58,6 +59,7 @@ import { buildFrontStatePatches } from './frontStateHandler.js'; import { buildActionContext } from './reservedTurnActionContext.js'; import { GeneralAI, shouldUseAi } from './ai/generalAi.js'; import type { AiReservedTurnProvider } from './ai/types.js'; +import { withCanonicalArgumentAliases } from './ai/aiUtils.js'; import { rankMetaKey } from './rankData.js'; import { hasScenarioStaticEventHandler, @@ -66,6 +68,7 @@ import { } from './scenarioStaticEvents.js'; const DEFAULT_ACTION = '휴식'; +const AI_INTERNAL_GENERAL_ACTION_KEYS = ['che_NPC능동'] as const satisfies readonly GeneralTurnCommandKey[]; const LEGACY_STAT_CHANGE_GENERAL_ACTIONS = new Set([ 'che_소집해제', @@ -96,6 +99,12 @@ const LEGACY_STAT_CHANGE_GENERAL_ACTIONS = new Set([ 'che_첩보', 'che_임관', 'che_상업투자', + // These legacy classes inherit che_상업투자::run(), including its + // unconditional checkStatChange() tail. + 'che_농지개간', + 'che_수비강화', + 'che_성벽보수', + 'che_치안강화', 'che_장비매매', 'che_장수대상임관', 'che_징병', @@ -682,21 +691,6 @@ class WorldStateView implements StateView { const extractArgsRecord = (value: unknown): Record => asRecord(value); -const withCanonicalArgumentAliases = (args: Record): Record => { - 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, @@ -737,6 +731,11 @@ export const createReservedTurnHandler = async (options: { commandEnv?: TurnCommandEnv; commandRngFactory?: (input: { kind: 'nation' | 'general'; actionKey: string; seed: string }) => RandUtil; getAdditionalOccupiedUniqueItemKeys?: () => Iterable; + calculateNpcNationFinance?: ( + world: InMemoryTurnWorld, + nation: Nation, + currentMonth: number + ) => Nation['meta'] | null; onActionResolved?: (payload: { kind: 'nation' | 'general'; generalId: number; @@ -774,6 +773,17 @@ export const createReservedTurnHandler = async (options: { }; const generalModuleLoader = new GeneralTurnCommandLoader(); const nationModuleLoader = new NationTurnCommandLoader(); + // NPC AI emits a few engine-internal commands that are intentionally not + // exposed by the scenario's player command profile. Keep their definitions + // available to AI resolution without adding them to the public profile. + for (const key of AI_INTERNAL_GENERAL_ACTION_KEYS) { + const module = await generalModuleLoader.load(key); + if (!generalDefinitions.has(key)) { + generalDefinitions.set(key, module.commandSpec.createDefinition(env)); + } + seenActionKeys.add(key); + applyActionContextBuilder(module); + } for (const key of commandProfile.general) { if (seenActionKeys.has(key)) { continue; @@ -830,6 +840,9 @@ export const createReservedTurnHandler = async (options: { return { execute(context): GeneralTurnResult { + // Legacy reads the current game_env.develcost for every command. + // Scenario const is only a fallback; the value changes with year. + env.develCost = readMetaNumber(asRecord(context.world.meta), 'develcost', env.develCost); const worldRef = options.getWorld(); const worldOverlay = worldRef ? createWorldOverlay(worldRef) : null; const worldView = worldOverlay?.view ?? worldRef; @@ -1034,6 +1047,12 @@ export const createReservedTurnHandler = async (options: { specificContext = baseContext; } const actionContext = specificContext ?? baseContext; + if ((process.env.CORE_AI_TRACE_GENERAL_IDS?.split(',') ?? []).includes(String(currentGeneral.id))) { + const tracedContext = actionContext as ActionContextBase & { destCity?: City; destGeneral?: TurnGeneral }; + process.stdout.write( + `AI_ACTION_INPUT_TRACE ${JSON.stringify({ generalId: currentGeneral.id, kind, actionKey, actionArgs, destCityId: tracedContext.destCity?.id, destGeneralId: tracedContext.destGeneral?.id })}\n` + ); + } const executionDefinition = definition as unknown as { getPreReqTurn?: (context: ActionContextBase, args: unknown) => number; getPostReqTurn?: (context: ActionContextBase, args: unknown) => number; @@ -1115,9 +1134,29 @@ export const createReservedTurnHandler = async (options: { }, actionArgs ); + if ( + (process.env.CORE_AI_TRACE_GENERAL_IDS?.split(',') ?? []).includes(String(currentGeneral.id)) && + resolution.patches + ) { + process.stdout.write( + `AI_ACTION_PATCH_TRACE ${JSON.stringify({ generalId: currentGeneral.id, kind, actionKey, patches: resolution.patches })}\n` + ); + } for (const troopId of resolution.deletedTroopIds ?? []) { commandDeletedTroopIds.add(troopId); } + for (const plan of resolution.reservedGeneralTurnPlans ?? []) { + for (let turnIdx = 0; turnIdx < plan.joinTurn; turnIdx += 1) { + options.reservedTurns.setGeneralTurn(plan.generalId, turnIdx, { + action: 'che_견문', + args: {}, + }); + } + options.reservedTurns.setGeneralTurn(plan.generalId, plan.joinTurn, { + action: 'che_임관', + args: { destNationId: plan.destNationId }, + }); + } currentGeneral = resolution.general as TurnGeneral; currentCity = resolution.city ?? currentCity; @@ -1559,6 +1598,7 @@ export const createReservedTurnHandler = async (options: { } let hasReservedTurn = false; + let sharedAi: GeneralAI | undefined; if (!isBlocked && currentNation && currentGeneral.officerLevel >= 5) { let nationCommand = options.reservedTurns.getNationTurn( currentNation.id, @@ -1570,7 +1610,7 @@ export const createReservedTurnHandler = async (options: { } let nationAiState: ReturnType | undefined; if (worldView && shouldUseAi(currentGeneral, context.world)) { - const ai = new GeneralAI({ + sharedAi = new GeneralAI({ general: currentGeneral, city: currentCity, nation: currentNation, @@ -1587,13 +1627,66 @@ export const createReservedTurnHandler = async (options: { generalFallback, nationFallback, }); + const ai = sharedAi; const candidate = ai.chooseNationTurn(nationCommand); if (candidate) { + if ( + (process.env.CORE_AI_TRACE_GENERAL_IDS?.split(',') ?? []).includes( + String(currentGeneral.id) + ) + ) { + process.stdout.write( + `AI_NATION_CANDIDATE_TRACE ${JSON.stringify({ generalId: currentGeneral.id, candidate })}\n` + ); + } nationCommand = { action: candidate.action, args: candidate.args }; } + const promotion = ai.consumePromotionPatches(); + for (const entry of promotion.generals) { + const promotedGeneral = worldOverlay?.view.getGeneralById(entry.generalId); + const patch = { + officerLevel: entry.officerLevel, + ...(promotedGeneral + ? { meta: { ...promotedGeneral.meta, officer_city: entry.officerCity } } + : {}), + }; + patches.generals.push({ id: entry.generalId, patch }); + worldOverlay?.applyGeneralPatch(entry.generalId, patch); + if (entry.generalId === currentGeneral.id) { + currentGeneral = applyGeneralPatch(currentGeneral, patch); + } + } + if (promotion.nationMeta && currentNation) { + currentNation = { ...currentNation, meta: promotion.nationMeta as Nation['meta'] }; + worldOverlay?.applyNationPatch(currentNation.id, { meta: currentNation.meta }); + } + if (currentNation && currentGeneral.officerLevel === 12 && options.calculateNpcNationFinance) { + const baseWorld = options.getWorld(); + const financeMeta = baseWorld + ? options.calculateNpcNationFinance(baseWorld, currentNation, context.world.currentMonth) + : null; + if (financeMeta) { + currentNation = { ...currentNation, meta: financeMeta as Nation['meta'] }; + worldOverlay?.applyNationPatch(currentNation.id, { meta: currentNation.meta }); + } + } nationAiState = ai.getDebugState(); } const nationResult = runAction('nation', nationDefinitions, nationFallback, nationCommand, false); + if ( + worldView && + (process.env.CORE_AI_TRACE_GENERAL_IDS?.split(',') ?? []).includes(String(currentGeneral.id)) + ) { + process.stdout.write( + `AI_DIPLOMACY_TRACE ${JSON.stringify({ + generalId: currentGeneral.id, + stage: 'after-nation-action', + entries: worldView + .listDiplomacy() + .filter((entry) => entry.fromNationId === currentGeneral.nationId && entry.state <= 1), + })}\n` + ); + } options.onActionResolved?.({ kind: 'nation', generalId: currentGeneral.id, @@ -1618,23 +1711,25 @@ export const createReservedTurnHandler = async (options: { let generalAiState: ReturnType | undefined; let generalAutorunMode = false; if (!isBlocked && worldView && shouldUseAi(currentGeneral, context.world)) { - const ai = new GeneralAI({ - general: currentGeneral, - city: currentCity, - nation: currentNation, - world: context.world, - worldRef: worldView, - reservedTurnProvider, - scenarioConfig: options.scenarioConfig, - scenarioMeta: options.scenarioMeta, - map: options.map, - unitSet: options.unitSet, - commandEnv: env, - generalDefinitions, - nationDefinitions, - generalFallback, - nationFallback, - }); + const ai = + sharedAi ?? + new GeneralAI({ + general: currentGeneral, + city: currentCity, + nation: currentNation, + world: context.world, + worldRef: worldView, + reservedTurnProvider, + scenarioConfig: options.scenarioConfig, + scenarioMeta: options.scenarioMeta, + map: options.map, + unitSet: options.unitSet, + commandEnv: env, + generalDefinitions, + nationDefinitions, + generalFallback, + nationFallback, + }); const candidate = ai.chooseGeneralTurn(generalCommand); if (candidate) { generalAutorunMode = @@ -1642,6 +1737,18 @@ export const createReservedTurnHandler = async (options: { JSON.stringify(candidate.args ?? {}) !== JSON.stringify(generalCommand.args ?? {}); generalCommand = { action: candidate.action, args: candidate.args }; } + const aiMetaPatch = ai.consumePersistentGeneralMetaPatch(); + if (Object.keys(aiMetaPatch.set).length > 0 || aiMetaPatch.unset.length > 0) { + const nextMeta = { ...currentGeneral.meta } as Record; + for (const key of aiMetaPatch.unset) { + delete nextMeta[key]; + } + currentGeneral = { + ...currentGeneral, + meta: { ...nextMeta, ...aiMetaPatch.set } as TurnGeneral['meta'], + }; + worldOverlay?.syncGeneral(currentGeneral); + } generalAiState = ai.getDebugState(); } const generalResult = isBlocked @@ -1854,6 +1961,12 @@ export const createReservedTurnHandler = async (options: { }, }; + if ((process.env.CORE_AI_TRACE_GENERAL_IDS?.split(',') ?? []).includes(String(currentGeneral.id))) { + process.stdout.write( + `AI_GENERAL_PRE_APPLY_TRACE ${JSON.stringify({ engine: 'core', generalId: currentGeneral.id, stats: currentGeneral.stats, experience: currentGeneral.experience, dedication: currentGeneral.dedication, meta: { leadership_exp: currentGeneral.meta.leadership_exp, strength_exp: currentGeneral.meta.strength_exp, intel_exp: currentGeneral.meta.intel_exp, dex1: currentGeneral.meta.dex1, dex2: currentGeneral.meta.dex2, dex3: currentGeneral.meta.dex3, dex4: currentGeneral.meta.dex4, dex5: currentGeneral.meta.dex5 } })}\n` + ); + } + const result: GeneralTurnResult = { general: currentGeneral, city: currentCity, diff --git a/app/game-engine/src/turn/reservedTurnStore.ts b/app/game-engine/src/turn/reservedTurnStore.ts index 31269fe5..41385325 100644 --- a/app/game-engine/src/turn/reservedTurnStore.ts +++ b/app/game-engine/src/turn/reservedTurnStore.ts @@ -460,6 +460,20 @@ export class InMemoryReservedTurnStore { this.dirtyGeneralIds.add(generalId); } + setGeneralTurn(generalId: number, turnIdx: number, entry: ReservedTurnEntry): void { + if (turnIdx < 0 || turnIdx >= this.maxGeneralTurns) { + return; + } + const turns = this.getGeneralTurns(generalId).slice(); + turns[turnIdx] = { + action: normalizeAction(entry.action), + args: normalizeArgs(entry.args), + }; + this.generalTurns.set(generalId, turns); + this.pendingGeneralInitializationIds.delete(generalId); + this.dirtyGeneralIds.add(generalId); + } + ensureNationTurns(nationId: number, officerLevel: number): void { const key = buildNationKey(nationId, officerLevel); this.getNationTurns(nationId, officerLevel); diff --git a/app/game-engine/src/turn/tournamentAutoStart.ts b/app/game-engine/src/turn/tournamentAutoStart.ts index d3253b81..a70f9cd5 100644 --- a/app/game-engine/src/turn/tournamentAutoStart.ts +++ b/app/game-engine/src/turn/tournamentAutoStart.ts @@ -44,7 +44,9 @@ const safeJsonParse = (raw: string | null): T | null => { const resolveTermSeconds = (tickSeconds: number): number => { const turnMinutes = Math.max(1, Math.round(tickSeconds / 60)); - return Math.min(120, Math.max(5, turnMinutes)) * 60; + // Ref calcTournamentTerm() receives the turn length in minutes but returns + // that clamped numeric value as tournament seconds. + return Math.min(120, Math.max(5, turnMinutes)); }; const readPattern = (world: InMemoryTurnWorld, config: Record): number[] => { @@ -57,17 +59,6 @@ const readPattern = (world: InMemoryTurnWorld, config: Record): ); }; -const shuffledDefaultPattern = ( - hiddenSeed: string | number, - previousYear: number, - previousMonth: number -): number[] => - new RandUtil( - new LiteHashDRBG( - simpleSerialize(hiddenSeed, 'monthly', previousYear, previousMonth, 'tournamentPattern') - ) - ).shuffle([0, 0, 1, 2, 3]); - export const createTournamentAutoStartHandler = (options: { profileName: string; getWorld: () => InMemoryTurnWorld | null; @@ -114,10 +105,9 @@ export const createTournamentAutoStartHandler = (options: { } const pattern = readPattern(world, config); - const resolvedPattern = - pattern.length > 0 - ? pattern - : shuffledDefaultPattern(hiddenSeed, context.previousYear, context.previousMonth); + // The deterministic Ref comparison branch replaces PHP's global + // shuffle() with this same already-advanced monthly RNG. + const resolvedPattern = pattern.length > 0 ? pattern : rng.shuffle([0, 0, 1, 2, 3]); const type = resolvedPattern.pop() ?? 0; world.updateWorldMeta({ tournamentPattern: resolvedPattern }); const now = options.now?.() ?? new Date(); @@ -133,8 +123,14 @@ export const createTournamentAutoStartHandler = (options: { openYear: context.currentYear, openMonth: context.currentMonth, termSeconds, - nextAt: new Date(now.getTime() + termSeconds * 1_000).toISOString(), - bettingId: undefined, + // Ref startTournament() passes calcTournamentTerm()'s seconds + // value to DateInterval's minute field. Preserve that historical + // initial enrollment delay; later tournament phases use seconds. + nextAt: new Date(now.getTime() + termSeconds * 60_000).toISOString(), + bettingId: + typeof previousState?.bettingId === 'number' && Number.isFinite(previousState.bettingId) + ? previousState.bettingId + 1 + : 1, bettingCloseAt: undefined, winnerId: undefined, bettingSettled: false, diff --git a/app/game-engine/src/turn/turnDaemon.ts b/app/game-engine/src/turn/turnDaemon.ts index a9beea62..c80b0d3e 100644 --- a/app/game-engine/src/turn/turnDaemon.ts +++ b/app/game-engine/src/turn/turnDaemon.ts @@ -20,6 +20,7 @@ import { createGatewayProfileGate } from './gatewayProfileGate.js'; import { composeCalendarHandlers } from './calendarHandlers.js'; import { createIncomeHandler } from './incomeHandler.js'; import { createNationTurnMonthlyHandler } from './nationTurnMonthlyHandler.js'; +import { calculateNpcNationFinance } from './npcTaxHandler.js'; import { createMonthlyBoundaryPreHandler } from './monthlyBoundaryPreHandler.js'; import { createMonthlyWanderHandler } from './monthlyWanderHandler.js'; import { @@ -471,6 +472,10 @@ const createTurnDaemonRuntimeWithLease = async ( onTournamentRollConsumed: (consumed) => { monthlyTournamentRollConsumed = consumed; }, + // Deterministic/manual runtimes must schedule the tournament from the + // same clock that advances the game world. Production still falls + // back to the system clock. + now: () => new Date(options.clock?.nowMs() ?? Date.now()), }); const yearbookHandler = createYearbookHandler({ profileName: options.profileName ?? options.profile, @@ -508,6 +513,13 @@ const createTurnDaemonRuntimeWithLease = async ( getWorld: () => worldRef, commandProfile, commandEnv: monthlyCommandEnv, + calculateNpcNationFinance: (financeWorld, nation, currentMonth) => + calculateNpcNationFinance(financeWorld, nation, currentMonth, { + commandEnv: monthlyCommandEnv, + scenarioConfig: snapshot.scenarioConfig, + unitSet: snapshot.unitSet, + nationTraits: nationTraitMap, + }), getAdditionalOccupiedUniqueItemKeys: () => occupiedAuctionUniqueItemKeys, onActionResolved: options.onActionResolved, })), diff --git a/app/game-engine/src/turn/worldLoader.ts b/app/game-engine/src/turn/worldLoader.ts index e0e9d4ef..e9613a79 100644 --- a/app/game-engine/src/turn/worldLoader.ts +++ b/app/game-engine/src/turn/worldLoader.ts @@ -389,18 +389,28 @@ export const loadTurnWorldFromDatabase = async (options: TurnWorldLoaderOptions) inheritanceByUser.set(row.userId, bucket); } const accessByGeneral = new Map(accessRows.map((row) => [row.generalId, row])); - const generals = generalRows.map((row) => - mapGeneralRow( - row, - ranksByGeneral.get(row.id) ?? [], - row.userId ? (inheritanceByUser.get(row.userId) ?? []) : [], - accessByGeneral.get(row.id) + // MariaDB legacy scans these tables in their primary-key order. Prisma + // findMany() does not promise an order, and Map insertion order can + // otherwise leak into monthly RNG and AI candidate traversal. + const generals = generalRows + .map((row) => + mapGeneralRow( + row, + ranksByGeneral.get(row.id) ?? [], + row.userId ? (inheritanceByUser.get(row.userId) ?? []) : [], + accessByGeneral.get(row.id) + ) ) - ); - const cities = cityRows.map(mapCityRow); - const nations = nationRows.map(mapNationRow); - const diplomacy = diplomacyRows.map(mapDiplomacyRow); - const troops = troopRows.map(mapTroopRow); + .sort((left, right) => left.id - right.id); + const cities = cityRows.map(mapCityRow).sort((left, right) => left.id - right.id); + const nations = nationRows.map(mapNationRow).sort((left, right) => left.id - right.id); + const diplomacy = diplomacyRows + .map(mapDiplomacyRow) + .sort( + (left, right) => + left.fromNationId - right.fromNationId || left.toNationId - right.toNationId + ); + const troops = troopRows.map(mapTroopRow).sort((left, right) => left.id - right.id); const worldConfig = asRecord(worldState.config); const scenarioConfig = mapScenarioConfig(worldState.config); diff --git a/app/game-engine/test/generalAiLegacyDecisionParity.test.ts b/app/game-engine/test/generalAiLegacyDecisionParity.test.ts index 7d4cddda..70785369 100644 --- a/app/game-engine/test/generalAiLegacyDecisionParity.test.ts +++ b/app/game-engine/test/generalAiLegacyDecisionParity.test.ts @@ -1,14 +1,18 @@ import { describe, expect, it } from 'vitest'; import type { City, General, Nation } from '@sammo-ts/logic'; +import { createRefOrderedActionStack } from '@sammo-ts/logic/actionModules/bundle.js'; import type { GeneralAI } from '../src/turn/ai/generalAi.js'; +import { resolveLegacyAiStats } from '../src/turn/ai/generalAi/core.js'; +import { withCanonicalArgumentAliases } from '../src/turn/ai/aiUtils.js'; import { do일반내정, do전쟁내정 } from '../src/turn/ai/generalAi/general/devActions.js'; import { do금쌀구매 } from '../src/turn/ai/generalAi/general/economyActions.js'; -import { do국가선택, do중립 } from '../src/turn/ai/generalAi/general/politicsActions.js'; +import { do거병, do건국, do국가선택, do중립 } from '../src/turn/ai/generalAi/general/politicsActions.js'; import { do징병 } from '../src/turn/ai/generalAi/general/recruitActions.js'; import { do전투준비, do출병 } from '../src/turn/ai/generalAi/general/warActions.js'; -import { do전방워프, do집합, do후방워프 } from '../src/turn/ai/generalAi/general/warpActions.js'; -import { doNPC몰수, do유저장포상 } from '../src/turn/ai/generalAi/nation/rewards.js'; +import { do내정워프, do전방워프, do집합, do후방워프 } from '../src/turn/ai/generalAi/general/warpActions.js'; +import { doNPC몰수, doNPC포상, do유저장포상 } from '../src/turn/ai/generalAi/nation/rewards.js'; +import { doNPC전방발령, doNPC후방발령 } from '../src/turn/ai/generalAi/nation/assignments/npcAssignments.js'; type Candidate = { action: string; @@ -74,7 +78,24 @@ const makeRng = (bools: boolean[] = [], choices: unknown[] = []): ScriptedRng => }; }; -const baseGeneral = (): General => ({ +const singleActionModuleStack = ( + module: NonNullable[number] +): NonNullable => { + const noOp = {}; + return createRefOrderedActionStack({ + nation: noOp, + officer: noOp, + domestic: noOp, + war: noOp, + personality: module, + crewType: null, + inheritance: noOp, + scenario: null, + items: [], + }); +}; + +const baseGeneral = (): General & { turnTime: Date } => ({ id: 1, name: '가상장수', nationId: 1, @@ -99,6 +120,7 @@ const baseGeneral = (): General => ({ atmos: 0, age: 30, npcState: 2, + turnTime: new Date('0190-01-01T00:00:00Z'), triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} }, meta: { killturn: 100, fullLeadership: 70 }, }); @@ -155,6 +177,7 @@ const makeAi = ( nations?: Nation[]; generals?: General[]; disabledPolicyActions?: string[]; + generalActionModules?: NonNullable; } = {} ): GeneralAI => { const general = { @@ -278,6 +301,7 @@ const makeAi = ( maxTechLevel: 10, techLevelIncYear: 5, initialAllowedTechLevel: 1, + generalActionModules: overrides.generalActionModules ?? [], }, aiConst: { baseGold: 1000, @@ -352,6 +376,109 @@ const makeAi = ( * selection and RNG-sensitive gates, not TypeScript implementation details. */ describe('legacy NPC AI final-decision parity', () => { + it('normalizes legacy uppercase destination IDs before AI constraint checks', () => { + expect( + withCanonicalArgumentAliases({ + destGeneralID: 2, + destCityID: 3, + destNationID: 4, + destTroopID: 5, + }) + ).toMatchObject({ + destGeneralId: 2, + destCityId: 3, + destNationId: 4, + destTroopId: 5, + }); + }); + + it('applies the legacy nation-level leadership bonus for officers', () => { + const ruler = { ...baseGeneral(), officerLevel: 12, injury: 0 }; + const nation = { ...baseNation(), level: 1 }; + + expect(resolveLegacyAiStats(ruler, nation, 255)).toMatchObject({ + fullLeadership: 72, + effectiveLeadership: 72, + }); + expect(resolveLegacyAiStats({ ...ruler, officerLevel: 5 }, nation, 255)).toMatchObject({ + fullLeadership: 71, + effectiveLeadership: 71, + }); + expect(resolveLegacyAiStats({ ...ruler, officerLevel: 1 }, nation, 255)).toMatchObject({ + fullLeadership: 70, + effectiveLeadership: 70, + }); + }); + it.each([ + ['Core scenario name', '강유'], + ['Ref stored name', 'ⓝ강유'], + ])('uses the full ruler name and Ref nation-type/color RNG order: %s', (_label, name) => { + const rng = makeRng([], ['che_음양가', 19]); + const ai = makeAi({ general: { name }, rng }); + ai.aiConst.availableNationTypes = ['che_도적', 'che_음양가']; + + expect(do건국(ai)).toMatchObject({ + action: 'che_건국', + args: { nationName: '㉿강유', nationType: 'che_음양가', colorType: 19 }, + }); + }); + + it('counts a wandering-nation ruler on a neutral city as occupying the uprising radius', () => { + const rng = makeRng([false, true]); + const currentCity = { ...baseCity(), id: 1, nationId: 0, level: 1 }; + const rulerCity = { ...baseCity(), id: 2, nationId: 0, level: 5 }; + const ruler = { ...baseGeneral(), id: 2, nationId: 9, cityId: 2, officerLevel: 12 }; + const ai = { + general: { ...baseGeneral(), nationId: 0, cityId: 1, npcState: 2, meta: {} }, + city: currentCity, + map: { + id: 'test', + name: 'test', + defaults: {}, + cities: [ + { + id: 1, + name: '현재', + level: 1, + region: 1, + position: { x: 0, y: 0 }, + connections: [2], + max: {}, + initial: {}, + }, + { + id: 2, + name: '군주', + level: 5, + region: 1, + position: { x: 1, y: 0 }, + connections: [1], + max: {}, + initial: {}, + }, + ], + }, + worldRef: { + listCities: () => [currentCity, rulerCity], + listGenerals: () => [ruler], + getCityById: (id: number) => (id === 1 ? currentCity : id === 2 ? rulerCity : null), + }, + generalPolicy: { can: (name: string) => name === '건국' }, + rng, + aiConst: { defaultStatNpcMax: 100, chiefStatMin: 70 }, + world: { currentYear: 179, meta: { initYear: 179 } }, + startYear: 179, + buildGeneralCandidate: (action: string, args: Record, reason: string) => ({ + action, + args, + reason, + }), + } as unknown as GeneralAI; + + expect(do거병(ai)).toBeNull(); + expect(rng.bools).toEqual([true]); + }); + it.each([ [0, 0], [0, 2], @@ -386,6 +513,30 @@ describe('legacy NPC AI final-decision parity', () => { } ); + it('applies legacy personality cost modifiers before halving recruit crew', () => { + const ai = makeAi({ + dipState: 2, + general: { + gold: 1_000, + rice: 10_000, + meta: { killturn: 100, fullLeadership: 70, rank_killcrew: 0, rank_deathcrew: 1 }, + }, + generalActionModules: singleActionModuleStack( + { + eventHandlers: {}, + onCalcDomestic: (_context, turnType, varType, value) => + turnType === '징병' && varType === 'cost' ? value * 1.2 : value, + } + ), + rng: makeRng([], [0, 0]), + }); + + expect(do징병(ai)).toMatchObject({ + action: 'che_징병', + args: { crewType: 1, amount: 3_500 }, + }); + }); + it.each([ [0, 0], [0, 2000], @@ -433,6 +584,68 @@ describe('legacy NPC AI final-decision parity', () => { expect(agriculture[1]).toBe(420); }); + it('coerces fractional nation tech to an integer before the legacy modulo', () => { + const rng = makeRng([false], [0]); + const ai = makeAi({ + dipState: 1, + genType: 6, + general: { + stats: { leadership: 73, strength: 44, intelligence: 98 }, + meta: { + killturn: 100, + effectiveLeadership: 73, + effectiveStrength: 69, + effectiveIntelligence: 109, + }, + }, + city: { + population: 75_098, + populationMax: 108_500, + }, + nation: { meta: { tech: 564.87353515625 } }, + year: 185, + rng, + }); + + do전쟁내정(ai); + const weights = rng.weightedPairs.at(-1)!; + const technology = weights.find(([candidate]) => (candidate as Candidate).action === 'che_기술연구')!; + expect(technology[1]).toBeCloseTo(109 / (565 / 3000), 12); + }); + + it('uses injury-adjusted legacy stats when weighting domestic choices', () => { + const rng = makeRng([], [2]); + const ai = makeAi({ + genType: 5, + general: { + stats: { leadership: 68, strength: 71, intelligence: 40 }, + meta: { + killturn: 100, + effectiveLeadership: 68, + effectiveStrength: 81, + effectiveIntelligence: 58, + }, + }, + city: { + population: 100_000, + populationMax: 293_700, + defence: 2000, + defenceMax: 5900, + wall: 2000, + wallMax: 6300, + security: 1000, + securityMax: 4000, + meta: { trust: 50, trade: 100 }, + }, + rng, + }); + + expect(do일반내정(ai)?.action).toBe('che_수비강화'); + const weights = rng.weightedPairs.at(-1)!; + const defence = weights.find(([candidate]) => (candidate as Candidate).action === 'che_수비강화')!; + expect(defence[1]).toBeCloseTo(238.95); + }); + it.each([ [1500, 400, null], [10_000, 1000, 'che_군량매매'], @@ -443,6 +656,48 @@ describe('legacy NPC AI final-decision parity', () => { expect(do금쌀구매(ai)?.action ?? null).toBe(expected); }); + it('uses the global command cap rather than the nation-specific reward cap for trade', () => { + const ai = makeAi({ general: { gold: 355, rice: 3177 } }); + ai.maxResourceActionAmount = 1200; + + // 명령 parser가 이 원시 수량을 Ref처럼 백 단위 1400으로 반올림한다. + expect(do금쌀구매(ai)?.args).toEqual({ buyRice: false, amount: 1411 }); + }); + + it('uses full leadership and the unit rice price for the trade reserve estimate', () => { + const ai = makeAi({ + general: { + gold: 4000, + rice: 2000, + stats: { leadership: 10, strength: 70, intelligence: 70 }, + meta: { killturn: 100, fullLeadership: 70 }, + }, + }); + const crewType = ai.unitSet?.crewTypes?.[0]; + if (!crewType) throw new Error('missing test crew type'); + crewType.cost = 9; + crewType.rice = 20; + + expect(do금쌀구매(ai)?.action).toBe('che_군량매매'); + }); + + it('applies domestic action cost modifiers to the trade recruit reserve estimate', () => { + const ai = makeAi({ + general: { gold: 900, rice: 100 }, + generalActionModules: singleActionModuleStack({ + eventHandlers: {}, + onCalcDomestic: (_context, turnType, varType, value) => + turnType === '징병' && varType === 'cost' ? value * 2 : value, + }), + }); + const crewType = ai.unitSet?.crewTypes?.[0]; + if (!crewType) throw new Error('missing test crew type'); + crewType.cost = 9; + crewType.rice = 9; + + expect(do금쌀구매(ai)).toBeNull(); + }); + it('randomly chooses between supply and search when national resources are sufficient', () => { const ai = makeAi({ rng: makeRng([], [1]) }); expect(do중립(ai)?.action).toBe('che_인재탐색'); @@ -481,6 +736,16 @@ describe('legacy NPC AI final-decision parity', () => { expect(do국가선택(ai)).toBeNull(); }); + it('does not count the Core-only neutral nation as a legacy nation', () => { + const ai = makeAi({ + general: { nationId: 0 }, + year: 181, + nations: [{ ...baseNation(), id: 0, name: '재야' }], + rng: makeRng([true]), + }); + expect(do국가선택(ai)).toBeNull(); + }); + it.each([ [false, 4, 100, 100, 2000, null], [true, 3, 100, 100, 2000, null], @@ -519,6 +784,32 @@ describe('legacy NPC AI final-decision parity', () => { expect(do후방워프(ai)).toBeNull(); }); + it('uses full leadership for the legacy rear-warp recruitment floor', () => { + const ai = makeAi({ + dipState: 4, + general: { + crew: 0, + stats: { leadership: 10, strength: 70, intelligence: 70 }, + meta: { killturn: 100, fullLeadership: 100 }, + }, + city: { population: 10_000 }, + }); + ai.categorizeNationCities = () => { + const candidate = { + ...baseCity(), + id: 2, + population: 35_000, + populationMax: 50_000, + dev: 0.7, + important: 0, + }; + ai.backupCities = { 2: candidate }; + ai.supplyCities = { 2: candidate }; + }; + + expect(do후방워프(ai)).toBeNull(); + }); + it('categorizes generals before weighting a front-line warp destination', () => { const ai = makeAi({ dipState: 4, @@ -537,6 +828,32 @@ describe('legacy NPC AI final-decision parity', () => { expect(categorizedGenerals).toBe(true); }); + it('keeps the legacy empty city-general counts when weighting a domestic warp', () => { + const ai = makeAi({ rng: makeRng([false, true]) }); + ai.categorizeNationCities = () => { + ai.supplyCities = { + 1: { ...baseCity(), dev: 1, important: 0, generals: {} }, + 2: { + ...baseCity(), + id: 2, + agriculture: 1000, + commerce: 1000, + security: 1000, + defence: 1000, + wall: 1000, + dev: 0.1, + important: 0, + generals: {}, + }, + }; + }; + ai.categorizeNationGeneral = () => { + throw new Error('Ref does not categorize generals in do내정워프'); + }; + + expect(do내정워프(ai)?.action).toBe('che_NPC능동'); + }); + it('awards a resource-poor civil user general like the legacy nation AI', () => { const ai = makeAi(); const civilGeneral = { @@ -553,6 +870,116 @@ describe('legacy NPC AI final-decision parity', () => { expect(do유저장포상(ai)?.action).toBe('che_포상'); }); + it('consumes the legacy reward draw before a selected command fails constraints', () => { + const rng = makeRng(); + const ai = makeAi({ rng, blockedActions: ['che_포상'] }); + const civilGeneral = { + ...baseGeneral(), + id: 2, + npcState: 0, + gold: 0, + rice: 20_000, + meta: { killturn: 100, fullLeadership: 70 }, + turnTime: new Date('0190-01-01T00:00:00Z'), + }; + ai.userGenerals = { 2: civilGeneral }; + ai.userWarGenerals = {}; + + expect(do유저장포상(ai)).toBeNull(); + expect(rng.weightedPairs).toHaveLength(1); + }); + + it('keeps Ref multiplication order at an exact NPC reward resource boundary', () => { + const rng = makeRng(); + const ai = makeAi({ + year: 185, + startYear: 180, + nation: { rice: 12_088, meta: { tech: 1_011.416320800781 } }, + rng, + }); + ai.nationPolicy.reqNationRice = 10_000; + ai.nationPolicy.reqNpcWarRice = 4_000; + ai.maxResourceActionAmount = 2_400; + const candidate = (id: number, leadership: number, rice: number) => ({ + ...baseGeneral(), + id, + stats: { ...baseGeneral().stats, leadership }, + rice, + crewTypeId: 1, + meta: { killturn: 100, fullLeadership: leadership }, + }); + ai.npcWarGenerals = { + 180: candidate(180, 88, 3_005), + 743: candidate(743, 80, 3_036), + }; + ai.npcCivilGenerals = {}; + + expect(doNPC포상(ai)).toMatchObject({ + action: 'che_포상', + args: { destGeneralId: 180, isGold: false }, + }); + expect(rng.weightedPairs[0]?.[0]?.[0]).toMatchObject({ destGeneralId: 180 }); + expect(rng.weightedPairs[0]).toHaveLength(1); + }); + + it('excludes no-population recruitment specialists before NPC rear assignment draws RNG', () => { + const rng = makeRng([], [0, 0]); + const specialist = { + ...baseGeneral(), + id: 2, + cityId: 2, + crew: 0, + role: { ...baseGeneral().role, specialWar: 'che_징병' }, + turnTime: new Date('0190-01-01T00:00:00Z'), + }; + const ai = makeAi({ + dipState: 4, + rng, + generals: [baseGeneral(), specialist], + generalActionModules: singleActionModuleStack( + { + eventHandlers: {}, + onCalcDomestic: (context, turnType, varType, value) => + context.general.id === 2 && turnType === '징집인구' && varType === 'score' ? 0 : value, + } + ), + }); + ai.frontCities = { 1: { ...baseCity(), frontState: 3, dev: 1, important: 1 } }; + ai.supplyCities = { + 2: { + ...baseCity(), + id: 2, + population: 40_000, + populationMax: 100_000, + dev: 1, + important: 1, + }, + 3: { ...baseCity(), id: 3, population: 100_000, dev: 1, important: 1 }, + }; + ai.backupCities = { 3: ai.supplyCities[3]! }; + ai.npcWarGenerals = { 2: specialist }; + + expect(doNPC후방발령(ai)).toBeNull(); + expect(rng.choices).toEqual([0, 0]); + }); + + it('draws the NPC front-assignment general before the weighted destination city', () => { + const rng = makeRng([], [1, 20]); + const first = { ...baseGeneral(), id: 2, crew: 3000, train: 100, atmos: 100 }; + const second = { ...baseGeneral(), id: 3, crew: 3000, train: 100, atmos: 100 }; + const ai = makeAi({ dipState: 4, rng }); + ai.npcWarGenerals = { 2: first, 3: second }; + ai.nationCities = { 1: { ...baseCity(), dev: 1, important: 1 } }; + ai.frontCities = { + 20: { ...baseCity(), id: 20, frontState: 2, dev: 1, important: 1 }, + }; + + expect(doNPC전방발령(ai)).toMatchObject({ + action: 'che_발령', + args: { destGeneralId: 3, destCityId: 20 }, + }); + }); + it('seizes a small war-NPC surplus while the treasury is below 1.5x reserve', () => { const ai = makeAi({ nation: { gold: 12_000, rice: 100_000 } }); const warGeneral = { diff --git a/app/game-engine/test/generalTurnLegacyCompatibility.test.ts b/app/game-engine/test/generalTurnLegacyCompatibility.test.ts index aabbca32..3ad8b706 100644 --- a/app/game-engine/test/generalTurnLegacyCompatibility.test.ts +++ b/app/game-engine/test/generalTurnLegacyCompatibility.test.ts @@ -152,6 +152,49 @@ const makeState = (): TurnWorldState => ({ }); describe('legacy general-turn execution contract', () => { + it('quantizes integer general columns at each in-memory DB mutation boundary', async () => { + const harness = await createTurnTestHarness({ + snapshot: makeSnapshot(makeGeneral()), + state: makeState(), + schedule, + map, + }); + + harness.world.updateGeneral(1, { + experience: 10.5, + dedication: 20.49, + gold: 1_000.5, + rice: 1_000.49, + meta: { killturn: 24, dex4: 100.5, intel_exp: 29.5 }, + }); + + expect(harness.world.getGeneralById(1)).toMatchObject({ + experience: 11, + dedication: 20, + gold: 1_001, + rice: 1_000, + meta: { killturn: 24, dex4: 101, intel_exp: 30 }, + }); + }); + + it('applies inherited domestic stat progression after farming', async () => { + const general = makeGeneral({ meta: { killturn: 24, intel_exp: 29 } }); + const harness = await createTurnTestHarness({ + snapshot: makeSnapshot(general), + state: makeState(), + schedule, + map, + }); + harness.reservedTurnStore.getGeneralTurns(1)[0] = { action: 'che_농지개간', args: {} }; + + await harness.runOneTick(); + + expect(harness.world.getGeneralById(1)).toMatchObject({ + stats: { leadership: 80, strength: 70, intelligence: 61 }, + meta: { intel_exp: 0 }, + }); + }); + it('runs injury recovery and troop rice consumption before the reserved command', async () => { const general = makeGeneral({ injury: 25, crew: 200, rice: 1 }); const harness = await createTurnTestHarness({ diff --git a/app/game-engine/test/monthlyCoreEventAction.test.ts b/app/game-engine/test/monthlyCoreEventAction.test.ts index 62e21c35..72df2122 100644 --- a/app/game-engine/test/monthlyCoreEventAction.test.ts +++ b/app/game-engine/test/monthlyCoreEventAction.test.ts @@ -3,6 +3,7 @@ import { LogCategory, LogFormat, LogScope, type City, type MapDefinition, type N import { createIncomeHandler } from '../src/turn/incomeHandler.js'; import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js'; +import { calculateNpcNationFinance } from '../src/turn/npcTaxHandler.js'; import { createNewYearHandler, createNoticeToHistoryLogHandler, @@ -10,6 +11,7 @@ import { createResetOfficerLockHandler, } from '../src/turn/monthlyCoreEventAction.js'; import { createMonthlyEventHandler, type MonthlyEventActionHandler } from '../src/turn/monthlyEventHandler.js'; +import { buildCommandEnv } from '../src/turn/reservedTurnCommands.js'; import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js'; const map: MapDefinition = { @@ -318,4 +320,148 @@ describe('core monthly event actions at the real month boundary', () => { }, }); }); + + it('keeps fractional income for the legacy salary payout ratio', async () => { + const nation = buildNation(); + nation.gold = 0; + nation.meta = { ...nation.meta, rate: 15, rate_tmp: 15, bill: 100 }; + const world = buildWorld( + [ + { + id: 4, + targetCode: 'month', + priority: 1, + condition: true, + action: [['ProcessIncome', 'gold']], + meta: {}, + }, + ], + (getWorld) => { + const incomeHandler = createIncomeHandler({ + getWorld, + scenarioConfig: { + stat: { + total: 300, + min: 10, + max: 100, + npcTotal: 150, + npcMax: 50, + npcMin: 10, + chiefMin: 70, + }, + iconPath: '', + map: {}, + const: { baseGold: 0, baseRice: 0 }, + environment: { mapName: map.id, unitSet: 'default' }, + }, + nationTraits: new Map(), + }); + return new Map([['ProcessIncome', createProcessIncomeActionHandler(incomeHandler)]]); + }, + { + currentMonth: 12, + generals: [buildGeneral(1, 1, 3)], + nations: [nation], + cities: [buildCity()], + } + ); + + await world.advanceMonth(new Date('0191-01-01T00:00:00.000Z')); + + expect(world.getNationById(1)?.meta.prev_income_gold).toBe(157.5); + }); + + it('uses the Ref default nation resource floors when scenario const omits them', async () => { + const nation = buildNation(); + nation.rice = 0; + const general = buildGeneral(1, 1, 3); + const world = buildWorld( + [ + { + id: 5, + targetCode: 'month', + priority: 1, + condition: true, + action: [['ProcessIncome', 'rice']], + meta: {}, + }, + ], + (getWorld) => { + const incomeHandler = createIncomeHandler({ + getWorld, + scenarioConfig: { + stat: { + total: 300, + min: 10, + max: 100, + npcTotal: 150, + npcMax: 50, + npcMin: 10, + chiefMin: 70, + }, + iconPath: '', + map: {}, + const: {}, + environment: { mapName: map.id, unitSet: 'default' }, + }, + nationTraits: new Map(), + }); + return new Map([['ProcessIncome', createProcessIncomeActionHandler(incomeHandler)]]); + }, + { + currentMonth: 6, + generals: [general], + nations: [nation], + cities: [buildCity()], + } + ); + + await world.advanceMonth(new Date('0190-07-01T00:00:00.000Z')); + + expect(world.getNationById(1)?.rice).toBe(2_000); + expect(world.getGeneralById(1)?.rice).toBe(general.rice); + }); + + it('excludes the NPC ruler stipend when choosing the nation bill like Ref', () => { + const config: TurnWorldSnapshot['scenarioConfig'] = { + stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 50, npcMin: 10, chiefMin: 70 }, + iconPath: '', + map: {}, + const: { baseGold: 0, baseRice: 0 }, + environment: { mapName: map.id, unitSet: 'default' }, + }; + const buildFinanceWorld = (chiefDedication: number) => { + const chief = buildGeneral(1, 1, 3); + chief.npcState = 2; + chief.officerLevel = 12; + chief.dedication = chiefDedication; + const subordinate = buildGeneral(2, 1, 3); + subordinate.npcState = 2; + return buildWorld([], () => new Map(), { + currentMonth: 12, + generals: [chief, subordinate], + nations: [buildNation()], + cities: [buildCity()], + }); + }; + const options = { scenarioConfig: config, commandEnv: buildCommandEnv(config) }; + const lowChiefWorld = buildFinanceWorld(1); + const highChiefWorld = buildFinanceWorld(100_000); + + const lowChiefBill = calculateNpcNationFinance( + lowChiefWorld, + lowChiefWorld.getNationById(1)!, + 12, + options + )?.bill; + const highChiefBill = calculateNpcNationFinance( + highChiefWorld, + highChiefWorld.getNationById(1)!, + 12, + options + )?.bill; + + expect(lowChiefBill).toBeTypeOf('number'); + expect(highChiefBill).toBe(lowChiefBill); + }); }); diff --git a/app/game-engine/test/monthlyEventHandler.test.ts b/app/game-engine/test/monthlyEventHandler.test.ts index 4d112b9e..8af73e09 100644 --- a/app/game-engine/test/monthlyEventHandler.test.ts +++ b/app/game-engine/test/monthlyEventHandler.test.ts @@ -140,7 +140,9 @@ describe('monthly event pipeline', () => { [ 'Trace', (args, environment) => { - trace.push(`${String(args[0])}:${environment.year}-${environment.month}`); + trace.push( + `${String(args[0])}:${environment.year}-${environment.month}:${environment.turnTime.toISOString()}` + ); }, ], ]); @@ -176,7 +178,11 @@ describe('monthly event pipeline', () => { await world.advanceMonth(new Date('0190-01-01T00:00:00.000Z')); - expect(trace).toEqual(['pre:189-12', 'month-high:190-1', 'month-low:190-1']); + expect(trace).toEqual([ + 'pre:189-12:0189-12-31T23:50:00.000Z', + 'month-high:190-1:0189-12-31T23:50:00.000Z', + 'month-low:190-1:0189-12-31T23:50:00.000Z', + ]); }); it('supports logic conditions and persists DeleteEvent through dirty state', async () => { @@ -236,7 +242,7 @@ describe('monthly event pipeline', () => { }, ], actions, - [buildCity(1, 1), buildCity(2, 4), buildCity(3, 5), buildCity(4, 6), buildCity(5, 7), buildCity(6, 8)] + [buildCity(6, 8), buildCity(1, 1), buildCity(2, 4), buildCity(3, 5), buildCity(4, 6), buildCity(5, 7)] ); actions.set( 'RandomizeCityTradeRate', @@ -254,12 +260,12 @@ describe('monthly event pipeline', () => { marker: city.meta.marker, })) ).toEqual([ + { id: 6, trade: 102, marker: 6 }, { id: 1, trade: null, marker: 1 }, { id: 2, trade: null, marker: 2 }, { id: 3, trade: 101, marker: 3 }, { id: 4, trade: 100, marker: 4 }, { id: 5, trade: 105, marker: 5 }, - { id: 6, trade: 102, marker: 6 }, ]); expect(world.peekDirtyState().cities.map((city) => city.id)).toEqual([1, 2, 3, 4, 5, 6]); }); @@ -386,7 +392,7 @@ describe('monthly event pipeline', () => { wall: 81, meta: { trade: 100, marker: 1 }, }); - expect(damagedCity?.meta.trust).toBeCloseTo(79.6); + expect(damagedCity?.meta.trust).toBe(Math.fround(79.6)); expect(world.getGeneralById(1)).toMatchObject({ injury: 0, crew: 99, atmos: 50, train: 51 }); expect(world.getGeneralById(2)).toMatchObject({ injury: 7, crew: 97, atmos: 49, train: 50 }); expect(world.getGeneralById(3)).toMatchObject({ injury: 80, crew: 97, atmos: 49, train: 50 }); diff --git a/app/game-engine/test/monthlyNationLevelAction.test.ts b/app/game-engine/test/monthlyNationLevelAction.test.ts index 92c213b0..0cfa5756 100644 --- a/app/game-engine/test/monthlyNationLevelAction.test.ts +++ b/app/game-engine/test/monthlyNationLevelAction.test.ts @@ -50,7 +50,7 @@ const buildCity = (id: number, nationId = 1, level = 4): City => ({ meta: {}, }); -const buildNation = (level: number): Nation => ({ +const buildNation = (level: number, patch: Partial = {}): Nation => ({ id: 1, name: '위', color: '#000000', @@ -62,6 +62,7 @@ const buildNation = (level: number): Nation => ({ level, typeCode: 'che_중립', meta: { marker: 1 }, + ...patch, }); const buildGeneral = (id: number, patch: Partial = {}): TurnGeneral => ({ @@ -113,6 +114,7 @@ const buildHarness = async ( itemModules?: ItemModule[]; configConst?: Record; additionalOccupiedCounts?: Map; + neutralCityCount?: number; } = {} ) => { const state: TurnWorldState = { @@ -143,8 +145,27 @@ const buildHarness = async ( defaults: { trust: 50, trade: 100, supplyState: 1, frontState: 0 }, }, generals: [buildGeneral(1), buildGeneral(2, { meta: { killturn: 850, belong: 30 } })], - cities: Array.from({ length: cityCount }, (_, index) => buildCity(index + 1)), - nations: [buildNation(nationLevel)], + cities: [ + ...Array.from({ length: cityCount }, (_, index) => buildCity(index + 1)), + ...Array.from({ length: options.neutralCityCount ?? 0 }, (_, index) => + buildCity(cityCount + index + 1, 0) + ), + ], + nations: [ + ...(options.neutralCityCount + ? [ + buildNation(0, { + id: 0, + name: '재야', + capitalCityId: null, + chiefGeneralId: null, + gold: 0, + rice: 0, + }), + ] + : []), + buildNation(nationLevel), + ], troops: [], diplomacy: [], events: [event], @@ -236,6 +257,15 @@ describe('UpdateNationLevel monthly action', () => { expect(world.peekDirtyState().logs).toEqual([]); }); + it('never promotes the Core-only neutral nation sentinel from neutral cities', async () => { + const { world, handler } = await buildHarness(0, 0, { neutralCityCount: 21 }); + + await handler([], { year: 193, month: 2, startyear: 190, currentEventID: 1, turnTime: new Date() }, event); + + expect(world.getNationById(0)).toMatchObject({ level: 0, gold: 0, rice: 0 }); + expect(world.peekDirtyState().logs).toEqual([]); + }); + it('sets both rename permissions only on promotion to emperor', async () => { const { world, handler } = await buildHarness(6, 21); diff --git a/app/game-engine/test/monthlyNationStatsHandler.test.ts b/app/game-engine/test/monthlyNationStatsHandler.test.ts index c09ff68f..404996c6 100644 --- a/app/game-engine/test/monthlyNationStatsHandler.test.ts +++ b/app/game-engine/test/monthlyNationStatsHandler.test.ts @@ -8,6 +8,7 @@ import { createMonthlyNationCountHandler, createMonthlyNationStatsHandler, createMonthlyWarSettingHandler, + roundLegacyNationPowerValue, } from '../src/turn/monthlyNationStatsHandler.js'; import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js'; @@ -107,6 +108,10 @@ const buildNation = ( }); describe('monthly nation statistics boundary', () => { + it('stabilizes MariaDB decimal half boundaries before rounding', () => { + expect(roundLegacyNationPowerValue(342.49999999999994)).toBe(343); + }); + it('matches the fixed legacy power, maxima, war-setting count, and final general cache', async () => { const state: TurnWorldState = { id: 1, diff --git a/app/game-engine/test/monthlySemiAnnualAction.test.ts b/app/game-engine/test/monthlySemiAnnualAction.test.ts index 9d11a9d4..11315507 100644 --- a/app/game-engine/test/monthlySemiAnnualAction.test.ts +++ b/app/game-engine/test/monthlySemiAnnualAction.test.ts @@ -2,7 +2,10 @@ import { describe, expect, it } from 'vitest'; import type { City, Nation, NationTraitModule } from '@sammo-ts/logic'; import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js'; -import { createProcessSemiAnnualHandler } from '../src/turn/monthlySemiAnnualAction.js'; +import { + createProcessSemiAnnualHandler, + storeLegacySemiAnnualTrust, +} from '../src/turn/monthlySemiAnnualAction.js'; import type { TurnEvent, TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js'; const buildCity = (id: number, patch: Partial = {}): City => ({ @@ -151,6 +154,11 @@ const environment = { }; describe('ProcessSemiAnnual monthly action', () => { + it('stores the adjusted trust at the MariaDB FLOAT boundary', () => { + expect(storeLegacySemiAnnualTrust(88.30675 + 10)).toBe(Math.fround(98.30675)); + expect(storeLegacySemiAnnualTrust(88.30675 + 10)).not.toBe(98.30675); + }); + it('preserves the global popIncrease order, neutral double decay, supplied filtering, and nation trait', async () => { const { world, handler } = buildHarness({ configConst: { basePopIncreaseAmount: 15_000 }, @@ -241,6 +249,17 @@ describe('ProcessSemiAnnual monthly action', () => { }); }); + it('rounds decimal half boundaries like MariaDB ROUND', async () => { + const { world, handler } = buildHarness({ + cities: [buildCity(1, { defence: 2_000, wall: 2_000 })], + nations: [buildNation(1, { meta: { rate: 15 } })], + }); + + await handler(['gold'], environment, event); + + expect(world.getCityById(1)).toMatchObject({ defence: 2_030, wall: 2_030 }); + }); + it('applies strict legacy resource thresholds to generals and nations for either resource', async () => { const amounts = [1_000, 1_001, 10_000, 10_001, 100_000, 100_001]; const { world, handler } = buildHarness({ diff --git a/app/game-engine/test/scenarioSeeder.test.ts b/app/game-engine/test/scenarioSeeder.test.ts index 7873eabc..348c3046 100644 --- a/app/game-engine/test/scenarioSeeder.test.ts +++ b/app/game-engine/test/scenarioSeeder.test.ts @@ -120,7 +120,7 @@ describeDb('scenario database seed', () => { expect(worldState?.config).toMatchObject({ tournamentTrig: true }); expect(generalCount).toBeGreaterThan(0); const seededGeneral = await prisma.general.findFirst(); - expect(seededGeneral?.startAge).toBe(seededGeneral?.age); + expect(seededGeneral?.startAge).toBe(20); expect(seededGeneral?.meta).toEqual( expect.objectContaining({ specage: expect.any(Number), @@ -239,6 +239,7 @@ describeDb('scenario database seed', () => { expect(config.tournamentTrig).toBe(false); const meta = (worldState.meta ?? {}) as Record; + expect(meta.develcost).toBe((worldState.currentYear - (scenario.startYear ?? worldState.currentYear) + 10) * 2); const autorun = (meta.autorun_user ?? {}) as Record; const autorunOptions = (autorun.options ?? {}) as Record; expect(autorunOptions.develop).toBe(true); diff --git a/app/game-engine/test/tournamentAutoStart.test.ts b/app/game-engine/test/tournamentAutoStart.test.ts index 513b1503..fb55d21c 100644 --- a/app/game-engine/test/tournamentAutoStart.test.ts +++ b/app/game-engine/test/tournamentAutoStart.test.ts @@ -83,7 +83,7 @@ describe('monthly tournament auto start', () => { auto: true, openYear: 193, openMonth: 2, - termSeconds: 600, + termSeconds: 10, nextAt: '2026-07-25T00:10:00.000Z', }); expect(world.getState().meta.tournamentPattern).toEqual([0, 1, 2]); @@ -218,8 +218,8 @@ describe('monthly tournament auto start', () => { const second = await run(); expect(second).toEqual(first); expect(first).toEqual({ - tournamentState: expect.objectContaining({ type: 1 }), - remainingPattern: [2, 0, 0, 3], + tournamentState: expect.objectContaining({ type: 0, bettingId: 1 }), + remainingPattern: [1, 3, 2, 0], }); } finally { random.mockRestore(); diff --git a/app/game-engine/test/turnDaemonLifecycle.test.ts b/app/game-engine/test/turnDaemonLifecycle.test.ts index e4642566..c095c7a3 100644 --- a/app/game-engine/test/turnDaemonLifecycle.test.ts +++ b/app/game-engine/test/turnDaemonLifecycle.test.ts @@ -280,12 +280,11 @@ describe('TurnDaemonLifecycle', () => { await loop; }); - it('runs scheduled turn based on queue front and checkpoint context', async () => { + it('catches up through the observed clock with queue and checkpoint context', async () => { const turnTermMinutes = 10; const lastTurnTime = new Date(2026, 0, 2, 2, 0, 0, 0); const generalTurnQueue = [addMinutes(lastTurnTime, 5), addMinutes(lastTurnTime, 20)]; - const nextTickTime = getNextTickTime(lastTurnTime, turnTermMinutes); - const expectedRunTimeMs = Math.min(nextTickTime.getTime(), generalTurnQueue[0]!.getTime()); + const expectedRunTimeMs = addMinutes(lastTurnTime, 30).getTime(); const checkpoint = { turnTime: lastTurnTime.toISOString(), generalId: 101, @@ -358,12 +357,11 @@ describe('TurnDaemonLifecycle', () => { await loop; }); - it('runs scheduled turn when tick boundary arrives before queue front', async () => { + it('catches up through the observed clock when tick boundary precedes the queue front', async () => { const turnTermMinutes = 10; const lastTurnTime = new Date(2026, 0, 2, 2, 0, 0, 0); const generalTurnQueue = [addMinutes(lastTurnTime, 15), addMinutes(lastTurnTime, 30)]; - const nextTickTime = getNextTickTime(lastTurnTime, turnTermMinutes); - const expectedRunTimeMs = Math.min(nextTickTime.getTime(), generalTurnQueue[0]!.getTime()); + const expectedRunTimeMs = addMinutes(lastTurnTime, 30).getTime(); const checkpoint = { turnTime: lastTurnTime.toISOString(), generalId: 102, diff --git a/app/game-engine/test/turnOrder.test.ts b/app/game-engine/test/turnOrder.test.ts index aabfa611..8dbd8b39 100644 --- a/app/game-engine/test/turnOrder.test.ts +++ b/app/game-engine/test/turnOrder.test.ts @@ -150,13 +150,23 @@ describe('InMemoryTurnProcessor ordering', () => { }, }); - await processor.run(addMinutes(baseTime, 30), { + const budget = { budgetMs: 1000, maxGenerals: 10, catchUpCap: 1, - }); + }; - expect(executed).toEqual([2, 3, 1]); + const boundaryResult = await processor.run(addMinutes(baseTime, 10), budget); + expect(boundaryResult.processedTurns).toBe(1); + expect(executed).toEqual([]); + + const tiedGeneralResult = await processor.run(new Date(addMinutes(baseTime, 10).getTime() + 1), budget); + expect(tiedGeneralResult.processedTurns).toBe(0); + expect(executed).toEqual([2, 3]); + + await processor.run(addMinutes(baseTime, 30), budget); + + expect(executed).toEqual([2, 3, 1, 2, 3]); expect(world.getNextGeneralId()).toBe(4); expect(world.getNextGeneralId()).toBe(5); expect(world.getState().meta).toMatchObject({ lastGeneralId: 5 }); diff --git a/packages/logic/src/actions/engine.ts b/packages/logic/src/actions/engine.ts index 67070aef..ae8997c8 100644 --- a/packages/logic/src/actions/engine.ts +++ b/packages/logic/src/actions/engine.ts @@ -107,6 +107,11 @@ export interface GeneralActionOutcome[]; completed?: boolean; deletedTroopIds?: number[]; + reservedGeneralTurnPlans?: Array<{ + generalId: number; + joinTurn: number; + destNationId: number; + }>; alternative?: { commandKey: string; args: unknown; @@ -148,6 +153,11 @@ export interface GeneralActionResolution { args: unknown; }; deletedTroopIds?: number[]; + reservedGeneralTurnPlans?: Array<{ + generalId: number; + joinTurn: number; + destNationId: number; + }>; } export const createGeneralPatchEffect = ( @@ -391,6 +401,9 @@ export const resolveGeneralAction = , keys: string[], fallback: number): number => { @@ -205,6 +207,8 @@ 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), + maxGeneralStat: resolveNumber(constValues, ['maxLevel'], 255), + statUpgradeLimit: resolveNumber(constValues, ['upgradeLimit'], 30), castleCrewTypeId, armTypes: { footman: 1, @@ -233,9 +237,11 @@ export const buildWarAftermathConfig = ( ), maxTechLevel: resolveNumber(constValues, ['maxTechLevel'], 0), defaultCityWall: resolveNumber(constValues, ['defaultCityWall'], DEFAULT_AFTER_CONFIG.defaultCityWall), - baseGold: resolveNumber(constValues, ['baseGold', 'basegold'], 0), - baseRice: resolveNumber(constValues, ['baseRice', 'baserice'], 0), + baseGold: resolveNumber(constValues, ['baseGold', 'basegold'], DEFAULT_AFTER_CONFIG.baseGold), + baseRice: resolveNumber(constValues, ['baseRice', 'baserice'], DEFAULT_AFTER_CONFIG.baseRice), castleCrewTypeId, + joinMode: 'full', + joinRuinedNpcProbability: resolveNumber(constValues, ['joinRuinedNPCProp'], 0.1), }; }; diff --git a/packages/logic/src/actions/turn/general/che_거병.ts b/packages/logic/src/actions/turn/general/che_거병.ts index e7ac1bf4..89543cf1 100644 --- a/packages/logic/src/actions/turn/general/che_거병.ts +++ b/packages/logic/src/actions/turn/general/che_거병.ts @@ -18,6 +18,7 @@ import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js' import type { ActionContextBuilder, ActionContextBase } 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/actionModules/general.js'; import { getLegacyStringWidth } from '@sammo-ts/logic/troop/management.js'; export interface UprisingArgs {} @@ -30,8 +31,12 @@ export interface UprisingContext extends ActionContextBase { } const ACTION_NAME = '거병'; -const formatHourMinute = (date: Date): string => - `${String(date.getUTCHours()).padStart(2, '0')}:${String(date.getUTCMinutes()).padStart(2, '0')}`; +const formatHourMinute = (date: unknown): string => { + if (!(date instanceof Date) || Number.isNaN(date.getTime())) { + return '00:00'; + } + return `${String(date.getUTCHours()).padStart(2, '0')}:${String(date.getUTCMinutes()).padStart(2, '0')}`; +}; const truncateLegacyWidth = (value: string, maxWidth: number): string => { let result = ''; @@ -52,6 +57,11 @@ export class ActionDefinition< > implements GeneralActionDefinition { public readonly key = 'che_거병'; public readonly name = ACTION_NAME; + private readonly pipeline: GeneralActionPipeline; + + constructor(env: TurnCommandEnv) { + this.pipeline = new GeneralActionPipeline(env.generalActionModules ?? []); + } getInheritanceActiveActionAmount(): number { return 1; } @@ -89,15 +99,6 @@ export class ActionDefinition< nationName = '㉥' + nationName; } - const npcNationPolicy = - general.npcState >= 2 - ? { - values: { - minNPCRecruitCityPopulation: 0, - }, - } - : undefined; - const newNation: Nation = { id: newNationId, name: nationName, @@ -116,7 +117,6 @@ export class ActionDefinition< surlimit: 72, secretlimit: uprisingCtx.scenarioId >= 1000 ? 1 : 3, gennum: 1, - ...(npcNationPolicy ? { npc_nation_policy: npcNationPolicy } : {}), }, }; @@ -158,8 +158,8 @@ export class ActionDefinition< createGeneralPatchEffect({ nationId: newNationId, officerLevel: 12, - experience: (general.experience || 0) + 100, - dedication: (general.dedication || 0) + 100, + experience: (general.experience || 0) + this.pipeline.onCalcStat(context, 'experience', 100), + dedication: (general.dedication || 0) + this.pipeline.onCalcStat(context, 'dedication', 100), meta: { ...general.meta, belong: 1, @@ -200,5 +200,5 @@ export const commandSpec: GeneralTurnCommandSpec = { category: '전략', reqArg: false, - createDefinition: (_env: TurnCommandEnv) => new ActionDefinition(), + createDefinition: (env: TurnCommandEnv) => new ActionDefinition(env), }; diff --git a/packages/logic/src/actions/turn/general/che_건국.ts b/packages/logic/src/actions/turn/general/che_건국.ts index e7a20dc3..fdf4c410 100644 --- a/packages/logic/src/actions/turn/general/che_건국.ts +++ b/packages/logic/src/actions/turn/general/che_건국.ts @@ -23,19 +23,16 @@ import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js' import type { ActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js'; import { resolveInitYearMonth } from '@sammo-ts/logic/actions/turn/actionContextHelpers.js'; import { tryApplyUniqueLottery } from '@sammo-ts/logic/rewards/uniqueLottery.js'; +import { GeneralActionPipeline } from '@sammo-ts/logic/actionModules/general.js'; import type { GeneralTurnCommandSpec } from './index.js'; import { parseArgsWithSchema } from '../parseArgs.js'; import { JosaUtil } from '@sammo-ts/common'; -import { - FOUNDING_ARGS_SCHEMA, - getNationTypeDisplayName, - NATION_COLORS, - type FoundingArgs, -} from './foundingShared.js'; +import { FOUNDING_ARGS_SCHEMA, getNationTypeDisplayName, NATION_COLORS, type FoundingArgs } from './foundingShared.js'; const ACTION_NAME = '건국'; -interface FoundingResolveContext - extends GeneralActionResolveContext { +interface FoundingResolveContext< + TriggerState extends GeneralTriggerState = GeneralTriggerState, +> extends GeneralActionResolveContext { currentYearMonth?: number; initYearMonth?: number; } @@ -45,6 +42,12 @@ export class ActionDefinition< > implements GeneralActionDefinition { public readonly key = 'che_건국'; public readonly name = ACTION_NAME; + private readonly pipeline: GeneralActionPipeline; + + constructor(env: TurnCommandEnv) { + this.pipeline = new GeneralActionPipeline(env.generalActionModules ?? []); + } + getInheritanceActiveActionAmount(): number { return 1; } @@ -74,10 +77,7 @@ export class ActionDefinition< ]; } - resolve( - context: FoundingResolveContext, - args: FoundingArgs - ): GeneralActionOutcome { + resolve(context: FoundingResolveContext, args: FoundingArgs): GeneralActionOutcome { const general = context.general; const nation = context.nation!; const cityId = general.cityId!; @@ -119,14 +119,11 @@ export class ActionDefinition< scope: LogScope.GENERAL, format: LogFormat.YEAR_MONTH, }); - context.addLog( - `${general.name}${josaGeneralYi} ${args.nationName}${josaNationUl} 건국`, - { - category: LogCategory.HISTORY, - scope: LogScope.NATION, - format: LogFormat.YEAR_MONTH, - } - ); + context.addLog(`${general.name}${josaGeneralYi} ${args.nationName}${josaNationUl} 건국`, { + category: LogCategory.HISTORY, + scope: LogScope.NATION, + format: LogFormat.YEAR_MONTH, + }); tryApplyUniqueLottery(context, { acquireType: '건국', @@ -157,8 +154,8 @@ export class ActionDefinition< cityId ), createGeneralPatchEffect({ - experience: (general.experience || 0) + 1000, - dedication: (general.dedication || 0) + 1000, + experience: (general.experience || 0) + this.pipeline.onCalcStat(context, 'experience', 1000), + dedication: (general.dedication || 0) + this.pipeline.onCalcStat(context, 'dedication', 1000), }), ]; @@ -186,5 +183,5 @@ export const commandSpec: GeneralTurnCommandSpec = { colorType: 'number', }, argsSchema: FOUNDING_ARGS_SCHEMA, - createDefinition: (_env: TurnCommandEnv) => new ActionDefinition(), + createDefinition: (env: TurnCommandEnv) => new ActionDefinition(env), }; diff --git a/packages/logic/src/actions/turn/general/che_견문.ts b/packages/logic/src/actions/turn/general/che_견문.ts index b82b7dea..7e1efc46 100644 --- a/packages/logic/src/actions/turn/general/che_견문.ts +++ b/packages/logic/src/actions/turn/general/che_견문.ts @@ -7,6 +7,7 @@ import { defaultActionContextBuilder } from '@sammo-ts/logic/actions/turn/action import { tryApplyUniqueLottery } from '@sammo-ts/logic/rewards/uniqueLottery.js'; import type { GeneralTurnCommandSpec } from './index.js'; import { increaseMetaNumber } from '@sammo-ts/logic/war/utils.js'; +import { GeneralActionPipeline } from '@sammo-ts/logic/actionModules/general.js'; export interface SightseeingArgs {} @@ -172,6 +173,11 @@ export class ActionDefinition< > implements GeneralActionDefinition { public readonly key = 'che_견문'; public readonly name = ACTION_NAME; + private readonly pipeline: GeneralActionPipeline; + + public constructor(env: TurnCommandEnv) { + this.pipeline = new GeneralActionPipeline(env.generalActionModules ?? []); + } parseArgs(_raw: unknown): SightseeingArgs | null { void _raw; @@ -231,7 +237,7 @@ export class ActionDefinition< general.injury = Math.min(80, general.injury + delta); } - general.experience += exp; + general.experience += this.pipeline.onCalcStat(context, 'experience', exp); context.addLog(message); tryApplyUniqueLottery(context, { acquireType: '아이템', reason: ACTION_NAME }); @@ -248,5 +254,5 @@ export const commandSpec: GeneralTurnCommandSpec = { category: '개인', reqArg: false, - createDefinition: (_env: TurnCommandEnv) => new ActionDefinition(), + createDefinition: (env: TurnCommandEnv) => new ActionDefinition(env), }; diff --git a/packages/logic/src/actions/turn/general/che_군량매매.ts b/packages/logic/src/actions/turn/general/che_군량매매.ts index da32d968..564534e8 100644 --- a/packages/logic/src/actions/turn/general/che_군량매매.ts +++ b/packages/logic/src/actions/turn/general/che_군량매매.ts @@ -17,10 +17,12 @@ import { tryApplyUniqueLottery } from '@sammo-ts/logic/rewards/uniqueLottery.js' import type { GeneralTurnCommandSpec } from './index.js'; import { parseArgsWithSchema } from '../parseArgs.js'; import { normalizeResourceActionAmount } from '../resourceAmount.js'; +import { GeneralActionPipeline } from '@sammo-ts/logic/actionModules/general.js'; export interface TradeEnvironment { exchangeFee?: number; maxResourceActionAmount?: number; + generalActionModules?: TurnCommandEnv['generalActionModules']; } const ACTION_NAME = '군량매매'; @@ -37,9 +39,11 @@ export class ActionDefinition< public readonly key = 'che_군량매매'; public readonly name = ACTION_NAME; private readonly env: TradeEnvironment; + private readonly pipeline: GeneralActionPipeline; constructor(env: TradeEnvironment = {}) { this.env = env; + this.pipeline = new GeneralActionPipeline(env.generalActionModules ?? []); } parseArgs(raw: unknown): TradeArgs | null { @@ -126,8 +130,8 @@ export class ActionDefinition< } // 경험치 및 명성 증가 - general.experience += 30; - general.dedication += 50; + general.experience += this.pipeline.onCalcStat(context, 'experience', 30); + general.dedication += this.pipeline.onCalcStat(context, 'dedication', 50); const weightedStats = [ ['leadership_exp', general.stats.leadership], ['strength_exp', general.stats.strength], @@ -167,5 +171,6 @@ export const commandSpec: GeneralTurnCommandSpec = { createDefinition: (env: TurnCommandEnv) => new ActionDefinition({ maxResourceActionAmount: env.maxResourceActionAmount, + generalActionModules: env.generalActionModules, }), }; diff --git a/packages/logic/src/actions/turn/general/che_귀환.ts b/packages/logic/src/actions/turn/general/che_귀환.ts index dfadc3c2..a00ef026 100644 --- a/packages/logic/src/actions/turn/general/che_귀환.ts +++ b/packages/logic/src/actions/turn/general/che_귀환.ts @@ -22,6 +22,7 @@ import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js' import type { ActionContextBuilder } 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/actionModules/general.js'; export interface ReturnArgs {} @@ -41,6 +42,11 @@ export class ActionResolver< TriggerState extends GeneralTriggerState = GeneralTriggerState, > implements GeneralActionResolver { readonly key = ACTION_KEY; + private readonly pipeline: GeneralActionPipeline; + + constructor(generalActionModules?: TurnCommandEnv['generalActionModules']) { + this.pipeline = new GeneralActionPipeline(generalActionModules ?? []); + } resolve(context: ReturnResolveContext, _args: ReturnArgs): GeneralActionOutcome { const general = context.general; @@ -116,8 +122,8 @@ export class ActionResolver< // We can just use standard MONTH format for now. }); - const exp = 70; - const ded = 100; + const exp = this.pipeline.onCalcStat(context, 'experience', 70); + const ded = this.pipeline.onCalcStat(context, 'dedication', 100); tryApplyUniqueLottery(context, { acquireType: '아이템', reason: ACTION_NAME }); @@ -159,8 +165,8 @@ export class ActionDefinition< public readonly name = ACTION_NAME; private readonly resolver: ActionResolver; - constructor() { - this.resolver = new ActionResolver(); + constructor(env: Pick = {}) { + this.resolver = new ActionResolver(env.generalActionModules); } parseArgs(_raw: unknown): ReturnArgs | null { @@ -192,5 +198,5 @@ export const commandSpec: GeneralTurnCommandSpec = { category: '군사', reqArg: false, - createDefinition: (_env: TurnCommandEnv) => new ActionDefinition(), + createDefinition: (env: TurnCommandEnv) => new ActionDefinition(env), }; diff --git a/packages/logic/src/actions/turn/general/che_기술연구.ts b/packages/logic/src/actions/turn/general/che_기술연구.ts index 3d707ca5..748d6a1e 100644 --- a/packages/logic/src/actions/turn/general/che_기술연구.ts +++ b/packages/logic/src/actions/turn/general/che_기술연구.ts @@ -27,6 +27,11 @@ import { } from './che_상업투자.js'; import { JosaUtil } from '@sammo-ts/common'; import { clamp } from 'es-toolkit'; +import { + addLegacyStoredFloat, + readLegacyStoredFloat, + toLegacyStoredFloat, +} from '@sammo-ts/logic/compat/legacyFloat.js'; export interface TechResearchArgs {} @@ -55,7 +60,17 @@ const readTech = (nation: Nation): number => { }; // 레거시 nation.tech는 MariaDB FLOAT이며 다음 명령 재조회 시 6자리 유효숫자로 양자화된다. -const toLegacyStoredTech = (value: number): number => Number(Math.fround(value).toPrecision(6)); +// Ref stores tech in a MariaDB FLOAT column. FLOAT applies binary32 +// quantization on write; its six-significant-digit text rendering happens +// only when the value is read, not on every update. +export const toLegacyStoredTech = toLegacyStoredFloat; + +// mysqli renders a MariaDB FLOAT with six significant decimal digits before +// PHP performs the next command's arithmetic. Model that read boundary, then +// model the binary32 write boundary separately. +export const readLegacyStoredTech = readLegacyStoredFloat; + +export const addLegacyStoredTech = addLegacyStoredFloat; export class ActionDefinition< TriggerState extends GeneralTriggerState = GeneralTriggerState, @@ -107,10 +122,21 @@ export class ActionDefinition< techScore /= 4; } + const generalCount = Math.max(context.nationGeneralCount, this.env.initialNationGenLimit); + if ( + (process.env.CORE_AI_TRACE_GENERAL_IDS?.split(',') ?? []).includes(String(context.general.id)) || + (process.env.CORE_AI_TRACE_NATION_IDS?.split(',') ?? []).includes(String(context.nation.id)) + ) { + process.stdout.write( + `AI_ACTION_PATCH_TRACE ${JSON.stringify({ engine: 'core-tech', generalId: context.general.id, nationId: context.nation.id, currentTech, techScore, nationGeneralCount: context.nationGeneralCount, generalCount, delta: techScore / generalCount })}\n` + ); + } + context.nation.meta = { ...context.nation.meta, - tech: toLegacyStoredTech( - currentTech + techScore / Math.max(context.nationGeneralCount, this.env.initialNationGenLimit) + tech: addLegacyStoredTech( + currentTech, + techScore / generalCount ), }; context.general.gold = Math.max(0, context.general.gold - result.costGold); diff --git a/packages/logic/src/actions/turn/general/che_랜덤임관.ts b/packages/logic/src/actions/turn/general/che_랜덤임관.ts index 1b277691..521cc407 100644 --- a/packages/logic/src/actions/turn/general/che_랜덤임관.ts +++ b/packages/logic/src/actions/turn/general/che_랜덤임관.ts @@ -15,6 +15,8 @@ import { tryApplyUniqueLottery } from '@sammo-ts/logic/rewards/uniqueLottery.js' import type { GeneralTurnCommandSpec } from './index.js'; import type { ActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js'; import { resolveStartYear } from '@sammo-ts/logic/actions/turn/actionContextHelpers.js'; +import { GeneralActionPipeline } from '@sammo-ts/logic/actionModules/general.js'; +import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js'; export interface RandomAppointmentArgs {} @@ -36,6 +38,8 @@ export interface RandomAppointmentResolveContext< } const ACTION_NAME = '무작위 국가로 임관'; +const LEGACY_INITIAL_NATION_GEN_LIMIT = 10; +const LEGACY_DEFAULT_MAX_GENERAL = 500; const TALK_LIST = [ '어쩌다 보니', @@ -138,6 +142,11 @@ export class ActionDefinition< > { public readonly key = 'che_랜덤임관'; public readonly name = ACTION_NAME; + private readonly pipeline: GeneralActionPipeline; + + constructor(env: TurnCommandEnv) { + this.pipeline = new GeneralActionPipeline(env.generalActionModules ?? []); + } getInheritanceActiveActionAmount(): number { return 1; } @@ -236,7 +245,7 @@ export class ActionDefinition< nationId: destNation.id, officerLevel: 1, cityId: destCityId, - experience: general.experience + expGain, + experience: general.experience + this.pipeline.onCalcStat(context, 'experience', expGain), meta, }), createNationPatchEffect( @@ -294,8 +303,16 @@ export const actionContextBuilder: ActionContextBuilder = (base, options) => { const constValues = asRecord(options.scenarioConfig.const); const worldMeta = asRecord(options.world.meta); - const initialNationGenLimit = resolveNumber(constValues, ['initialNationGenLimit'], 0); - const defaultMaxGeneral = resolveNumber(constValues, ['defaultMaxGeneral', 'maxGeneral'], 0); + const initialNationGenLimit = resolveNumber( + constValues, + ['initialNationGenLimit'], + LEGACY_INITIAL_NATION_GEN_LIMIT + ); + const defaultMaxGeneral = resolveNumber( + constValues, + ['defaultMaxGeneral', 'maxGeneral'], + LEGACY_DEFAULT_MAX_GENERAL + ); const genLimit = relYear < 3 && initialNationGenLimit > 0 ? initialNationGenLimit : defaultMaxGeneral; const generals = worldRef.listGenerals(); @@ -314,7 +331,11 @@ export const actionContextBuilder: ActionContextBuilder = (base, options) => { } } - const nations = worldRef.listNations(); + // Ref's GROUP BY result is observed in ascending nation id order. The + // weighted draw is order-sensitive, while newly founded Core nations can + // remain in action/creation order, so make the legacy candidate order + // explicit before consuming the command RNG. + const nations = [...worldRef.listNations()].sort((left, right) => left.id - right.id); const candidateNations: Array = []; for (const nation of nations) { @@ -373,5 +394,5 @@ export const commandSpec: GeneralTurnCommandSpec = { key: 'che_랜덤임관', category: '전략', reqArg: false, - createDefinition: () => new ActionDefinition(), + createDefinition: (env: TurnCommandEnv) => new ActionDefinition(env), }; diff --git a/packages/logic/src/actions/turn/general/che_물자조달.ts b/packages/logic/src/actions/turn/general/che_물자조달.ts index 1f50990e..ee022f03 100644 --- a/packages/logic/src/actions/turn/general/che_물자조달.ts +++ b/packages/logic/src/actions/turn/general/che_물자조달.ts @@ -25,6 +25,8 @@ interface ProcureContext< const ACTION_NAME = '물자조달'; const ACTION_KEY = 'che_물자조달'; +export const roundLegacyAccumulatedInteger = (current: number, delta: number): number => Math.round(current + delta); + export class ActionResolver< TriggerState extends GeneralTriggerState = GeneralTriggerState, > implements GeneralActionResolver { @@ -96,13 +98,17 @@ export class ActionResolver< score = Math.round(score); // 6. Calculate Exp/Dedication - const exp = (score * 0.7) / 3; - const ded = (score * 1.0) / 3; + const exp = this.pipeline.onCalcStat(context, 'experience', (score * 0.7) / 3); + const ded = this.pipeline.onCalcStat(context, 'dedication', (score * 1.0) / 3); // 7. Update General - // 레거시는 부동소수점 증가분을 INT column에 저장할 때 반올림한다. - const nextExp = general.experience + Math.round(exp); - const nextDed = general.dedication + Math.round(ded); + // Ref adds the floating delta to the current value first and MariaDB + // rounds the accumulated value when it writes the INT column. Rounding + // the delta separately changes cancellation cases such as + // 4554 + (45 * 0.7 / 3): the delta is 10.499999999999998, while the + // accumulated binary value is exactly 4564.5 and persists as 4565. + const nextExp = roundLegacyAccumulatedInteger(general.experience, exp); + const nextDed = roundLegacyAccumulatedInteger(general.dedication, ded); let appliedScore = score; if (context.city && [1, 3].includes(context.city.frontState)) { diff --git a/packages/logic/src/actions/turn/general/che_사기진작.ts b/packages/logic/src/actions/turn/general/che_사기진작.ts index 819efe1d..cd7d26ba 100644 --- a/packages/logic/src/actions/turn/general/che_사기진작.ts +++ b/packages/logic/src/actions/turn/general/che_사기진작.ts @@ -17,6 +17,7 @@ import { tryApplyUniqueLottery } from '@sammo-ts/logic/rewards/uniqueLottery.js' import type { GeneralTurnCommandSpec } from './index.js'; import { clamp } from 'es-toolkit'; import { GeneralActionPipeline } from '@sammo-ts/logic/actionModules/general.js'; +import { applyLegacyInjury, finalizeLegacyStat } from './legacyGeneralStat.js'; export interface BoostMoraleArgs {} @@ -85,8 +86,10 @@ export class ActionDefinition< ? this.env.maxAtmosByCommand : DEFAULT_MAX_ATMOS; const delta = this.env.atmosDelta && this.env.atmosDelta > 0 ? this.env.atmosDelta : DEFAULT_ATMOS_DELTA; - const leadership = this.pipeline.onCalcStat(context, 'leadership', general.stats.leadership); - const score = Math.round((leadership * 100 * delta) / general.crew); + const leadership = finalizeLegacyStat( + this.pipeline.onCalcStat(context, 'leadership', applyLegacyInjury(general.stats.leadership, general.injury)) + ); + const score = Math.round(((leadership * 100) / general.crew) * delta); const nextAtmos = clamp(general.atmos + score, 0, maxAtmos); const applied = nextAtmos - general.atmos; const costGold = this.env.costGold ?? Math.round(general.crew / 100); @@ -95,15 +98,20 @@ export class ActionDefinition< general.atmos = nextAtmos; general.train = trainSideEffect; general.gold = Math.max(0, general.gold - costGold); - general.experience += 100; - general.dedication += 70; + general.experience += this.pipeline.onCalcStat(context, 'experience', 100); + general.dedication += this.pipeline.onCalcStat(context, 'dedication', 70); const leadershipExp = typeof general.meta.leadership_exp === 'number' ? general.meta.leadership_exp : 0; general.meta.leadership_exp = leadershipExp + 1; const crewType = this.env.unitSet?.crewTypes?.find((entry) => entry.id === general.crewTypeId); if (crewType) { - const dexKey = `dex${crewType.armType}`; - const dex = typeof general.meta[dexKey] === 'number' ? general.meta[dexKey] : 0; - general.meta[dexKey] = dex + applied; + const armType = crewType.armType === 0 ? 5 : crewType.armType; + if (armType >= 0) { + const dexKey = `dex${armType}`; + const dex = typeof general.meta[dexKey] === 'number' ? general.meta[dexKey] : 0; + const typeMultiplier = armType === 4 || armType === 5 ? 0.9 : 1; + general.meta[dexKey] = + dex + this.pipeline.onCalcStat(context, 'addDex', applied * typeMultiplier, { armType }); + } } context.addLog(`사기치가 ${applied} 상승했습니다.`); diff --git a/packages/logic/src/actions/turn/general/che_상업투자.ts b/packages/logic/src/actions/turn/general/che_상업투자.ts index c9624641..e5d89486 100644 --- a/packages/logic/src/actions/turn/general/che_상업투자.ts +++ b/packages/logic/src/actions/turn/general/che_상업투자.ts @@ -58,6 +58,7 @@ export interface InvestmentConfig { useCityTrust?: boolean; scaleSuccessByTrust?: boolean; roundCriticalScore?: boolean; + costMultiplier?: number; } export interface InvestmentEnvironment { @@ -181,7 +182,7 @@ export class CommandResolver; -// 레거시 city.trust는 MariaDB FLOAT이며 다음 명령에서 6자리 유효숫자로 -// 재조회된다. 메모리 상태도 같은 persistence 경계로 정규화한다. -const toLegacyStoredTrust = (value: number): number => Number(value.toPrecision(6)); - export class ActionResolver< TriggerState extends GeneralTriggerState = GeneralTriggerState, > implements GeneralActionResolver { @@ -98,8 +95,9 @@ export class ActionResolver< } general.meta.firenum = (typeof general.meta.firenum === 'number' ? general.meta.firenum : 0) + 1; const newSecu = Math.max(0, destCity.security - result.agriDamage); - const currentTrust = typeof destCity.meta.trust === 'number' ? destCity.meta.trust : 50; - const newTrust = toLegacyStoredTrust(Math.max(0, currentTrust - result.commDamage)); + const currentTrust = + typeof destCity.meta.trust === 'number' ? readLegacyCityTrust(destCity.meta.trust) : 50; + const newTrust = storeLegacyCityTrust(Math.max(0, currentTrust - result.commDamage)); // Log const commandName = ACTION_NAME; diff --git a/packages/logic/src/actions/turn/general/che_소집해제.ts b/packages/logic/src/actions/turn/general/che_소집해제.ts index 244f80dd..2e6f9044 100644 --- a/packages/logic/src/actions/turn/general/che_소집해제.ts +++ b/packages/logic/src/actions/turn/general/che_소집해제.ts @@ -43,8 +43,8 @@ export class ActionDefinition< const crewUp = this.pipeline.onCalcDomestic(context, '징집인구', 'score', general.crew); general.crew = 0; city.population += Math.trunc(crewUp); - general.experience += 70; - general.dedication += 100; + general.experience += this.pipeline.onCalcStat(context, 'experience', 70); + general.dedication += this.pipeline.onCalcStat(context, 'dedication', 100); context.addLog(`병사들을 소집해제하였습니다.`); diff --git a/packages/logic/src/actions/turn/general/che_요양.ts b/packages/logic/src/actions/turn/general/che_요양.ts index 96276bd8..3ba0f9f4 100644 --- a/packages/logic/src/actions/turn/general/che_요양.ts +++ b/packages/logic/src/actions/turn/general/che_요양.ts @@ -5,6 +5,7 @@ import type { GeneralActionOutcome, GeneralActionResolveContext } from '@sammo-t import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js'; import { defaultActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js'; import type { GeneralTurnCommandSpec } from './index.js'; +import { GeneralActionPipeline } from '@sammo-ts/logic/actionModules/general.js'; export interface RecoveryArgs {} @@ -21,9 +22,11 @@ export class ActionDefinition< public readonly key = 'che_요양'; public readonly name = ACTION_NAME; private readonly env: RecoveryEnvironment; + private readonly pipeline: GeneralActionPipeline; - constructor(env: RecoveryEnvironment = {}) { + constructor(env: RecoveryEnvironment & Partial> = {}) { this.env = env; + this.pipeline = new GeneralActionPipeline(env.generalActionModules ?? []); } parseArgs(_raw: unknown): RecoveryArgs | null { @@ -46,8 +49,8 @@ export class ActionDefinition< // 직접 수정 (Immer Draft) general.injury = nextInjury; general.gold = Math.max(0, general.gold - costGold); - general.experience += 10; - general.dedication += 7; + general.experience += this.pipeline.onCalcStat(context, 'experience', 10); + general.dedication += this.pipeline.onCalcStat(context, 'dedication', 7); context.addLog(`건강 회복을 위해 요양합니다.`); @@ -63,5 +66,5 @@ export const commandSpec: GeneralTurnCommandSpec = { category: '개인', reqArg: false, - createDefinition: (_env: TurnCommandEnv) => new ActionDefinition(), + createDefinition: (env: TurnCommandEnv) => new ActionDefinition(env), }; diff --git a/packages/logic/src/actions/turn/general/che_이동.ts b/packages/logic/src/actions/turn/general/che_이동.ts index d43b3531..3d99052f 100644 --- a/packages/logic/src/actions/turn/general/che_이동.ts +++ b/packages/logic/src/actions/turn/general/che_이동.ts @@ -24,6 +24,7 @@ import type { GeneralTurnCommandSpec } from './index.js'; import type { MapDefinition } from '@sammo-ts/logic/world/types.js'; import { parseArgsWithSchema } from '../parseArgs.js'; import { formatDestCityConstraintFailure } from '../constraintFailure.js'; +import { GeneralActionPipeline } from '@sammo-ts/logic/actionModules/general.js'; export interface MoveResolveContext< TriggerState extends GeneralTriggerState = GeneralTriggerState, @@ -44,6 +45,11 @@ export class ActionResolver< TriggerState extends GeneralTriggerState = GeneralTriggerState, > implements GeneralActionResolver { readonly key = ACTION_KEY; + private readonly pipeline: GeneralActionPipeline; + + constructor(env: TurnCommandEnv) { + this.pipeline = new GeneralActionPipeline(env.generalActionModules ?? []); + } resolve(context: MoveResolveContext, args: MoveArgs): GeneralActionOutcome { const general = context.general; @@ -89,7 +95,7 @@ export class ActionResolver< if (isSelf) { nextGold = Math.max(0, nextGold - cost); nextAtmos = Math.max(20, nextAtmos - 5); - nextExp += 50; + nextExp += this.pipeline.onCalcStat(context, 'experience', 50); nextLeadershipExp += 1; } @@ -123,8 +129,8 @@ export class ActionDefinition< public readonly name = ACTION_NAME; private readonly resolver: ActionResolver; - constructor() { - this.resolver = new ActionResolver(); + constructor(env: TurnCommandEnv) { + this.resolver = new ActionResolver(env); } parseArgs(raw: unknown): MoveArgs | null { @@ -171,7 +177,10 @@ export const actionContextBuilder: ActionContextBuilder = (base, options) => { return { ...base, map: options.map, - develCost: options.scenarioConfig.const.develCost as number | undefined, + develCost: + typeof options.world.meta?.develcost === 'number' + ? options.world.meta.develcost + : (options.scenarioConfig.const.develCost as number | undefined), moveGenerals: options.worldRef?.listGenerals() ?? [], }; }; @@ -182,5 +191,5 @@ export const commandSpec: GeneralTurnCommandSpec = { reqArg: true, availabilityArgs: { destCityId: 0 }, argsSchema: ARGS_SCHEMA, - createDefinition: (_env: TurnCommandEnv) => new ActionDefinition(), + createDefinition: (env: TurnCommandEnv) => new ActionDefinition(env), }; diff --git a/packages/logic/src/actions/turn/general/che_인재탐색.ts b/packages/logic/src/actions/turn/general/che_인재탐색.ts index 94d55f5d..6714e5a5 100644 --- a/packages/logic/src/actions/turn/general/che_인재탐색.ts +++ b/packages/logic/src/actions/turn/general/che_인재탐색.ts @@ -51,11 +51,14 @@ export interface TalentScoutResolveContext< > extends GeneralActionResolveContext { currentYear: number; currentMonth: number; + retirementYear: number; worldSummary: TalentScoutWorldSummary; generalPool?: TalentScoutCandidate[]; cityPool?: City[]; + existingGeneralNames: string[]; createGeneralId: () => number; turnTermMinutes: number; + turnTimeBase: Date; } export interface TalentScoutEnvironment { @@ -97,6 +100,17 @@ const DEFAULT_MAX_AGE = 25; const DEFAULT_DEATH_MIN = 10; const DEFAULT_DEATH_MAX = 50; +export const normalizeLegacyGeneratedDex = ( + values: readonly [number, number, number, number, number] +): [number, number, number, number, number] => + values.map((value) => Math.trunc(value)) as [number, number, number, number, number]; + +export const resolveLegacySpecialityAge = ( + retirementYear: number, + age: number, + divisor: number +): number => Math.round((retirementYear - age) / divisor) + age; + const addMetaValue = ( meta: Record, key: string, @@ -181,6 +195,32 @@ const legacyChoiceIndex = (rng: RandomGenerator, length: number): number => { const legacyChoice = (rng: RandomGenerator, values: readonly T[]): T => values[legacyChoiceIndex(rng, values.length)]!; +const NPC_NAME_PREFIXES = ['', 'ⓝ', 'ⓝ', 'ⓜ', 'ⓖ', '㉥', 'ⓤ', 'ⓞ'] as const; +const NPC_STATE_NAME_PREFIXES: Readonly> = { + 0: '', + 1: 'ⓝ', + 2: 'ⓝ', + 3: 'ⓜ', + 4: 'ⓖ', + 5: '㉥', + 6: 'ⓤ', + 9: 'ⓞ', +}; +const STORED_NAME_PREFIXES = new Set(Object.values(NPC_STATE_NAME_PREFIXES).filter(Boolean)); + +const restoreLegacyStoredName = (general: Pick): 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 resolveCandidate = ( context: TalentScoutResolveContext, rng: RandomGenerator, @@ -261,6 +301,14 @@ export class CommandResolver, value: number): number { + return this.pipeline.onCalcStat(context, 'experience', value); + } + + adjustDedication(context: TalentScoutResolveContext, value: number): number { + return this.pipeline.onCalcStat(context, 'dedication', value); + } } // 인재탐색 실행 결과를 계산한다. @@ -297,8 +345,8 @@ export class ActionResolver< // 직접 수정 (Immer Draft) general.gold = nextGold; general.rice = nextRice; - general.experience += expGain; - general.dedication += dedGain; + general.experience += this.command.adjustExperience(context, expGain); + general.dedication += this.command.adjustDedication(context, dedGain); if (!found) { const statKey = pickStatExpKey(context.rng, general); @@ -328,10 +376,23 @@ export class ActionResolver< 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)}`; + 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; + } + if (duplicateLoopCount >= 99 || duplicateCount < 2) { + generatedName += duplicateCount + 1; + break; + } + duplicateLoopCount += 1; + } const newGeneralId = context.createGeneralId(); const resolvedCandidate: TalentScoutCandidate = candidate ?? { name: generatedName }; const affinity = randomRangeInt(context.rng, 1, 150); @@ -369,6 +430,10 @@ export class ActionResolver< } else { dex = [dexTotal / 4, dexTotal / 4, dexTotal / 4, dexTotal / 4, averageDex[4]]; } + // Ref passes the averages into GeneralBuilder::setDex(int ...), so PHP + // truncates every component before persistence. Core's common integer + // normalizer rounds instead, which changes exact half-point cases. + dex = normalizeLegacyGeneratedDex(dex); const personality = resolvedCandidate.personality ?? legacyChoice(context.rng, this.env.availablePersonalities ?? ['che_안전']); const name = this.env.decorateName @@ -377,6 +442,9 @@ export class ActionResolver< 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 turnTime = new Date( + context.turnTimeBase.getTime() + turnSecond * 1_000 + Math.floor(turnFraction / 1_000) + ); const killturn = (deathYear - context.currentYear) * 12 + randomRangeInt(context.rng, 0, 11) + context.currentMonth - 1; const meta: GeneralMeta = { @@ -386,6 +454,16 @@ export class ActionResolver< affinity, birthYear, deathYear, + specage: resolveLegacySpecialityAge( + context.retirementYear, + age, + 12 + ), + specage2: resolveLegacySpecialityAge( + context.retirementYear, + age, + 6 + ), dex1: dex[0], dex2: dex[1], dex3: dex[2], @@ -397,27 +475,33 @@ export class ActionResolver< addMetaValue(meta, 'picture', resolvedCandidate.picture ?? null); addMetaValue(meta, 'text', resolvedCandidate.text ?? null); - const newGeneral = buildRecruitmentGeneral({ - id: newGeneralId, - name, - nationId: 0, - cityId, - stats, - officerLevel: 0, - age, - npcState: NPC_TYPE, - gold: this.env.defaultNpcGold, - rice: this.env.defaultNpcRice, - experience: age * 100, - dedication: age * 100, - crewTypeId: this.env.defaultCrewTypeId, - role: { - personality, - specialDomestic: null, - specialWar: null, - }, - meta, - }); + const newGeneral = { + ...buildRecruitmentGeneral({ + id: newGeneralId, + name, + nationId: 0, + cityId, + stats, + officerLevel: 0, + age, + npcState: NPC_TYPE, + gold: this.env.defaultNpcGold, + rice: this.env.defaultNpcRice, + experience: age * 100, + dedication: age * 100, + crewTypeId: this.env.defaultCrewTypeId, + role: { + personality, + specialDomestic: null, + specialWar: null, + }, + meta, + }), + turnTime, + bornYear: birthYear, + deadYear: deathYear, + affinity, + }; const recruitVerb = '발견'; const nameRa = JosaUtil.pick(name, '라'); @@ -495,6 +579,10 @@ export const actionContextBuilder: ActionContextBuilder = (base, options) => ({ ...base, currentYear: options.world.currentYear, currentMonth: options.world.currentMonth, + retirementYear: + typeof options.scenarioConfig.const.retirementYear === 'number' + ? options.scenarioConfig.const.retirementYear + : 80, worldSummary: { ...buildWorldSummary(options.worldRef), averageDex: (() => { @@ -511,9 +599,18 @@ export const actionContextBuilder: ActionContextBuilder = (base, options) => ({ ) as [number, number, number, number, number]; })(), }, - cityPool: options.worldRef?.listCities() ?? [], + // Legacy CityHelper::getAllCities() is consumed in primary-key order. + cityPool: [...(options.worldRef?.listCities() ?? [])].sort((left, right) => left.id - right.id), + // Core keeps the initial scenario's display prefix separate from `name`, + // while Ref persists it in `general.name`. Reconstruct it before executing + // AbsGeneralPool::checkDuplicatedCnt semantics; the duplicated ⓝ prefix in + // GeneralBuilder::$prefixList intentionally counts NPC matches twice. + existingGeneralNames: options.worldRef?.listGenerals().map(restoreLegacyStoredName) ?? [], createGeneralId: options.createGeneralId, turnTermMinutes: Math.max(1, Math.round(options.world.tickSeconds / 60)), + // GeneralBuilder::build() derives a new NPC turn from gameStor.turntime, + // not from the scout's own reserved-turn timestamp. + turnTimeBase: options.world.lastTurnTime ?? base.general.turnTime, }); export const commandSpec: GeneralTurnCommandSpec = { diff --git a/packages/logic/src/actions/turn/general/che_정착장려.ts b/packages/logic/src/actions/turn/general/che_정착장려.ts index bbf8f250..708fb957 100644 --- a/packages/logic/src/actions/turn/general/che_정착장려.ts +++ b/packages/logic/src/actions/turn/general/che_정착장려.ts @@ -39,6 +39,7 @@ const CONFIG: InvestmentConfig = { frontDebuff: 1, useCityTrust: false, scaleSuccessByTrust: false, + costMultiplier: 2, }; export class ActionDefinition< @@ -49,11 +50,7 @@ export class ActionDefinition< private readonly command: CommandResolver; constructor(env: TurnCommandEnv) { - this.command = new CommandResolver( - env.generalActionModules ?? [], - { ...env, develCost: env.develCost * 2 }, - CONFIG - ); + this.command = new CommandResolver(env.generalActionModules ?? [], env, CONFIG); } parseArgs(_raw: unknown): SettlementArgs | null { diff --git a/packages/logic/src/actions/turn/general/che_주민선정.ts b/packages/logic/src/actions/turn/general/che_주민선정.ts index 727954c9..7d80c02d 100644 --- a/packages/logic/src/actions/turn/general/che_주민선정.ts +++ b/packages/logic/src/actions/turn/general/che_주민선정.ts @@ -23,6 +23,7 @@ import { } from './che_상업투자.js'; import { JosaUtil } from '@sammo-ts/common'; import { clamp } from 'es-toolkit'; +import { readLegacyCityTrust, storeLegacyCityTrust } from './legacyCityTrust.js'; export interface TrustActionArgs {} @@ -40,26 +41,21 @@ const CONFIG: InvestmentConfig = { useCityTrust: false, scaleSuccessByTrust: false, roundCriticalScore: false, + costMultiplier: 2, }; const readTrust = (city: City): number => { const trust = city.meta.trust; - return typeof trust === 'number' && Number.isFinite(trust) ? trust : DEFAULT_TRUST; + return typeof trust === 'number' && Number.isFinite(trust) ? readLegacyCityTrust(trust) : DEFAULT_TRUST; }; -// 레거시 city.trust는 MariaDB FLOAT이며 다음 명령에서 6자리 유효숫자로 -// 재조회된다. 같은 턴의 후속 명령도 그 저장 경계를 보도록 정규화한다. -const toLegacyStoredTrust = (value: number): number => Number(Math.fround(value).toPrecision(6)); - const remainCityTrust = (): Constraint => ({ name: 'remainCityTrust', requires: (ctx) => (ctx.cityId !== undefined ? [{ kind: 'city', id: ctx.cityId }] : []), test: (ctx, view) => { const city = ctx.cityId !== undefined ? (view.get({ kind: 'city', id: ctx.cityId }) as City | null) : null; if (!city) return { kind: 'deny', reason: '도시 정보가 없습니다.' }; - return readTrust(city) >= 100 - ? { kind: 'deny', reason: '주민 선정은 충분합니다.' } - : { kind: 'allow' }; + return readTrust(city) >= 100 ? { kind: 'deny', reason: '주민 선정은 충분합니다.' } : { kind: 'allow' }; }, }); @@ -71,11 +67,7 @@ export class ActionDefinition< private readonly command: CommandResolver; constructor(env: TurnCommandEnv) { - this.command = new CommandResolver( - env.generalActionModules ?? [], - { ...env, develCost: env.develCost * 2 }, - CONFIG - ); + this.command = new CommandResolver(env.generalActionModules ?? [], env, CONFIG); } parseArgs(_raw: unknown): TrustActionArgs | null { @@ -113,7 +105,7 @@ export class ActionDefinition< const trustDelta = result.score / 10; context.city.meta = { ...context.city.meta, - trust: toLegacyStoredTrust(clamp(readTrust(context.city) + trustDelta, 0, 100)), + trust: storeLegacyCityTrust(clamp(readTrust(context.city) + trustDelta, 0, 100)), }; context.general.rice = Math.max(0, context.general.rice - result.costGold); context.general.experience += result.exp; diff --git a/packages/logic/src/actions/turn/general/che_징병.ts b/packages/logic/src/actions/turn/general/che_징병.ts index 23b6c900..117183e8 100644 --- a/packages/logic/src/actions/turn/general/che_징병.ts +++ b/packages/logic/src/actions/turn/general/che_징병.ts @@ -30,6 +30,8 @@ import { isCrewTypeAvailable, } from '@sammo-ts/logic/world/unitSet.js'; import { parseArgsWithSchema } from '../parseArgs.js'; +import { readLegacyCityTrust, storeLegacyCityTrust } from './legacyCityTrust.js'; +import { applyLegacyInjury, finalizeLegacyStat } from './legacyGeneralStat.js'; export interface RecruitEnvironment { costOffset?: number; @@ -58,6 +60,13 @@ const DEFAULT_MIN_POP = 30000; const DEFAULT_TRUST = 50; const MIN_CREW = 100; +// PHP round() compensates for the small binary drift around a half boundary. +// Ref then converts the result to int through Util::round(). +export const roundLegacyRecruitCost = (value: number): number => { + const corrected = value + Math.sign(value) * Number.EPSILON * Math.max(1, Math.abs(value)); + return corrected < 0 ? Math.ceil(corrected - 0.5) : Math.floor(corrected + 0.5); +}; + export const ARGS_SCHEMA = z.preprocess( (raw) => { if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { @@ -114,10 +123,6 @@ const readCityTrust = (city: City, fallback: number): number => { return typeof trust === 'number' ? trust : fallback; }; -// 레거시 city.trust는 MariaDB FLOAT이며 다음 명령에서 6자리 유효숫자로 -// 재조회된다. 메모리 상태도 같은 persistence 경계로 정규화한다. -const toLegacyStoredTrust = (value: number): number => Number(value.toPrecision(6)); - const addMetaNumber = (meta: GeneralMeta, key: string, delta: number): GeneralMeta => { const current = typeof meta[key] === 'number' ? (meta[key] as number) : 0; return { ...meta, [key]: current + delta }; @@ -223,8 +228,9 @@ export class CommandResolver): number { - const base = context.general.stats.leadership; - return Math.round(this.pipeline.onCalcStat(context, 'leadership', base)); + const general = context.general; + const base = applyLegacyInjury(general.stats.leadership, general.injury); + return finalizeLegacyStat(this.pipeline.onCalcStat(context, 'leadership', base)); } resolveCrewPlan( @@ -268,7 +274,7 @@ export class CommandResolver, + statName: 'experience' | 'dedication', + amount: number + ): number { + return this.pipeline.onCalcStat(context, statName, amount); + } + getDexGain( context: RecruitCalcContext, armType: number, @@ -374,9 +388,9 @@ export class ActionResolver< const costOffset = this.env.costOffset ?? DEFAULT_COST_OFFSET; const recruitPop = this.command.getRecruitPopulation(context, appliedCrew); const nextPopulation = Math.max(city.population - recruitPop, 0); - const baseTrust = readCityTrust(city, this.env.defaultTrust ?? DEFAULT_TRUST); + const baseTrust = readLegacyCityTrust(readCityTrust(city, this.env.defaultTrust ?? DEFAULT_TRUST)); const trustLoss = city.population > 0 ? (recruitPop / city.population / costOffset) * 100 : 0; - const nextTrust = toLegacyStoredTrust(Math.max(baseTrust - trustLoss, 0)); + const nextTrust = storeLegacyCityTrust(Math.max(baseTrust - trustLoss, 0)); const actionName = this.env.actionName ?? ACTION_NAME; const [nextCrewTypeId, nextCrew, nextTrain, nextAtmos] = @@ -405,8 +419,13 @@ export class ActionResolver< const nextGold = Math.max(0, general.gold - plan.gold); const nextRice = Math.max(0, general.rice - plan.rice); - const expGain = Math.round(appliedCrew / 100); - const dedGain = Math.round(appliedCrew / 100); + // Ref rounds the raw crew-based reward first and then routes both + // General::addExperience()/addDedication() through onCalcStat(). This + // is observable for every successful recruit once seasonal rice pay + // lets NPCs afford the command (personality fame modifiers are +/-10%). + const baseStatGain = Math.round(appliedCrew / 100); + const expGain = this.command.getStatGain(context, 'experience', baseStatGain); + const dedGain = this.command.getStatGain(context, 'dedication', baseStatGain); const dexGain = this.command.getDexGain(context, crewType.armType, appliedCrew); // 직접 수정 (Immer Draft) @@ -425,6 +444,12 @@ export class ActionResolver< general.experience += expGain; general.dedication += dedGain; general.meta = addMetaNumber(general.meta, 'leadership_exp', 1); + // Ref persists the selected arm category in General.aux. GeneralAI + // reuses it for the next recruitment instead of drawing a new category. + general.meta = { + ...general.meta, + armType: crewType.armType, + }; if (dexGain) { general.meta = addMetaNumber(general.meta, dexGain.key, dexGain.amount); } diff --git a/packages/logic/src/actions/turn/general/che_출병.ts b/packages/logic/src/actions/turn/general/che_출병.ts index c6fbede4..a8dbc5ca 100644 --- a/packages/logic/src/actions/turn/general/che_출병.ts +++ b/packages/logic/src/actions/turn/general/che_출병.ts @@ -33,10 +33,12 @@ 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/actionModules/traits/nation/index.js'; import type { GeneralActionModule } from '@sammo-ts/logic/actionModules/general.js'; -import { increaseMetaNumber, simpleSerialize } from '@sammo-ts/logic/war/utils.js'; +import { GeneralActionPipeline } from '@sammo-ts/logic/actionModules/general.js'; +import { 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'; import { tryApplyUniqueLottery } from '@sammo-ts/logic/rewards/uniqueLottery.js'; +import { buildNationFrontStatePatches } from '../../../diplomacy/frontState.js'; import { formatDestCityConstraintFailure } from '../constraintFailure.js'; import { buildWarAftermathConfig, @@ -55,7 +57,7 @@ export interface DispatchResolveContext< generals: General[]; unitSet: UnitSetDefinition; map?: MapDefinition; - diplomacy?: Array<{ fromNationId: number; toNationId: number; state: number }>; + diplomacy?: Array<{ fromNationId: number; toNationId: number; state: number; term: number }>; time: WarTimeContext; seedBase: string; warConfig: WarEngineConfig; @@ -172,23 +174,19 @@ const pickCandidateCity = ( if (minDist === undefined) { return null; } - const candidates: Array<[number, number]> = []; for (const dist of distances) { if (dist > minDist + 1) { break; } - for (const entry of distanceList.get(dist) ?? []) { - if (entry[1] !== attackerNationId) { - candidates.push(entry); - } + const candidates = (distanceList.get(dist) ?? []).filter(([, nationId]) => nationId !== attackerNationId); + if (candidates.length > 0) { + // Ref breaks at the first distance layer containing an enemy. It + // only considers minDist + 1 when the minDist layer has none. + // RandUtil::choice() still consumes nextInt(0) for one candidate. + const [cityId] = pickLegacyChoice(candidates); + return { cityId, isEnemy: true, minDist }; } } - if (candidates.length > 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) ?? []; const friendly = fallback.filter(([, nationId]) => nationId === attackerNationId); if (friendly.length === 0) { @@ -257,6 +255,7 @@ export class ActionDefinition< private readonly warModules: ReadonlyArray>; private readonly nationTraitModules: Map; private readonly generalModules: ReadonlyArray>; + private readonly generalPipeline: GeneralActionPipeline; constructor( modules: ReadonlyArray | null | undefined> = [], @@ -266,6 +265,7 @@ export class ActionDefinition< this.warModules = modules.filter(Boolean) as ReadonlyArray>; this.nationTraitModules = new Map(nationTraitModules.map((module) => [module.key, module])); this.generalModules = generalModules.filter(Boolean) as ReadonlyArray>; + this.generalPipeline = new GeneralActionPipeline(this.generalModules); } parseArgs(raw: unknown): DispatchArgs | null { @@ -416,7 +416,16 @@ export class ActionDefinition< const armType = resolveCrewTypeArm(unitSet, context.general.crewTypeId); if (armType !== null) { - increaseMetaNumber(context.general.meta, `dex${armType}`, context.general.crew / 100); + const typeMultiplier = armType === 4 || armType === 5 ? 0.9 : 1; + const amount = this.generalPipeline.onCalcStat( + context, + 'addDex', + (context.general.crew / 100) * typeMultiplier, + { armType } + ); + const dexKey = `dex${armType}`; + const currentDex = context.general.meta[dexKey]; + context.general.meta[dexKey] = (typeof currentDex === 'number' ? currentDex : 0) + amount; } const cities = context.cities.map(cloneCity); @@ -441,6 +450,10 @@ export class ActionDefinition< general.crew > 0 && (unitSet.crewTypes?.some((crewType) => crewType.id === general.crewTypeId) ?? false) ); + const traceGeneralIds = new Set(process.env.CORE_AI_TRACE_GENERAL_IDS?.split(',') ?? []); + const shouldTraceWar = + traceGeneralIds.has(String(context.general.id)) || + defenderGenerals.some((general) => traceGeneralIds.has(String(general.id))); const battle = resolveWarBattle({ seed, @@ -461,6 +474,15 @@ export class ActionDefinition< })), defenderCity, defenderNation, + ...(shouldTraceWar + ? { + trace: (event) => { + process.stdout.write( + `AI_WAR_TRACE ${JSON.stringify({ generalId: context.general.id, event })}\n` + ); + }, + } + : {}), }); const aftermath = resolveWarAftermath({ @@ -486,6 +508,36 @@ export class ActionDefinition< }, }); + // Ref ConquerCity() recalculates the fronts of every nation around the + // captured city immediately. Later generals in the same monthly due + // list therefore observe those refreshed values when choosing whether + // to deploy. Preserve that ordering before snapshotting city effects. + let frontStatePatches: Array<{ id: number; frontState: number }> = []; + if (battle.conquered && context.map && context.diplomacy) { + const connections = new Map( + context.map.cities.map((city) => [city.id, city.connections ?? []] as const) + ); + const nearbyCityIds = new Set([defenderCity.id, ...(connections.get(defenderCity.id) ?? [])]); + const nearbyNationIds = new Set([aftermath.conquest?.conquerNationId ?? attackerNation.id]); + for (const city of cities) { + if (nearbyCityIds.has(city.id) && city.nationId > 0) { + nearbyNationIds.add(city.nationId); + } + } + frontStatePatches = buildNationFrontStatePatches({ + cities, + diplomacy: context.diplomacy, + connections, + nationIds: [...nearbyNationIds], + }); + for (const patch of frontStatePatches) { + const city = cities.find((candidate) => candidate.id === patch.id); + if (city) { + city.frontState = patch.frontState; + } + } + } + const effects: Array> = []; for (const entry of battle.logs) { @@ -541,6 +593,9 @@ export class ActionDefinition< for (const [id, patch] of cityPatches) { effects.push(createCityPatchEffect(patch, id)); } + for (const patch of frontStatePatches) { + effects.push(createCityPatchEffect({ frontState: patch.frontState }, patch.id)); + } for (const [id, patch] of nationPatches) { effects.push(createNationPatchEffect(patch, id)); } @@ -555,7 +610,12 @@ export class ActionDefinition< tryApplyUniqueLottery(context, { acquireType: '아이템', reason: ACTION_NAME }); - return { effects }; + return { + effects, + ...(aftermath.conquest?.ruinedNpcJoinPlans.length + ? { reservedGeneralTurnPlans: aftermath.conquest.ruinedNpcJoinPlans } + : {}), + }; } } @@ -576,6 +636,8 @@ export const actionContextBuilder: ActionContextBuilder = (base, options) => { const diplomacy = options.worldRef.listDiplomacy(); const warConfig = buildWarConfig(options.scenarioConfig, options.unitSet); const aftermathConfig = buildWarAftermathConfig(options.scenarioConfig, warConfig.castleCrewTypeId); + const joinModeRaw = options.world.meta?.join_mode ?? options.world.meta?.joinMode; + aftermathConfig.joinMode = joinModeRaw === 'onlyRandom' ? 'onlyRandom' : 'full'; return { ...base, destCity, diff --git a/packages/logic/src/actions/turn/general/che_헌납.ts b/packages/logic/src/actions/turn/general/che_헌납.ts index cdf59a0c..d6bff186 100644 --- a/packages/logic/src/actions/turn/general/che_헌납.ts +++ b/packages/logic/src/actions/turn/general/che_헌납.ts @@ -22,6 +22,7 @@ import { tryApplyUniqueLottery } from '@sammo-ts/logic/rewards/uniqueLottery.js' import type { GeneralTurnCommandSpec } from './index.js'; import { parseArgsWithSchema } from '../parseArgs.js'; import { normalizeResourceActionAmount } from '../resourceAmount.js'; +import { GeneralActionPipeline } from '@sammo-ts/logic/actionModules/general.js'; const ACTION_NAME = '헌납'; const ACTION_KEY = 'che_헌납'; @@ -36,6 +37,8 @@ export class ActionResolver< > implements GeneralActionResolver { readonly key = ACTION_KEY; + constructor(private readonly pipeline: GeneralActionPipeline = new GeneralActionPipeline([])) {} + resolve(context: GeneralActionResolveContext, args: DonateArgs): GeneralActionOutcome { const general = context.general; const nation = context.nation; @@ -51,8 +54,8 @@ export class ActionResolver< const realAmount = Math.max(0, Math.min(amount, currentRes)); - const exp = 70; - const ded = 100; + const exp = this.pipeline.onCalcStat(context, 'experience', 70); + const ded = this.pipeline.onCalcStat(context, 'dedication', 100); const amountText = realAmount.toLocaleString(); context.addLog(`${resName} ${amountText}을 헌납했습니다.`, { @@ -98,7 +101,9 @@ export class ActionDefinition< private readonly resolver: ActionResolver; constructor(private readonly env: TurnCommandEnv) { - this.resolver = new ActionResolver(); + this.resolver = new ActionResolver( + new GeneralActionPipeline(env.generalActionModules ?? []) + ); } parseArgs(raw: unknown): DonateArgs | null { diff --git a/packages/logic/src/actions/turn/general/che_훈련.ts b/packages/logic/src/actions/turn/general/che_훈련.ts index 3fdabb6c..53398014 100644 --- a/packages/logic/src/actions/turn/general/che_훈련.ts +++ b/packages/logic/src/actions/turn/general/che_훈련.ts @@ -13,6 +13,8 @@ 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/actionModules/general.js'; +import { applyLegacyInjury, finalizeLegacyStat } from './legacyGeneralStat.js'; export interface TrainingArgs {} @@ -21,6 +23,7 @@ export interface TrainingEnvironment { maxTrainByCommand?: number; costGold?: number; unitSet?: TurnCommandEnv['unitSet']; + generalActionModules?: TurnCommandEnv['generalActionModules']; } const ACTION_NAME = '훈련'; @@ -32,9 +35,11 @@ export class ActionDefinition< public readonly key = 'che_훈련'; public readonly name = ACTION_NAME; private readonly env: TrainingEnvironment; + private readonly pipeline: GeneralActionPipeline; constructor(env: TrainingEnvironment = {}) { this.env = env; + this.pipeline = new GeneralActionPipeline(env.generalActionModules ?? []); } parseArgs(_raw: unknown): TrainingArgs | null { @@ -70,10 +75,13 @@ export class ActionDefinition< ? this.env.maxTrainByCommand : DEFAULT_MAX_TRAIN; const trainDelta = this.env.trainDelta && this.env.trainDelta > 0 ? this.env.trainDelta : 0; + const leadership = finalizeLegacyStat( + this.pipeline.onCalcStat(context, 'leadership', applyLegacyInjury(general.stats.leadership, general.injury)) + ); const score = Math.max( 0, Math.min( - Math.round((general.stats.leadership * 100 * trainDelta) / Math.max(general.crew, 1)), + Math.round((leadership * 100 * trainDelta) / Math.max(general.crew, 1)), maxTrain - general.train ) ); @@ -81,15 +89,23 @@ export class ActionDefinition< general.train += score; general.gold = Math.max(0, general.gold - costGold); - general.experience += 100; - general.dedication += 70; + general.experience += this.pipeline.onCalcStat(context, 'experience', 100); + general.dedication += this.pipeline.onCalcStat(context, 'dedication', 70); const leadershipExp = typeof general.meta.leadership_exp === 'number' ? general.meta.leadership_exp : 0; general.meta.leadership_exp = leadershipExp + 1; const crewType = this.env.unitSet?.crewTypes?.find((entry) => entry.id === general.crewTypeId); if (crewType) { - const dexKey = `dex${crewType.armType}`; + const armType = crewType.armType === 0 ? 5 : crewType.armType; + if (armType < 0) { + context.addLog(`훈련치가 ${score.toLocaleString()} 상승했습니다.`); + tryApplyUniqueLottery(context, { acquireType: '아이템', reason: ACTION_NAME }); + return { effects: [] }; + } + const dexKey = `dex${armType}`; const dex = typeof general.meta[dexKey] === 'number' ? general.meta[dexKey] : 0; - general.meta[dexKey] = dex + score; + const typeMultiplier = armType === 4 || armType === 5 ? 0.9 : 1; + general.meta[dexKey] = + dex + this.pipeline.onCalcStat(context, 'addDex', score * typeMultiplier, { armType }); } context.addLog(`훈련치가 ${score.toLocaleString()} 상승했습니다.`); @@ -112,5 +128,6 @@ export const commandSpec: GeneralTurnCommandSpec = { trainDelta: env.trainDelta, maxTrainByCommand: env.maxTrainByCommand, unitSet: env.unitSet, + generalActionModules: env.generalActionModules, }), }; diff --git a/packages/logic/src/actions/turn/general/cr_맹훈련.ts b/packages/logic/src/actions/turn/general/cr_맹훈련.ts index ee020343..62ad1686 100644 --- a/packages/logic/src/actions/turn/general/cr_맹훈련.ts +++ b/packages/logic/src/actions/turn/general/cr_맹훈련.ts @@ -16,6 +16,7 @@ import { defaultActionContextBuilder } from '@sammo-ts/logic/actions/turn/action import { tryApplyUniqueLottery } from '@sammo-ts/logic/rewards/uniqueLottery.js'; import type { GeneralTurnCommandSpec } from './index.js'; import { GeneralActionPipeline } from '@sammo-ts/logic/actionModules/general.js'; +import { applyLegacyInjury, finalizeLegacyStat } from './legacyGeneralStat.js'; const ACTION_NAME = '맹훈련'; const ACTION_KEY = 'cr_맹훈련'; @@ -71,7 +72,9 @@ export class ActionDefinition< const maxTrain = this.env.maxTrainByCommand > 0 ? this.env.maxTrainByCommand : 100; const maxAtmos = this.env.maxAtmosByCommand > 0 ? this.env.maxAtmosByCommand : 100; - const leadership = this.pipeline.onCalcStat(context, 'leadership', general.stats.leadership); + const leadership = finalizeLegacyStat( + this.pipeline.onCalcStat(context, 'leadership', applyLegacyInjury(general.stats.leadership, general.injury)) + ); const score = Math.round((leadership * 100 * trainDelta * 2) / (Math.max(general.crew, 1) * 3)); const scoreText = score.toLocaleString('en-US'); diff --git a/packages/logic/src/actions/turn/general/legacyCityTrust.ts b/packages/logic/src/actions/turn/general/legacyCityTrust.ts new file mode 100644 index 00000000..fb52198f --- /dev/null +++ b/packages/logic/src/actions/turn/general/legacyCityTrust.ts @@ -0,0 +1,8 @@ +/** + * MariaDB stores city.trust as FLOAT (binary32), while the PHP driver exposes + * the value rounded to six significant decimal digits on the next read. + * Keep those boundaries separate so later SQL expressions use binary32 state. + */ +export const storeLegacyCityTrust = (value: number): number => Math.fround(value); + +export const readLegacyCityTrust = (value: number): number => Number(Math.fround(value).toPrecision(6)); diff --git a/packages/logic/src/actions/turn/general/legacyGeneralStat.ts b/packages/logic/src/actions/turn/general/legacyGeneralStat.ts new file mode 100644 index 00000000..68533c17 --- /dev/null +++ b/packages/logic/src/actions/turn/general/legacyGeneralStat.ts @@ -0,0 +1,3 @@ +export const applyLegacyInjury = (value: number, injury: number): number => value * ((100 - injury) / 100); + +export const finalizeLegacyStat = (value: number): number => Math.trunc(value); diff --git a/packages/logic/src/compat/legacyFloat.ts b/packages/logic/src/compat/legacyFloat.ts new file mode 100644 index 00000000..4440b288 --- /dev/null +++ b/packages/logic/src/compat/legacyFloat.ts @@ -0,0 +1,10 @@ +// MariaDB FLOAT stores binary32, but its text protocol exposes only six +// significant decimal digits. Ref reads that text into PHP before every +// command/battle update, so both boundaries are part of the game state. +export const toLegacyStoredFloat = (value: number): number => Math.fround(value); + +export const readLegacyStoredFloat = (value: number): number => + Number(Math.fround(value).toPrecision(6)); + +export const addLegacyStoredFloat = (current: number, delta: number): number => + toLegacyStoredFloat(readLegacyStoredFloat(current) + delta); diff --git a/packages/logic/src/triggers/generalTriggers/che_도시치료.ts b/packages/logic/src/triggers/generalTriggers/che_도시치료.ts index 553cb9c8..ac7bdfe7 100644 --- a/packages/logic/src/triggers/generalTriggers/che_도시치료.ts +++ b/packages/logic/src/triggers/generalTriggers/che_도시치료.ts @@ -18,7 +18,12 @@ const resolveCityGenerals = ( const list = worldView.listGeneralsByCity ? worldView.listGeneralsByCity(general.cityId) : worldView.listGenerals().filter((candidate) => candidate.cityId === general.cityId); - return list.filter((candidate) => candidate.id !== general.id); + // Ref reads patients from the primary-key-backed `general` table. Make + // that observed order explicit so the 50% draws stay attached to the + // same general even when Core's in-memory insertion order differs. + return list + .filter((candidate) => candidate.id !== general.id) + .sort((left, right) => left.id - right.id); }; // 의술 특기의 도시 치료 트리거. diff --git a/packages/logic/src/war/aftermath.ts b/packages/logic/src/war/aftermath.ts index 37d80bd7..08ac38de 100644 --- a/packages/logic/src/war/aftermath.ts +++ b/packages/logic/src/war/aftermath.ts @@ -50,8 +50,11 @@ const findReport = (reports: WarUnitReport[], predicate: (report: WarUnitReport) const getDeadCounter = (city: City): number => getMetaNumber(city.meta, META_DEAD, 0); -const setDeadCounter = (city: City, value: number): void => { - city.meta[META_DEAD] = round(value); +const increaseDeadCounter = (city: City, delta: number): void => { + // Ref binds each `dead + %i` increment as an integer before MariaDB adds + // it. Truncate each 40/60 percent split independently; rounding the + // accumulated counter changes monthly recovery and war income. + city.meta[META_DEAD] = getDeadCounter(city) + Math.trunc(delta); }; const isSupplyCity = (city: City): boolean => { @@ -122,11 +125,16 @@ const applyNationTechGain = ( } const divisor = Math.max(config.initialNationGenLimit, total); - const tech = getMetaNumber(nation.meta, 'tech', 0) + gain / divisor; - // 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 currentTech = getMetaNumber(nation.meta, 'tech', 0); + const delta = gain / divisor; + // Ref executes `tech + delta` inside MariaDB for battle gains, so the + // arithmetic starts from the stored binary32 value without a PHP text read. + nation.meta.tech = Math.fround(currentTech + delta); + if ((process.env.CORE_WAR_TECH_TRACE_NATION_IDS?.split(',') ?? []).includes(String(nation.id))) { + process.stdout.write( + `WAR_TECH_TRACE ${JSON.stringify({ engine: 'core', nationId: nation.id, side: context.side, currentTech, baseGain, gain, total, effective, divisor, delta, storedTech: nation.meta.tech, attackerGeneralId: context.attackerReport.id })}\n` + ); + } }; const resolveConquerNation = (city: City, attackerNationId: number, nations: Nation[]): number => { @@ -247,10 +255,22 @@ const resolveConquerCity = ( let collapseRewardGold = 0; let collapseRewardRice = 0; + const ruinedNpcJoinPlans: ConquerCityOutcome['ruinedNpcJoinPlans'] = []; // 국가 붕괴 시 자원 손실과 포상 정산. if (nationCollapsed && defenderNation) { - const defenderGenerals = generals.filter((general) => general.nationId === defenderNationId); + const defenderGenerals = generals + .filter((general) => general.nationId === defenderNationId) + .sort((lhs, rhs) => { + // deleteNation() reads the non-lord rows in primary-key order, + // then appends the lord object to the returned PHP array. + const lhsIsLord = lhs.id === defenderNation.chiefGeneralId; + const rhsIsLord = rhs.id === defenderNation.chiefGeneralId; + if (lhsIsLord !== rhsIsLord) { + return lhsIsLord ? 1 : -1; + } + return lhs.id - rhs.id; + }); let totalGoldLoss = 0; let totalRiceLoss = 0; @@ -290,6 +310,21 @@ const resolveConquerCity = ( ); pushLoggers([generalLogger], logs); affectedGenerals.add(general); + + if (config.joinMode !== 'onlyRandom') { + // Ref attempts to build/send a scout message after every loss. + // Message availability does not affect this draw. + rng.nextBool(0.5); + + const eligibleNpc = general.npcState >= 2 && general.npcState <= 8 && general.npcState !== 5; + if (eligibleNpc && rng.nextBool(config.joinRuinedNpcProbability ?? 0.1)) { + ruinedNpcJoinPlans.push({ + generalId: general.id, + destNationId: attackerNation.id, + joinTurn: rng.nextRangeInt(0, 12), + }); + } + } } collapseRewardGold = Math.floor((Math.max(0, defenderNation.gold - config.baseGold) + totalGoldLoss) / 2); @@ -426,6 +461,7 @@ const resolveConquerCity = ( nations: Array.from(affectedNations), cities: Array.from(affectedCities), generals: Array.from(affectedGenerals), + ruinedNpcJoinPlans, }; }; @@ -447,10 +483,8 @@ export const resolveWarAftermath = 0) { - const attackerCityDead = getDeadCounter(input.attackerCity) + totalDead * 0.4; - const defenderCityDead = getDeadCounter(input.defenderCity) + totalDead * 0.6; - setDeadCounter(input.attackerCity, attackerCityDead); - setDeadCounter(input.defenderCity, defenderCityDead); + increaseDeadCounter(input.attackerCity, totalDead * 0.4); + increaseDeadCounter(input.defenderCity, totalDead * 0.6); affectedCities.add(input.attackerCity); affectedCities.add(input.defenderCity); } diff --git a/packages/logic/src/war/engine.ts b/packages/logic/src/war/engine.ts index 64297e9c..22c2723c 100644 --- a/packages/logic/src/war/engine.ts +++ b/packages/logic/src/war/engine.ts @@ -1,6 +1,6 @@ import { JosaUtil, LiteHashDRBG, RandUtil } from '@sammo-ts/common'; -import type { City, General, GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js'; +import type { City, GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js'; import { compileCrewTypeCatalog, isCrewTypeWarActionRouter } from '@sammo-ts/logic/crewType/catalog.js'; import { ActionLogger } from '@sammo-ts/logic/logging/actionLogger.js'; import { LogFormat } from '@sammo-ts/logic/logging/types.js'; @@ -19,10 +19,7 @@ import type { import { getMetaNumber } from './utils.js'; import { WarUnitCity, WarUnitGeneral, type WarUnit } from './units.js'; -const META_FULL_LEADERSHIP = 'fullLeadership'; -const META_FULL_STRENGTH = 'fullStrength'; -const META_FULL_INTELLIGENCE = 'fullIntelligence'; -const META_DEFENCE_TRAIN = 'defenceTrain'; +const META_DEFENCE_TRAIN = 'defence_train'; const defaultLoggerFactory = (options: { generalId?: number; nationId?: number }): ActionLogger => new ActionLogger(options); @@ -101,18 +98,6 @@ const buildBattlePhaseTriggers = (unit: WarUnit, registry: WarTriggerRegistry): return caller; }; -const resolveFullStats = ( - general: General -): { - leadership: number; - strength: number; - intelligence: number; -} => ({ - leadership: getMetaNumber(general.meta, META_FULL_LEADERSHIP, general.stats.leadership), - strength: getMetaNumber(general.meta, META_FULL_STRENGTH, general.stats.strength), - intelligence: getMetaNumber(general.meta, META_FULL_INTELLIGENCE, general.stats.intelligence), -}); - const isSupplyCity = (city: City): boolean => { const supply = city.meta.supply; if (typeof supply === 'boolean') { @@ -153,9 +138,17 @@ export const computeBattleOrder = ( return 0; } - const realStat = general.stats.leadership + general.stats.strength + general.stats.intelligence; - const fullStats = resolveFullStats(general); - const fullStat = fullStats.leadership + fullStats.strength + fullStats.intelligence; + // Ref calls General::getLeadership/Strength/Intel() for every defender at + // battle time. That applies injury, stat cross-adjustment, officer/items/ + // traits and integer conversion through the defender's action pipeline. + const realStat = + defender.getComputedStat('leadership', general.stats.leadership) + + defender.getComputedStat('strength', general.stats.strength) + + defender.getComputedStat('intelligence', general.stats.intelligence); + const fullStat = + defender.getComputedStat('leadership', general.stats.leadership, { withInjury: false }) + + defender.getComputedStat('strength', general.stats.strength, { withInjury: false }) + + defender.getComputedStat('intelligence', general.stats.intelligence, { withInjury: false }); const totalStat = (realStat + fullStat) / 2; const totalCrew = (general.crew / 1_000_000) * Math.pow(general.train * general.atmos, 1.5); @@ -330,6 +323,12 @@ export const resolveWarBattle = ) => ({ + id: unit instanceof WarUnitGeneral ? unit.getGeneral().id : 0, + order: computeBattleOrder(unit, attackerUnit), + }); + const defenderOrderBeforeSort = defenderUnits.map(summarizeDefenderOrder); + defenderUnits.sort( (lhs, rhs) => computeBattleOrder(rhs, attackerUnit) - computeBattleOrder(lhs, attackerUnit) @@ -373,6 +372,10 @@ export const resolveWarBattle = []; + ruinedNpcJoinPlans: RuinedNpcJoinPlan[]; } export interface WarAftermathInput { diff --git a/packages/logic/src/war/units/general.ts b/packages/logic/src/war/units/general.ts index 43098cce..232b6604 100644 --- a/packages/logic/src/war/units/general.ts +++ b/packages/logic/src/war/units/general.ts @@ -322,7 +322,7 @@ export class WarUnitGeneral< } const atmosMultiplier = this.isAttacker() ? 1.1 : 1.05; - this.general.atmos = clamp(Math.round(this.general.atmos * atmosMultiplier), 0, this.config.maxAtmosByWar); + this.general.atmos = clamp(this.general.atmos * atmosMultiplier, 0, this.config.maxAtmosByWar); this.addStatExp(1); } @@ -384,7 +384,10 @@ export class WarUnitGeneral< nextExp *= 0.9; } const adjustedExp = this.actionPipeline.onCalcStat(this.getActionContext(), 'addDex', nextExp, { armType }); - this.general.meta[key] = base + adjustedExp; + // PHP interpolates floats into MeekroDB SQL with precision=14 before + // MariaDB rounds the integer dex column. Normalize this accumulated + // battle value at the same boundary (for example ...499999999996 -> .5). + this.general.meta[key] = Number((base + adjustedExp).toPrecision(14)); } public calcRiceConsumption(damage: number): number { @@ -529,5 +532,31 @@ export class WarUnitGeneral< this.general.rice = round(this.general.rice); this.general.experience = round(this.general.experience); this.general.dedication = round(this.general.dedication); + + // Ref WarUnitGeneral::finishBattle() runs General::checkStatChange() + // before persisting the participant. A battle can therefore consume + // one accumulated stat-exp threshold even though che_출병 itself does + // not have the generic command progression tail. + const limit = this.config.statUpgradeLimit ?? 30; + const maxStat = this.config.maxGeneralStat ?? 255; + const entries = [ + ['leadership', META_LEADERSHIP_EXP, '통솔'], + ['strength', META_STRENGTH_EXP, '무력'], + ['intelligence', META_INTEL_EXP, '지력'], + ] as const; + for (const [statKey, expKey, label] of entries) { + const statExp = getMetaNumber(this.general.meta, expKey); + if (statExp < 0) { + this.general.meta[expKey] = statExp + limit; + this.general.stats[statKey] -= 1; + this.logger.pushGeneralActionLog(`${label}이 1 떨어졌습니다!`, LogFormat.PLAIN); + } else if (statExp >= limit) { + if (this.general.stats[statKey] < maxStat) { + this.general.stats[statKey] += 1; + this.logger.pushGeneralActionLog(`${label}이 1 올랐습니다!`, LogFormat.PLAIN); + } + this.general.meta[expKey] = statExp - limit; + } + } } } diff --git a/packages/logic/src/war/utils.ts b/packages/logic/src/war/utils.ts index 23f8fbe5..32bd8766 100644 --- a/packages/logic/src/war/utils.ts +++ b/packages/logic/src/war/utils.ts @@ -11,7 +11,19 @@ export const clampMin = (value: number, min: number): number => (value < min ? m export const clampMax = (value: number, max: number): number => (value > max ? max : value); -export const round = (value: number): number => Math.round(value); +// PHP's round() compensates for small binary floating-point drift around a +// half boundary and rounds halves away from zero. War state is persisted to +// integer columns through legacy Util::round(), so Math.round() is not enough: +// e.g. accumulated siege damage can produce 4159.499999999999, which PHP +// rounds to 4160 while Math.round() returns 4159. +export const round = (value: number): number => { + if (!Number.isFinite(value)) { + return Math.round(value); + } + + const corrected = value + Math.sign(value) * Number.EPSILON * Math.max(1, Math.abs(value)); + return corrected < 0 ? Math.ceil(corrected - 0.5) : Math.floor(corrected + 0.5); +}; export const getMetaNumber = (meta: Record, key: string, fallback = 0): number => { const value = meta[key]; diff --git a/packages/logic/src/world/bootstrap.ts b/packages/logic/src/world/bootstrap.ts index c4d1a370..a0820ad3 100644 --- a/packages/logic/src/world/bootstrap.ts +++ b/packages/logic/src/world/bootstrap.ts @@ -8,6 +8,11 @@ import type { TriggerValue, } from '@sammo-ts/logic/domain/entities.js'; import type { ScenarioDefinition, ScenarioGeneral } from '@sammo-ts/logic/scenario/types.js'; +import { + isDomesticTraitKey, + isEventDomesticTraitKey, + isWarTraitKey, +} from '@sammo-ts/logic/actionModules/traits/index.js'; import type { CitySeed, GeneralSeed, @@ -37,6 +42,9 @@ export interface ScenarioBootstrapOptions { * remains deterministic in tests and reset operations. */ hiddenSeed?: string | number; + initialYear?: number; + initialMonth?: number; + turnTermMinutes?: number; } export type ScenarioBootstrapWarningCode = @@ -72,11 +80,70 @@ const DEFAULT_GENERAL_GOLD = 1000; const DEFAULT_GENERAL_RICE = 1000; const DEFAULT_CREWTYPE_ID = 1100; const DEFAULT_GENERAL_KILLTURN = 24; +const DEFAULT_AVAILABLE_PERSONALITIES = [ + 'che_안전', + 'che_유지', + 'che_재간', + 'che_출세', + 'che_할거', + 'che_정복', + 'che_패권', + 'che_의협', + 'che_대의', + 'che_왕좌', +]; const DEFAULT_CITY_TRUST = 50; const DEFAULT_CITY_TRADE = 100; const DEFAULT_CITY_SUPPLY_STATE = 1; const DEFAULT_CITY_FRONT_STATE = 0; +const canonicalizeDomesticTrait = (raw: string | null | undefined): string | null => { + if (!raw || raw === 'None') { + return null; + } + if (isDomesticTraitKey(raw) || isEventDomesticTraitKey(raw)) { + return raw; + } + const domesticKey = `che_${raw}`; + if (isDomesticTraitKey(domesticKey)) { + return domesticKey; + } + const eventKey = `che_event_${raw}`; + if (isEventDomesticTraitKey(eventKey)) { + return eventKey; + } + return null; +}; + +const canonicalizeWarTrait = (raw: string | null | undefined): string | null => { + if (!raw || raw === 'None') { + return null; + } + if (isWarTraitKey(raw)) { + return raw; + } + const warKey = `che_${raw}`; + return isWarTraitKey(warKey) ? warKey : null; +}; + +// Scenario rows expose one legacy speciality column. GeneralBuilder tries the +// domestic catalogue first, then the war catalogue, and persists the resolved +// class code. Preserve unknown values in the domestic slot so custom scenario +// packs retain their prior data even when their module is not installed here. +const resolveScenarioTraits = ( + special: string | null, + explicitWar: string | null | undefined +): { specialDomestic: string | null; specialWar: string | null } => { + const domestic = canonicalizeDomesticTrait(special); + const inferredWar = domestic === null ? canonicalizeWarTrait(special) : null; + const retainedDomestic = special && special !== 'None' ? special : null; + const retainedWar = explicitWar && explicitWar !== 'None' ? explicitWar : null; + return { + specialDomestic: domestic ?? (inferredWar === null ? retainedDomestic : null), + specialWar: canonicalizeWarTrait(explicitWar) ?? inferredWar ?? retainedWar, + }; +}; + const createScenarioMeta = (scenario: ScenarioDefinition): ScenarioMeta => ({ title: scenario.title, startYear: scenario.startYear, @@ -93,6 +160,82 @@ const createEmptyTriggerState = (): GeneralTriggerState => ({ meta: {}, }); +// GameConstBase::$defaultInitialEvents / $defaultEvents. Ref prepends these +// rows to every scenario unless ignoreDefaultEvents is enabled; scenario JSON +// only contains its additional rows. +const LEGACY_DEFAULT_INITIAL_EVENTS: unknown[] = [ + [true, ['NoticeToHistoryLog', '2년간 거병 및 건국이 가능합니다.', 6]], +]; + +const LEGACY_DEFAULT_EVENTS: unknown[] = [ + ['pre_month', 9_000, true, ['UpdateCitySupply'], ['ProcessWarIncome']], + [ + 'month', + 9_000, + ['Date', '==', null, 1], + ['MergeInheritPointRank'], + ['ProcessSemiAnnual', 'gold'], + ['ProcessIncome', 'gold'], + ['ResetOfficerLock'], + ['RaiseDisaster'], + ['RandomizeCityTradeRate'], + ['NewYear'], + ['AssignGeneralSpeciality'], + ], + ['month', 9_000, ['Date', '==', null, 4], ['ResetOfficerLock'], ['RaiseDisaster']], + [ + 'month', + 9_000, + ['Date', '==', null, 7], + ['MergeInheritPointRank'], + ['ProcessSemiAnnual', 'rice'], + ['ProcessIncome', 'rice'], + ['ResetOfficerLock'], + ['RaiseDisaster'], + ['RandomizeCityTradeRate'], + ], + ['month', 9_000, ['Date', '==', null, 10], ['ResetOfficerLock'], ['RaiseDisaster']], + [ + 'month', + 2_000, + ['DateRelative', '==', 1, 1], + ['NoticeToHistoryLog', '2년 뒤 출병 제한이 풀립니다.', 6], + ['DeleteEvent'], + ], + [ + 'month', + 2_000, + ['DateRelative', '==', 2, 1], + ['NoticeToHistoryLog', '1년 뒤 출병 제한이 풀립니다.', 6], + ['DeleteEvent'], + ], + [ + 'month', + 2_000, + ['DateRelative', '==', 2, 7], + ['NoticeToHistoryLog', '6개월 뒤 출병 제한이 풀립니다. 병력을 준비해주세요.', 6], + ['DeleteEvent'], + ], + [ + 'month', + 2_000, + ['DateRelative', '==', 3, 1], + ['NoticeToHistoryLog', '출병 제한이 풀렸습니다.', 6], + ['DeleteEvent'], + ], + [ + 'month', + 2_000, + ['DateRelative', '==', 4, 1], + ['NoticeToHistoryLog', '이제부터 하야, 망명시 패널티가 적용됩니다.', 6], + ['AddGlobalBetray', 1, 0], + ['AddGlobalBetray', 1, 1], + ['DeleteEvent'], + ], + ['month', 1_000, true, ['UpdateNationLevel'], ['ProvideNPCTroopLeader']], + ['united', 5_000, true, ['MergeInheritPointRank']], +]; + const addTriggerMeta = ( meta: Record, key: string, @@ -194,6 +337,13 @@ const resolveAge = (startYear: number | null, birthYear: number): number => { const buildSpecialityAge = (retirementYear: number, age: number, divisor: number): number => Math.max(Math.round((retirementYear - age) / divisor), 3) + age; +const resolveBootstrapSpecialityAge = ( + scenarioStartYear: number | null, + birthYear: number, + retirementYear: number, + divisor: number +): number => buildSpecialityAge(retirementYear, resolveAge(scenarioStartYear, birthYear), divisor); + const ADULT_GENERAL_AGE = 14; type GeneralBootstrapDisposition = 'active' | 'delayed' | 'expired'; @@ -313,7 +463,8 @@ const buildGeneralSeeds = ( defaultCrewTypeId: number, mapCities: MapDefinition['cities'], nationCityIds: Map, - placementRng: RandUtil, + installRng: RandUtil, + initializedValues: Map, options?: ScenarioBootstrapOptions ): { seeds: GeneralSeed[]; @@ -340,47 +491,49 @@ const buildGeneralSeeds = ( const ownedCityIds = nationId > 0 ? (nationCityIds.get(nationId) ?? []) : []; const candidateCityIds = ownedCityIds.length > 0 ? ownedCityIds : mapCities.map((city) => city.id); if (candidateCityIds.length > 0) { - cityId = placementRng.choice(candidateCityIds); + cityId = installRng.choice(candidateCityIds); } } const birthYear = resolveBirthYear(row.birthYear, scenario.startYear); const deathYear = resolveDeathYear(row.deathYear, birthYear, scenario.startYear); - const deathMonth = resolveScenarioGeneralDeathMonth({ - scenarioTitle: scenario.title, - startYear: scenario.startYear, - contextLabel, - generalId: id, - generalName: row.name, - deathYear, - }); + const turnTermMinutes = Math.max(1, Math.floor(options?.turnTermMinutes ?? 60)); + const initialTurnOffsetMicros = + installRng.nextRangeInt(0, turnTermMinutes * 60 - 1) * 1_000_000 + installRng.nextRangeInt(0, 999_999); + const deathMonth = installRng.nextRangeInt(1, 12); const officerLevel = resolveOfficerLevel(row.officerLevel, nationId); - const age = resolveAge(scenario.startYear, birthYear); + const initialYear = options?.initialYear ?? scenario.startYear; + const initialMonth = options?.initialMonth ?? 1; + const age = resolveAge(initialYear, birthYear); + const initialized = initializedValues.get(row); + if (!initialized) { + throw new Error(`Missing legacy initialization values for general ${row.name}.`); + } const stats = { leadership: row.leadership, strength: row.strength, intelligence: row.intelligence, }; + const { specialDomestic, specialWar } = resolveScenarioTraits(row.special, row.specialWar); const seedMeta: Record = { source: contextLabel, deathMonth, - specage: buildSpecialityAge(retirementYear, age, 12), - specage2: buildSpecialityAge(retirementYear, age, 6), + initialTurnOffsetMicros, + // Ref's GeneralBuilder derives speciality ages from the scenario + // opening year even when installation stores a pre-opening age. + specage: resolveBootstrapSpecialityAge(scenario.startYear, birthYear, retirementYear, 12), + specage2: resolveBootstrapSpecialityAge(scenario.startYear, birthYear, retirementYear, 6), }; - if (row.affinity !== null) { - seedMeta.affinity = row.affinity; - } - if (row.personality !== null) { - seedMeta.personality = row.personality; - } - if (row.special !== null) { - seedMeta.special = row.special; + seedMeta.affinity = initialized.affinity; + seedMeta.personality = initialized.personality; + if (specialDomestic !== null) { + seedMeta.special = specialDomestic; } if (row.picture !== null) { seedMeta.picture = row.picture; } - if (row.specialWar !== null && row.specialWar !== undefined) { - seedMeta.specialWar = row.specialWar; + if (specialWar !== null) { + seedMeta.specialWar = specialWar; } if (row.horse !== null && row.horse !== undefined) { seedMeta.horse = row.horse; @@ -407,10 +560,10 @@ const buildGeneralSeeds = ( officerLevel, birthYear, deathYear, - affinity: row.affinity, - personality: row.personality, - special: row.special, - specialWar: row.specialWar ?? null, + affinity: initialized.affinity, + personality: initialized.personality, + special: specialDomestic, + specialWar, horse: row.horse ?? null, weapon: row.weapon ?? null, book: row.book ?? null, @@ -419,14 +572,16 @@ const buildGeneralSeeds = ( npcType, text: row.text, crewTypeId: defaultCrewTypeId, + experience: age * 100, + dedication: age * 100, meta: seedMeta, }; seeds.push(seed); const generalMeta: GeneralMeta = { killturn: resolveKillturnFromDeathYear( - scenario.startYear, - 1, + initialYear, + initialMonth, deathYear, deathMonth, DEFAULT_GENERAL_KILLTURN @@ -434,14 +589,14 @@ const buildGeneralSeeds = ( deathMonth, npcType, crewTypeId: defaultCrewTypeId, - specage: buildSpecialityAge(retirementYear, age, 12), - specage2: buildSpecialityAge(retirementYear, age, 6), + specage: resolveBootstrapSpecialityAge(scenario.startYear, birthYear, retirementYear, 12), + specage2: resolveBootstrapSpecialityAge(scenario.startYear, birthYear, retirementYear, 6), }; - addTriggerMeta(generalMeta, 'affinity', row.affinity); - addTriggerMeta(generalMeta, 'personality', row.personality ?? undefined); - addTriggerMeta(generalMeta, 'special', row.special ?? undefined); + addTriggerMeta(generalMeta, 'affinity', initialized.affinity); + addTriggerMeta(generalMeta, 'personality', initialized.personality); + addTriggerMeta(generalMeta, 'special', specialDomestic ?? undefined); addTriggerMeta(generalMeta, 'picture', row.picture ?? undefined); - addTriggerMeta(generalMeta, 'specialWar', row.specialWar ?? undefined); + addTriggerMeta(generalMeta, 'specialWar', specialWar ?? undefined); addTriggerMeta(generalMeta, 'horse', row.horse ?? undefined); addTriggerMeta(generalMeta, 'weapon', row.weapon ?? undefined); addTriggerMeta(generalMeta, 'book', row.book ?? undefined); @@ -456,13 +611,13 @@ const buildGeneralSeeds = ( cityId, troopId: 0, stats, - experience: 0, - dedication: 0, + experience: age * 100, + dedication: age * 100, officerLevel, role: { - personality: row.personality, - specialDomestic: row.special, - specialWar: row.specialWar ?? null, + personality: initialized.personality, + specialDomestic, + specialWar, items: { horse: row.horse ?? null, weapon: row.weapon ?? null, @@ -632,8 +787,8 @@ export const buildScenarioBootstrap = (input: ScenarioBootstrapInput): ScenarioB } const mapDefaults = resolveMapDefaults(map, options); - const placementRng = new RandUtil( - new LiteHashDRBG(simpleSerialize(options?.hiddenSeed ?? scenario.title, 'InitScenarioGeneralCities')) + const installRng = new RandUtil( + new LiteHashDRBG(simpleSerialize(options?.hiddenSeed ?? scenario.title, 'InitScenario')) ); const defaultCrewTypeId = unitSet?.defaultCrewTypeId ?? options?.defaultCrewTypeId ?? DEFAULT_CREWTYPE_ID; const seedCities: CitySeed[] = []; @@ -714,6 +869,19 @@ export const buildScenarioBootstrap = (input: ScenarioBootstrapInput): ScenarioB const allGeneralSeeds: GeneralSeed[] = []; const allGenerals: General[] = []; const delayedActionsByBirthYear = new Map(); + const initializedValues = new Map(); + const rawAvailablePersonalities = scenario.config.const.availablePersonality; + const availablePersonalities = Array.isArray(rawAvailablePersonalities) + ? rawAvailablePersonalities.filter((value): value is string => typeof value === 'string') + : DEFAULT_AVAILABLE_PERSONALITIES; + const personalityPool = + availablePersonalities.length > 0 ? availablePersonalities : DEFAULT_AVAILABLE_PERSONALITIES; + for (const row of [...scenario.generals, ...scenario.generalsEx, ...scenario.generalsNeutral]) { + const affinity = row.affinity !== null && row.affinity > 0 ? row.affinity : installRng.nextRangeInt(1, 150); + const rawPersonality = row.personality ?? installRng.choice(personalityPool); + const personality = rawPersonality.includes('_') ? rawPersonality : `che_${rawPersonality}`; + initializedValues.set(row, { affinity, personality }); + } const partitionGenerals = (rows: ScenarioGeneral[], npcType: 2 | 6): ScenarioGeneral[] => { const active: ScenarioGeneral[] = []; @@ -750,7 +918,8 @@ export const buildScenarioBootstrap = (input: ScenarioBootstrapInput): ScenarioB defaultCrewTypeId, map.cities, nationCityIds, - placementRng, + installRng, + initializedValues, options ); allGeneralSeeds.push(...generalResult.seeds); @@ -769,7 +938,8 @@ export const buildScenarioBootstrap = (input: ScenarioBootstrapInput): ScenarioB defaultCrewTypeId, map.cities, nationCityIds, - placementRng, + installRng, + initializedValues, options ); allGeneralSeeds.push(...generalExResult.seeds); @@ -788,7 +958,8 @@ export const buildScenarioBootstrap = (input: ScenarioBootstrapInput): ScenarioB defaultCrewTypeId, map.cities, nationCityIds, - placementRng, + installRng, + initializedValues, options ); allGeneralSeeds.push(...generalNeutralResult.seeds); @@ -801,7 +972,9 @@ export const buildScenarioBootstrap = (input: ScenarioBootstrapInput): ScenarioB ...actions, ['DeleteEvent'], ]); - const events = [...scenario.events, ...delayedGeneralEvents]; + const defaultEvents = scenario.ignoreDefaultEvents ? [] : LEGACY_DEFAULT_EVENTS; + const defaultInitialEvents = scenario.ignoreDefaultEvents ? [] : LEGACY_DEFAULT_INITIAL_EVENTS; + const events = [...defaultEvents, ...scenario.events, ...delayedGeneralEvents]; const seed: WorldSeedPayload = { scenarioConfig: scenario.config, @@ -814,7 +987,7 @@ export const buildScenarioBootstrap = (input: ScenarioBootstrapInput): ScenarioB troops: [], diplomacy: scenario.diplomacy, events, - initialEvents: scenario.initialEvents, + initialEvents: [...defaultInitialEvents, ...scenario.initialEvents], }; const snapshot: WorldSnapshot = { @@ -828,7 +1001,7 @@ export const buildScenarioBootstrap = (input: ScenarioBootstrapInput): ScenarioB troops: [], diplomacy: scenario.diplomacy, events, - initialEvents: scenario.initialEvents, + initialEvents: [...defaultInitialEvents, ...scenario.initialEvents], }; return { snapshot, seed, warnings }; diff --git a/packages/logic/src/world/distance.ts b/packages/logic/src/world/distance.ts index 85600f9d..2f25a386 100644 --- a/packages/logic/src/world/distance.ts +++ b/packages/logic/src/world/distance.ts @@ -27,16 +27,20 @@ export const getCityDistance = (map: MapDefinition, startCityId: number, endCity return Infinity; }; -export const searchDistance = (map: MapDefinition, startCityId: number, range: number): Record => { - const result: Record = {}; +export const searchDistanceEntries = ( + map: MapDefinition, + startCityId: number, + range: number +): Array<[cityId: number, distance: number]> => { + const result: Array<[number, number]> = []; const visited = new Set(); const queue: [number, number][] = [[startCityId, 0]]; visited.add(startCityId); - result[startCityId] = 0; while (queue.length > 0) { const [currentId, dist] = queue.shift()!; + result.push([currentId, dist]); if (dist >= range) continue; @@ -47,7 +51,6 @@ export const searchDistance = (map: MapDefinition, startCityId: number, range: n if (!visited.has(neighborId)) { visited.add(neighborId); const newDist = dist + 1; - result[neighborId] = newDist; queue.push([neighborId, newDist]); } } @@ -55,3 +58,6 @@ export const searchDistance = (map: MapDefinition, startCityId: number, range: n return result; }; + +export const searchDistance = (map: MapDefinition, startCityId: number, range: number): Record => + Object.fromEntries(searchDistanceEntries(map, startCityId, range)); diff --git a/packages/logic/src/world/types.ts b/packages/logic/src/world/types.ts index e460c458..0426499e 100644 --- a/packages/logic/src/world/types.ts +++ b/packages/logic/src/world/types.ts @@ -158,6 +158,8 @@ export interface GeneralSeed { npcType: number; text: string | null; crewTypeId: number; + experience?: number; + dedication?: number; meta: Record; } diff --git a/packages/logic/test/crewTypeExecution.test.ts b/packages/logic/test/crewTypeExecution.test.ts index c2489748..5e531004 100644 --- a/packages/logic/test/crewTypeExecution.test.ts +++ b/packages/logic/test/crewTypeExecution.test.ts @@ -233,6 +233,43 @@ describe('crew type catalog', () => { expect(computeBattleOrder(defender, attacker)).toBe(10000); }); + + it('uses live injured and full action-adjusted stats for defender order', () => { + const footman = crewType(1100, 1, '보병'); + const rng = new RandUtil(new ConstantRNG(0)); + const attacker = buildGeneralUnit(rng, buildGeneral(1, footman.id), footman, true); + const defenderGeneral = { ...buildGeneral(2, footman.id), injury: 50 }; + const defender = buildGeneralUnit( + rng, + defenderGeneral, + footman, + false, + new WarActionPipeline([ + { + onCalcStat: (_context, statName, value) => + statName === 'leadership' && typeof value === 'number' ? value + 40 : value, + }, + ]) + ); + + expect(computeBattleOrder(defender, attacker)).toBe(260); + }); + + it('excludes defenders below the legacy defence training threshold', () => { + const footman = crewType(1100, 1, '보병'); + const rng = new RandUtil(new ConstantRNG(0)); + const attacker = buildGeneralUnit(rng, buildGeneral(1, footman.id), footman, true); + const baseDefender = buildGeneral(2, footman.id); + const defenderGeneral = { + ...baseDefender, + train: 79, + atmos: 80, + meta: { ...baseDefender.meta, defence_train: 80 }, + }; + const defender = buildGeneralUnit(rng, defenderGeneral, footman, false); + + expect(computeBattleOrder(defender, attacker)).toBe(0); + }); }); describe('crew type war triggers', () => { diff --git a/packages/logic/test/disbandAction.test.ts b/packages/logic/test/disbandAction.test.ts new file mode 100644 index 00000000..3fb59301 --- /dev/null +++ b/packages/logic/test/disbandAction.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest'; + +import { createRefOrderedActionStack } from '../src/actionModules/bundle.js'; +import type { GeneralActionModule } from '../src/actionModules/general.js'; +import { ActionDefinition } from '../src/actions/turn/general/che_소집해제.js'; + +describe('che_소집해제', () => { + it('applies legacy experience and dedication stat hooks', () => { + const personality = { + eventHandlers: {}, + onCalcStat: (_context, statName, value) => { + if (statName === 'experience') return Number(value) * 1.1; + if (statName === 'dedication') return Number(value) * 0.9; + return value; + }, + } satisfies GeneralActionModule; + const noOp = {}; + const definition = new ActionDefinition({ + generalActionModules: createRefOrderedActionStack({ + nation: noOp, + officer: noOp, + domestic: noOp, + war: noOp, + personality, + crewType: null, + inheritance: noOp, + scenario: null, + items: [], + }), + } as never); + const general = { crew: 500, experience: 1_000, dedication: 2_000 }; + const city = { population: 10_000 }; + + definition.resolve( + { + general, + city, + addLog: () => undefined, + } as never, + {} + ); + + expect(general.experience).toBe(1_077); + expect(general.dedication).toBe(2_090); + }); +}); diff --git a/packages/logic/test/dispatchWarAction.test.ts b/packages/logic/test/dispatchWarAction.test.ts index c92377c2..d2fe2593 100644 --- a/packages/logic/test/dispatchWarAction.test.ts +++ b/packages/logic/test/dispatchWarAction.test.ts @@ -181,8 +181,12 @@ describe('che_출병', () => { const defenderNation = buildNation(2); const attackerCity = buildCity(1, attackerNation.id); const defenderCity = buildCity(2, defenderNation.id); + const neutralCity = buildCity(3, 0); const attacker = buildGeneral(1, attackerNation.id, attackerCity.id); const defender = buildGeneral(2, defenderNation.id, defenderCity.id); + defender.crew = 0; + defenderCity.defence = 0; + defenderCity.wall = 0; const definition = new ActionDefinition(); const context: Omit = { @@ -192,10 +196,23 @@ describe('che_출병', () => { rng, destCity: defenderCity, destNation: defenderNation, - cities: [attackerCity, defenderCity], + cities: [attackerCity, defenderCity, neutralCity], nations: [attackerNation, defenderNation], generals: [attacker, defender], unitSet, + map: { + id: 'test-map', + name: 'test-map', + cities: [ + { id: 1, name: 'City1', level: 2, region: 1, position: { x: 0, y: 0 }, connections: [2, 3], max: { population: 1, agriculture: 1, commerce: 1, security: 1, defence: 1, wall: 1 }, initial: { population: 1, agriculture: 1, commerce: 1, security: 1, defence: 1, wall: 1 } }, + { id: 2, name: 'City2', level: 2, region: 1, position: { x: 1, y: 0 }, connections: [1], max: { population: 1, agriculture: 1, commerce: 1, security: 1, defence: 1, wall: 1 }, initial: { population: 1, agriculture: 1, commerce: 1, security: 1, defence: 1, wall: 1 } }, + { id: 3, name: 'City3', level: 2, region: 1, position: { x: 0, y: 1 }, connections: [1], max: { population: 1, agriculture: 1, commerce: 1, security: 1, defence: 1, wall: 1 }, initial: { population: 1, agriculture: 1, commerce: 1, security: 1, defence: 1, wall: 1 } }, + ], + }, + diplomacy: [ + { fromNationId: attackerNation.id, toNationId: defenderNation.id, state: 0, term: 0 }, + { fromNationId: defenderNation.id, toNationId: attackerNation.id, state: 0, term: 0 }, + ], time: { year: 200, month: 1, @@ -220,6 +237,9 @@ describe('che_출병', () => { expect(resolution.logs.length).toBeGreaterThan(0); expect(resolution.patches?.generals.some((patch) => patch.id === defender.id)).toBe(true); expect(resolution.patches?.cities.some((patch) => patch.id === defenderCity.id)).toBe(true); + expect({ city: resolution.city, patches: resolution.patches?.cities }).toMatchObject({ + city: { frontState: 2 }, + }); expect( resolution.effects.some( (effect) => @@ -229,4 +249,77 @@ describe('che_출병', () => { ) ).toBe(true); }); + + it('prefers an enemy on the shortest route layer before considering the next layer', () => { + const attackerNation = buildNation(1); + const attackerCity = buildCity(1, attackerNation.id); + const targetCity = buildCity(2, 0); + const alternateCity = buildCity(3, 0); + targetCity.defence = 0; + targetCity.wall = 0; + alternateCity.defence = 0; + alternateCity.wall = 0; + const attacker = buildGeneral(1, attackerNation.id, attackerCity.id); + const pickLastRng = { + ...rng, + nextInt: (_minInclusive: number, maxExclusive: number) => maxExclusive - 1, + }; + const definition = new ActionDefinition(); + const mapCity = (id: number, connections: number[]) => ({ + id, + name: `City${id}`, + level: 2, + region: 1, + position: { x: id, y: 0 }, + connections, + max: { + population: 1, + agriculture: 1, + commerce: 1, + security: 1, + defence: 1, + wall: 1, + }, + initial: { + population: 1, + agriculture: 1, + commerce: 1, + security: 1, + defence: 1, + wall: 1, + }, + }); + const context: Omit = { + general: attacker, + city: attackerCity, + nation: attackerNation, + rng: pickLastRng, + destCity: targetCity, + destNation: null, + cities: [attackerCity, targetCity, alternateCity], + nations: [attackerNation], + generals: [attacker], + unitSet, + map: { + id: 'triangle', + name: 'triangle', + cities: [mapCity(1, [2, 3]), mapCity(2, [1, 3]), mapCity(3, [1, 2])], + }, + diplomacy: [], + time: { year: 200, month: 1, startYear: 180 }, + seedBase: 'route-layer-seed', + warConfig, + aftermathConfig, + }; + + const resolution = resolveGeneralAction( + definition, + context, + { now: new Date('2000-01-01T00:00:00Z'), schedule }, + { destCityId: targetCity.id } + ); + + expect(resolution.patches?.cities.some((patch) => patch.id === targetCity.id)).toBe(true); + expect(resolution.patches?.cities.some((patch) => patch.id === alternateCity.id)).toBe(false); + }); }); diff --git a/packages/logic/test/scenarios/blankStart.test.ts b/packages/logic/test/scenarios/blankStart.test.ts index f8052ca5..92128b98 100644 --- a/packages/logic/test/scenarios/blankStart.test.ts +++ b/packages/logic/test/scenarios/blankStart.test.ts @@ -191,6 +191,10 @@ describe('Blank Start Scenario', () => { const newNation = world.getNation(newNationId)!; expect(newNation.chiefGeneralId).toBe(gen0.id); expect(newNation.level).toBe(0); // Wandering Nation + // Ref che_거병 does not install a nation-specific NPC policy. Leaving + // this key absent preserves AutorunNationPolicy's 50,000 population + // floor instead of silently overriding it with zero. + expect(newNation.meta).not.toHaveProperty('npc_nation_policy'); // --- Step 2: Gen 1 performs Appointment --- // Before appointment, Gen 0 should FAIL Founding because general count = 1 diff --git a/packages/logic/test/scenarios/general/che_귀환.test.ts b/packages/logic/test/scenarios/general/che_귀환.test.ts index 98193757..2ae63472 100644 --- a/packages/logic/test/scenarios/general/che_귀환.test.ts +++ b/packages/logic/test/scenarios/general/che_귀환.test.ts @@ -6,6 +6,8 @@ import type { MapDefinition } from '../../../src/world/types.js'; import type { TurnCommandEnv } from '../../../src/actions/turn/commandEnv.js'; import type { ConstraintContext, RequirementKey, StateView } from '../../../src/constraints/types.js'; import { evaluateActionConstraints } from '../../../src/constraints/evaluate.js'; +import { createRefOrderedActionStack } from '../../../src/actionModules/bundle.js'; +import type { GeneralActionModule } from '../../../src/actionModules/general.js'; const MOCK_SCENARIO_BASE = { title: 'Test', @@ -146,7 +148,7 @@ function createViewState(world: InMemoryWorld, year: number = 200, env: TurnComm } describe('che_귀환', () => { - it('should return to capital if normal officer', async () => { + it('should return to capital and apply legacy experience modifiers', async () => { const bootstrapResult = buildScenarioBootstrap({ scenario: MOCK_SCENARIO_BASE, map: LINEAR_MAP, @@ -203,7 +205,23 @@ describe('che_귀환', () => { generalId: general.id, commandKey: 'che_귀환', resolver: (await import('../../../src/actions/turn/general/che_귀환.js')).commandSpec.createDefinition( - {} as any + { + ...systemEnv, + generalActionModules: createRefOrderedActionStack({ + nation: { + onCalcStat: (_context, statName, value) => + statName === 'experience' && typeof value === 'number' ? value * 1.1 : value, + }, + officer: {}, + domestic: {}, + war: {}, + personality: {}, + crewType: null, + inheritance: {}, + scenario: null, + items: [], + }), + } ), args: {}, }, @@ -211,7 +229,7 @@ describe('che_귀환', () => { const updated = world.getGeneral(general.id); expect(updated?.cityId).toBe(101); // Capital - expect(updated?.experience).toBe(70); + expect(updated?.experience).toBe(77); expect(updated?.dedication).toBe(100); expect(updated?.meta.leadership_exp).toBe(1); diff --git a/packages/logic/test/scenarios/general_commands_new.test.ts b/packages/logic/test/scenarios/general_commands_new.test.ts index cb411dc4..467c8052 100644 --- a/packages/logic/test/scenarios/general_commands_new.test.ts +++ b/packages/logic/test/scenarios/general_commands_new.test.ts @@ -3,7 +3,10 @@ import { MINIMAL_MAP } from '../fixtures/minimalMap.js'; import { InMemoryWorld, TestGameRunner } from '../testEnv.js'; import type { City, General, Nation } from '../../src/domain/entities.js'; import type { WorldSnapshot } from '../../src/world/types.js'; -import { commandSpec as procureSpec } from '../../src/actions/turn/general/che_물자조달.js'; +import { + commandSpec as procureSpec, + roundLegacyAccumulatedInteger, +} from '../../src/actions/turn/general/che_물자조달.js'; import { commandSpec as donateSpec } from '../../src/actions/turn/general/che_헌납.js'; import { commandSpec as moveSpec } from '../../src/actions/turn/general/che_이동.js'; import { commandSpec as wanderSpec } from '../../src/actions/turn/general/che_방랑.js'; @@ -24,8 +27,65 @@ import { loadItemModules, } from '../../src/items/index.js'; import { createRefOrderedActionStack } from '../../src/actionModules/bundle.js'; +import type { GeneralActionModule } from '../../src/actionModules/general.js'; +import { + normalizeLegacyGeneratedDex, + resolveLegacySpecialityAge, +} from '../../src/actions/turn/general/che_인재탐색.js'; +import { + addLegacyStoredTech, + readLegacyStoredTech, + toLegacyStoredTech, +} from '../../src/actions/turn/general/che_기술연구.js'; +import { + readLegacyCityTrust, + storeLegacyCityTrust, +} from '../../src/actions/turn/general/legacyCityTrust.js'; +import { roundLegacyRecruitCost } from '../../src/actions/turn/general/che_징병.js'; describe('General Commands New Scenario', () => { + it('truncates generated NPC dex like GeneralBuilder integer arguments', () => { + expect(normalizeLegacyGeneratedDex([36.5, 7.9, 7.1, 7.99, 0.75])).toEqual([36, 7, 7, 7, 0]); + }); + + it('rounds the accumulated procurement experience like a MariaDB INT assignment', () => { + const delta = (45 * 0.7) / 3; + expect(delta).toBe(10.499999999999998); + expect(Math.round(delta)).toBe(10); + expect(roundLegacyAccumulatedInteger(4554, delta)).toBe(4565); + }); + + it('persists generated NPC speciality ages from the legacy creation date', () => { + expect(resolveLegacySpecialityAge(80, 22, 12)).toBe(27); + expect(resolveLegacySpecialityAge(80, 22, 6)).toBe(32); + expect(resolveLegacySpecialityAge(80, 24, 12)).toBe(29); + }); + + it('stores technology as binary32 without per-update decimal quantization', () => { + const value = 433.51797; + expect(toLegacyStoredTech(value)).toBe(Math.fround(value)); + expect(toLegacyStoredTech(value)).not.toBe(Number(Math.fround(value).toPrecision(6))); + expect(readLegacyStoredTech(624.0966796875)).toBe(624.097); + expect(addLegacyStoredTech(624.0966796875, 22.9)).toBe(Math.fround(624.097 + 22.9)); + }); + + it('separates MariaDB FLOAT trust storage from its six-digit PHP read value', () => { + const stored = storeLegacyCityTrust(88.306755); + + expect(stored).toBe(Math.fround(88.306755)); + expect(stored).not.toBe(readLegacyCityTrust(stored)); + expect(readLegacyCityTrust(stored)).toBe(88.3068); + expect(readLegacyCityTrust(storeLegacyCityTrust(readLegacyCityTrust(stored) + 10))).toBe(98.3068); + }); + + it('rounds recruitment cost across the PHP half boundary', () => { + const cavalryCost = (11 * 1.15 * 7000) / 100; + + expect(cavalryCost).toBe(885.4999999999999); + expect(Math.round(cavalryCost)).toBe(885); + expect(roundLegacyRecruitCost(cavalryCost)).toBe(886); + }); + // 1. Setup Environment const systemEnv: TurnCommandEnv = { develCost: 100, @@ -168,7 +228,26 @@ describe('General Commands New Scenario', () => { const runner = new TestGameRunner(world, 200, 1); // 1. Procure - const procureDef = procureSpec.createDefinition(systemEnv); + const procureDef = procureSpec.createDefinition({ + ...systemEnv, + generalActionModules: (() => { + const noOp = {}; + return createRefOrderedActionStack({ + nation: noOp, + officer: noOp, + domestic: noOp, + war: noOp, + personality: { + eventHandlers: {}, + onCalcStat: (_context, statName, value) => (statName === 'experience' ? value * 1.1 : value), + } satisfies GeneralActionModule, + crewType: null, + inheritance: noOp, + scenario: null, + items: [], + }); + })(), + }); await runner.runTurn([ { generalId: 1, @@ -191,8 +270,12 @@ describe('General Commands New Scenario', () => { ]); const n1_after_procure = world.getNation(1)!; + const g1_after_procure = world.getGeneral(1)!; // Nation gains gold expect(n1_after_procure.gold).toBeGreaterThan(10000); + // Ref's addExperience/addDedication route rewards through onCalcStat. + expect(g1_after_procure.experience).toBe(183); + expect(g1_after_procure.dedication).toBe(208); // 2. Donate const donateDef = donateSpec.createDefinition(systemEnv); diff --git a/packages/logic/test/scenarios/troops.test.ts b/packages/logic/test/scenarios/troops.test.ts index 970e9566..6f85ec4c 100644 --- a/packages/logic/test/scenarios/troops.test.ts +++ b/packages/logic/test/scenarios/troops.test.ts @@ -7,8 +7,18 @@ import { commandSpec as recruitSpec } from '../../src/actions/turn/general/che_ import { commandSpec as trainSpec } from '../../src/actions/turn/general/che_훈련.js'; import { commandSpec as atmosSpec } from '../../src/actions/turn/general/che_사기진작.js'; import type { TurnCommandEnv } from '../../src/actions/turn/commandEnv.js'; +import { createRefOrderedActionStack } from '../../src/actionModules/bundle.js'; +import type { GeneralActionModule } from '../../src/actionModules/general.js'; +import { applyLegacyInjury, finalizeLegacyStat } from '../../src/actions/turn/general/legacyGeneralStat.js'; describe('Troop Management Scenario', () => { + it('applies injury before action stat modifiers and floors like Ref General::getLeadership()', () => { + const injured = applyLegacyInjury(62, 3); + expect(injured).toBeCloseTo(60.14, 12); + expect(finalizeLegacyStat(injured * 2)).toBe(120); + expect(Math.round(((120 * 100) / 6076) * 30)).toBe(59); + }); + it('should successfully draft troops, then train and boost morale', async () => { // 1. Setup World const mockNation: Nation = { @@ -130,6 +140,29 @@ describe('Troop Management Scenario', () => { baseGold: 1000, baseRice: 1000, maxResourceActionAmount: 1000, + ...(snapshot.unitSet ? { unitSet: snapshot.unitSet } : {}), + generalActionModules: (() => { + const noOp = {}; + return createRefOrderedActionStack({ + nation: noOp, + officer: noOp, + domestic: noOp, + war: noOp, + personality: { + eventHandlers: {}, + onCalcStat: (_context, statName, value) => { + if (typeof value !== 'number') return value; + if (statName === 'experience') return value * 0.9; + if (statName === 'addDex') return value * 0.5; + return value; + }, + } satisfies GeneralActionModule, + crewType: null, + inheritance: noOp, + scenario: null, + items: [], + }); + })(), }; // 2. Draft Troops @@ -145,8 +178,20 @@ describe('Troop Management Scenario', () => { const generalAfterDraft = world.getGeneral(1)!; expect(generalAfterDraft.crew).toBe(1000); + // Ref's General::addExperience() applies personality/item stat modules + // after rounding the crew-based base reward. Dedication is routed + // through the same pipeline but remains unchanged in this fixture. + expect(generalAfterDraft.experience).toBe(109); + expect(generalAfterDraft.dedication).toBe(110); // General::addDex(): 보병(armType 1)은 징병 인원 / 100만큼 숙련도가 오른다. - expect(generalAfterDraft.meta.dex1).toBe(10); + expect(generalAfterDraft.meta.dex1).toBe(5); + // Ref che_징병 stores aux.armType and its AI keeps that category on + // later recruitment decisions. + expect(generalAfterDraft.meta.armType).toBe(1); + // 훈련/사기진작의 실제 증가분과 addDex 파이프라인을 관찰할 여유를 만든다. + world.snapshot.generals = world.snapshot.generals.map((general) => + general.id === generalAfterDraft.id ? { ...general, train: 80, atmos: 80 } : general + ); // 3. Train const trainDef = trainSpec.createDefinition(systemEnv); @@ -162,6 +207,10 @@ describe('Troop Management Scenario', () => { const generalAfterTrain = world.getGeneral(1)!; // 레거시 훈련식: round(통솔 * 100 * trainDelta / 병력), 상한까지 적용. expect(generalAfterTrain.train).toBe(100); + // General::addExperience()/addDedication()/addDex()와 동일하게 모듈 보정을 거친다. + expect(generalAfterTrain.experience).toBe(199); + expect(generalAfterTrain.dedication).toBe(180); + expect(generalAfterTrain.meta.dex1).toBe(15); // 4. Boost Morale const atmosDef = atmosSpec.createDefinition(systemEnv); @@ -177,5 +226,8 @@ describe('Troop Management Scenario', () => { const generalAfterAtmos = world.getGeneral(1)!; // 레거시 사기진작식: round(통솔 * 100 / 병력 * atmosDelta), 명령 상한까지 적용. expect(generalAfterAtmos.atmos).toBe(100); + expect(generalAfterAtmos.experience).toBe(289); + expect(generalAfterAtmos.dedication).toBe(250); + expect(generalAfterAtmos.meta.dex1).toBe(25); }); }); diff --git a/packages/logic/test/specialActions.test.ts b/packages/logic/test/specialActions.test.ts index 23c5c360..635e47c8 100644 --- a/packages/logic/test/specialActions.test.ts +++ b/packages/logic/test/specialActions.test.ts @@ -10,6 +10,7 @@ import { createGeneralTriggerContext } from '../src/triggers/general.js'; import { createTraitCatalog, createTraitModules, + loadEventDomesticTraitModules, loadDomesticTraitModules, loadWarTraitModules, } from '../src/actionModules/traits/index.js'; @@ -251,6 +252,40 @@ describe('trait modules', () => { expect(general.triggerState.flags['pre.치료']).toBe(true); }); + it('assigns event-의술 healing draws in legacy general-id order', async () => { + const eventDomestic = await loadEventDomesticTraitModules(['che_event_의술']); + const registry = createTraitCatalog({ domestic: eventDomestic }); + const traitModules = createTraitModules(registry); + const pipeline = new GeneralActionPipeline(traitModules.general); + const healer = buildGeneral({ + id: 818, + role: { + personality: null, + specialDomestic: 'che_event_의술', + specialWar: null, + items: { horse: null, weapon: null, book: null, item: null }, + }, + }); + const lowerIdPatient = buildGeneral({ id: 444, name: 'Lower', injury: 12 }); + const higherIdPatient = buildGeneral({ id: 775, name: 'Higher', injury: 30 }); + const worldView = { + listGeneralsByCity: (_cityId: number) => [healer, higherIdPatient, lowerIdPatient], + listGenerals: () => [healer, higherIdPatient, lowerIdPatient], + }; + let draw = 0; + const rng: RandomGenerator = { + nextFloat1: () => 0, + nextBool: () => draw++ === 0, + nextInt: (minInclusive: number, _maxExclusive: number) => minInclusive, + }; + const triggerContext = createGeneralTriggerContext({ general: healer, rng, worldView }); + + pipeline.getPreTurnExecuteTriggerList({ general: healer, worldView }).fire(triggerContext, {}); + + expect(lowerIdPatient.injury).toBe(0); + expect(higherIdPatient.injury).toBe(30); + }); + it('activates 의술 battle trigger and reduces damage', async () => { const domestic = await loadDomesticTraitModules(['che_인덕', 'che_발명']); const war = await loadWarTraitModules(['che_의술', 'che_징병']); diff --git a/packages/logic/test/warAftermath.test.ts b/packages/logic/test/warAftermath.test.ts index fcf5d3b7..01dfd5be 100644 --- a/packages/logic/test/warAftermath.test.ts +++ b/packages/logic/test/warAftermath.test.ts @@ -170,13 +170,49 @@ describe('war aftermath', () => { }, }); - expect(attackerNation.meta.tech).toBe(1000.6); - expect(defenderNation.meta.tech).toBe(1000.9); + expect(attackerNation.meta.tech).toBe(Math.fround(1000.6)); + expect(defenderNation.meta.tech).toBe(Math.fround(1000.9)); expect(outcome.diplomacyDeltas).toHaveLength(2); expect(attackerCity.meta.dead).toBe(60); expect(defenderCity.meta.dead).toBe(90); }); + it('truncates each city casualty split before accumulating it', () => { + const attackerNation = buildNation(1); + const defenderNation = buildNation(2); + const attackerCity = buildCity(1, 1); + const defenderCity = buildCity(2, 2); + attackerCity.meta.dead = 10; + defenderCity.meta.dead = 20; + const attacker = buildGeneral(1, 1, 1); + + resolveWarAftermath({ + battle: { + attacker, + defenders: [], + defenderCity, + logs: [], + conquered: false, + reports: [ + { id: attacker.id, type: 'general', name: attacker.name, isAttacker: true, killed: 101, dead: 52 }, + ], + }, + attackerNation, + defenderNation, + attackerCity, + defenderCity, + nations: [attackerNation, defenderNation], + cities: [attackerCity, defenderCity], + generals: [attacker], + unitSet: buildUnitSet(), + config: buildConfig(), + time: { year: 200, month: 1, startYear: 180 }, + }); + + expect(attackerCity.meta.dead).toBe(71); + expect(defenderCity.meta.dead).toBe(111); + }); + it('logs emergency relocation when a surviving nation loses its capital', () => { const attackerNation = buildNation(1); const defenderNation = buildNation(2); @@ -307,6 +343,62 @@ describe('war aftermath', () => { ); }); + it('matches ruined-nation lord ordering and NPC appointment draws', () => { + const attackerNation = buildNation(1); + const defenderNation = buildNation(2); + defenderNation.chiefGeneralId = 10; + const attackerCity = buildCity(1, 1); + const defenderCity = buildCity(2, 2); + const attacker = buildGeneral(1, 1, 1); + const lord = buildGeneral(10, 2, 2); + const npc = buildGeneral(2, 2, 2); + npc.npcState = 2; + + const rangeDraws = [0.2, 0.21, 0.4, 0.41]; + const nextBool = vi.fn().mockReturnValueOnce(false).mockReturnValueOnce(true).mockReturnValueOnce(false); + const rng = { + nextRange: vi.fn(() => rangeDraws.shift()!), + nextBool, + nextRangeInt: vi.fn(() => 6), + } as unknown as RandUtil; + + const outcome = resolveWarAftermath({ + battle: { + attacker, + defenders: [], + defenderCity, + logs: [], + conquered: true, + reports: [], + }, + attackerNation, + defenderNation, + attackerCity, + defenderCity, + nations: [attackerNation, defenderNation], + cities: [attackerCity, defenderCity], + // The caller order deliberately puts the lord first. + generals: [attacker, lord, npc], + unitSet: buildUnitSet(), + config: { + ...buildConfig(), + joinMode: 'full', + joinRuinedNpcProbability: 0.1, + }, + time: { year: 186, month: 1, startYear: 179 }, + rng, + }); + + expect(npc.gold).toBe(800); + expect(npc.rice).toBe(790); + expect(lord.gold).toBe(600); + expect(lord.rice).toBe(590); + expect(nextBool.mock.calls.map(([probability]) => probability)).toEqual([0.5, 0.1, 0.5]); + expect(outcome.conquest?.ruinedNpcJoinPlans).toEqual([ + { generalId: npc.id, destNationId: attackerNation.id, joinTurn: 6 }, + ]); + }); + it('dispatches city conquest to every stationed defender before collapse RNG', () => { const rng = new RandUtil(new ConstantRNG(0)); const draws = [0.01, 0.02, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7]; diff --git a/packages/logic/test/warEngine.test.ts b/packages/logic/test/warEngine.test.ts index 7a1a5ecd..24bf6f9e 100644 --- a/packages/logic/test/warEngine.test.ts +++ b/packages/logic/test/warEngine.test.ts @@ -166,6 +166,64 @@ const buildGeneral = (strength: number): General => ({ }); describe('war triggers', () => { + it('normalizes accumulated dexterity to the PHP SQL float precision', () => { + const general = buildGeneral(80); + general.meta.dex4 = 14_677.199999999997; + const wizard = new WarCrewType({ + ...buildUnitSet().crewTypes![0]!, + id: 104, + armType: 4, + name: '귀병', + }); + const unit = new WarUnitGeneral( + new RandUtil(new ConstantRNG(0)), + buildConfig(), + general, + buildCity(), + buildNation(), + true, + wizard, + new ActionLogger({ generalId: 1, nationId: 1 }), + new WarActionPipeline([]) + ); + + unit.addDex(wizard, 2047); + + expect(general.meta.dex4).toBe(16_519.5); + }); + + it('preserves the legacy fractional morale gain after a win', () => { + const general = buildGeneral(80); + general.atmos = 105; + const crewType = new WarCrewType(buildUnitSet().crewTypes![0]!); + const unit = new WarUnitGeneral( + new RandUtil(new ConstantRNG(0)), + buildConfig(), + general, + buildCity(), + buildNation(), + true, + crewType, + new ActionLogger({ generalId: 1, nationId: 1 }), + new WarActionPipeline([]) + ); + const defenderCity = new WarUnitCity( + new RandUtil(new ConstantRNG(0)), + buildConfig(), + { ...buildCity(), nationId: 2 }, + { ...buildNation(), id: 2 }, + new WarCrewType(buildUnitSet().crewTypes![1]!), + new ActionLogger({ generalId: 0, nationId: 2 }), + 200, + 180 + ); + unit.setOppose(defenderCity); + + unit.addWin(); + + expect(general.atmos).toBeCloseTo(115.5, 12); + }); + it('updates the legacy experience level and applies item experience modifiers immediately', () => { const general = buildGeneral(80); general.experience = 90; @@ -224,6 +282,28 @@ describe('war triggers', () => { expect(general.meta.dex5).toBe(5090); }); + it('consumes one accumulated stat-exp threshold when a battle finishes', () => { + const general = buildGeneral(80); + general.meta.intel_exp = 30; + const crewType = new WarCrewType(buildUnitSet().crewTypes![0]!); + const unit = new WarUnitGeneral( + new RandUtil(new ConstantRNG(0)), + buildConfig(), + general, + buildCity(), + buildNation(), + true, + crewType, + new ActionLogger({ generalId: 1, nationId: 1 }), + new WarActionPipeline([]) + ); + + unit.finishBattle(); + + expect(general.stats.intelligence).toBe(71); + expect(general.meta.intel_exp).toBe(0); + }); + it('activates and applies critical damage', async () => { const rng = new RandUtil(new ConstantRNG(0)); const config = buildConfig(); @@ -326,6 +406,30 @@ describe('war triggers', () => { expect(city.conflict).toEqual({ 1: 1.05, 2: 1 }); expect(city.meta.conflict_order).toEqual([1, 2]); }); + + it('uses the legacy virtual rice reserve for a neutral defender', () => { + const events: string[] = []; + const city = { ...buildCity(), nationId: 0 }; + + resolveWarBattle({ + rng: new RandUtil(new ConstantRNG(0)), + unitSet: buildUnitSet(), + config: buildConfig(), + time: { year: 200, month: 1, startYear: 180 }, + attacker: { + general: buildGeneral(80), + city: buildCity(), + nation: buildNation(), + }, + defenders: [], + defenderCity: city, + defenderNation: null, + trace: (event) => events.push(event.event), + }); + + expect(events).not.toContain('supply_retreat'); + expect(events).toContain('phase_damage'); + }); }); describe('resolveWarBattle', () => { diff --git a/packages/logic/test/warUtils.test.ts b/packages/logic/test/warUtils.test.ts new file mode 100644 index 00000000..4ec62987 --- /dev/null +++ b/packages/logic/test/warUtils.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from 'vitest'; + +import { round } from '../src/war/utils.js'; + +describe('legacy war rounding', () => { + it('matches PHP round() at drifted positive and negative half boundaries', () => { + expect(round(4159.499999999999)).toBe(4160); + expect(round(-4159.499999999999)).toBe(-4160); + }); + + it('keeps values meaningfully below a half boundary on the lower integer', () => { + expect(round(4159.499999)).toBe(4159); + expect(round(-4159.499999)).toBe(-4159); + }); +}); diff --git a/packages/logic/test/world/distance.test.ts b/packages/logic/test/world/distance.test.ts index 2a79dece..5c08f27c 100644 --- a/packages/logic/test/world/distance.test.ts +++ b/packages/logic/test/world/distance.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { getCityDistance, searchDistance } from '@sammo-ts/logic/world/distance.js'; +import { getCityDistance, searchDistance, searchDistanceEntries } from '@sammo-ts/logic/world/distance.js'; import type { MapDefinition } from '@sammo-ts/logic/world/types.js'; describe('World Distance', () => { @@ -69,5 +69,22 @@ describe('World Distance', () => { const result = searchDistance(mockMap, 1, 10); expect(result).not.to.have.property('8'); }); + + it('preserves legacy BFS visit order independently of numeric object-key ordering', () => { + const orderMap: MapDefinition = { + id: 'order-map', + name: 'Order Map', + cities: [ + { id: 1, connections: [10, 2] }, + { id: 10, connections: [1] }, + { id: 2, connections: [1] }, + ] as any[], + }; + expect(searchDistanceEntries(orderMap, 1, 1)).toEqual([ + [1, 0], + [10, 1], + [2, 1], + ]); + }); }); }); diff --git a/packages/logic/test/worldBootstrap.test.ts b/packages/logic/test/worldBootstrap.test.ts index 5e34f933..b774566c 100644 --- a/packages/logic/test/worldBootstrap.test.ts +++ b/packages/logic/test/worldBootstrap.test.ts @@ -72,6 +72,38 @@ describe('scenario bootstrap', () => { special: 'Special', text: 'Test line', }, + { + affinity: 11, + name: 'MedicalGeneral', + picture: null, + nation: 1, + city: 'Alpha', + leadership: 40, + strength: 40, + intelligence: 70, + officerLevel: 1, + birthYear: 180, + deathYear: 240, + personality: '출세', + special: '의술', + text: null, + }, + { + affinity: 12, + name: 'ChargeGeneral', + picture: null, + nation: 1, + city: 'Alpha', + leadership: 70, + strength: 60, + intelligence: 20, + officerLevel: 1, + birthYear: 180, + deathYear: 240, + personality: '패권', + special: '돌격', + text: null, + }, ], generalsEx: [], generalsNeutral: [], @@ -129,17 +161,49 @@ describe('scenario bootstrap', () => { expect(result.snapshot.generals[0]?.crewTypeId).toBe(1200); expect(result.snapshot.generals[0]?.role.specialDomestic).toBe('Special'); expect(result.snapshot.generals[0]?.role.specialWar).toBeNull(); + expect(result.snapshot.generals[1]?.role).toMatchObject({ + personality: 'che_출세', + specialDomestic: 'che_event_의술', + specialWar: null, + }); + expect(result.seed.generals[1]).toMatchObject({ + special: 'che_event_의술', + specialWar: null, + }); + expect(result.snapshot.generals[2]?.role).toMatchObject({ + personality: 'che_패권', + specialDomestic: 'che_event_돌격', + specialWar: null, + }); expect(result.snapshot.generals[0]?.meta).toMatchObject({ specage: 25, specage2: 30 }); expect(result.seed.generals[0]?.meta).toMatchObject({ deathMonth: expect.any(Number), specage: 25, specage2: 30, }); + const preOpening = buildScenarioBootstrap({ + scenario, + map, + unitSet, + options: { initialYear: scenario.startYear! - 1 }, + }); + expect(preOpening.snapshot.generals[0]).toMatchObject({ + age: 19, + meta: { specage: 25, specage2: 30 }, + }); + expect(preOpening.seed.generals[0]?.meta).toMatchObject({ specage: 25, specage2: 30 }); expect(buildScenarioBootstrap({ scenario, map, unitSet }).snapshot.generals[0]?.meta).toEqual( result.snapshot.generals[0]?.meta ); expect(result.seed.generals[0]?.npcType).toBe(2); expect(result.snapshot.scenarioMeta?.title).toBe('Test Scenario'); + expect(result.seed.events[0]).toEqual(['pre_month', 9_000, true, ['UpdateCitySupply'], ['ProcessWarIncome']]); + expect(result.seed.events.flat(3)).toContain('ProcessSemiAnnual'); + expect(result.seed.events.flat(3)).toContain('NewYear'); + expect(result.seed.initialEvents[0]).toEqual([ + true, + ['NoticeToHistoryLog', '2년간 거병 및 건국이 가능합니다.', 6], + ]); }); it('places generals without an explicit city in a deterministic valid city', () => { @@ -210,7 +274,7 @@ describe('scenario bootstrap', () => { cities: [], events: [], initialEvents: [], - ignoreDefaultEvents: false, + ignoreDefaultEvents: true, }; const map: MapDefinition = { id: 'test-map', @@ -248,6 +312,36 @@ describe('scenario bootstrap', () => { expect(first.seed.generals[0]?.cityId).toBe(1); expect([1, 2]).toContain(first.seed.generals[1]?.cityId); expect(first.seed.generals.every((general) => general.cityId > 0)).toBe(true); + expect( + first.seed.generals.map((general) => ({ + cityId: general.cityId, + affinity: general.affinity, + personality: general.personality, + experience: general.experience, + dedication: general.dedication, + deathMonth: general.meta.deathMonth, + initialTurnOffsetMicros: general.meta.initialTurnOffsetMicros, + })) + ).toEqual([ + { + cityId: 1, + affinity: 10, + personality: 'che_안전', + experience: 2_000, + dedication: 2_000, + deathMonth: 12, + initialTurnOffsetMicros: 2_161_529_667, + }, + { + cityId: 2, + affinity: 20, + personality: 'che_재간', + experience: 2_000, + dedication: 2_000, + deathMonth: 7, + initialTurnOffsetMicros: 3_203_248_275, + }, + ]); }); it('defers future generals into birth-year registration events and omits expired rows', () => { @@ -306,7 +400,7 @@ describe('scenario bootstrap', () => { cities: [], events: [['Month', 500, ['Date', '>=', 200, 1], ['Existing']]], initialEvents: [], - ignoreDefaultEvents: false, + ignoreDefaultEvents: true, }; const map: MapDefinition = { id: 'test-map',