From 5d6923f11a93ea21909b3714d75babebdc39e386 Mon Sep 17 00:00:00 2001 From: hided62 Date: Wed, 5 Aug 2026 13:25:39 +0000 Subject: [PATCH] fix: align scenario 2400 monthly parity --- app/game-api/src/turns/commandTable.ts | 2 +- app/game-engine/src/turn/ai/generalAi/core.ts | 141 +++++++++++++++--- .../nation/assignments/npcAssignments.ts | 13 +- .../src/turn/ai/generalAi/nation/capital.ts | 32 +++- app/game-engine/src/turn/inMemoryWorld.ts | 19 ++- .../monthlyProvideNpcTroopLeaderAction.ts | 23 +-- .../src/turn/monthlySpecialityBetrayAction.ts | 10 +- .../src/turn/reservedTurnHandler.ts | 12 ++ .../generalAiLegacyDecisionParity.test.ts | 118 ++++++++++++++- ...monthlyProvideNpcTroopLeaderAction.test.ts | 17 +-- .../monthlySpecialityBetrayAction.test.ts | 6 +- app/game-engine/test/turnOrder.test.ts | 15 +- .../src/actions/turn/actionContextHelpers.ts | 3 +- .../src/actions/turn/general/che_임관.ts | 13 +- .../src/actions/turn/general/che_출병.ts | 33 ++-- .../actions/turn/general/legacyCityTrust.ts | 6 +- packages/logic/src/compat/legacyFloat.ts | 23 ++- packages/logic/src/domain/entities.ts | 2 + packages/logic/src/war/aftermath.ts | 6 +- packages/logic/src/war/engine.ts | 3 + packages/logic/src/war/types.ts | 2 + packages/logic/src/war/units/general.ts | 14 +- packages/logic/test/dispatchWarAction.test.ts | 44 +++++- .../scenarios/general_commands_new.test.ts | 37 +++++ packages/logic/test/warAftermath.test.ts | 53 +++++++ packages/logic/test/warEngine.test.ts | 6 + 26 files changed, 564 insertions(+), 89 deletions(-) diff --git a/app/game-api/src/turns/commandTable.ts b/app/game-api/src/turns/commandTable.ts index 1949382..8ad6206 100644 --- a/app/game-api/src/turns/commandTable.ts +++ b/app/game-api/src/turns/commandTable.ts @@ -206,7 +206,7 @@ const buildCommandEnv = (worldState: WorldStateRow): CommandEnv => { defaultSpecialDomestic: resolveOptionalString(constValues, ['defaultSpecialDomestic']), defaultSpecialWar: resolveOptionalString(constValues, ['defaultSpecialWar']), initialNationGenLimit: resolveNumber(constValues, ['initialNationGenLimit'], 0), - maxTechLevel: resolveNumber(constValues, ['maxTechLevel'], 0), + maxTechLevel: resolveNumber(constValues, ['maxTechLevel'], 12), maxStatLevel: resolveNumber(constValues, ['maxLevel'], 255), techLevelIncYear: resolveNumber(constValues, ['techLevelIncYear'], 5), initialAllowedTechLevel: resolveNumber(constValues, ['initialAllowedTechLevel'], 1), diff --git a/app/game-engine/src/turn/ai/generalAi/core.ts b/app/game-engine/src/turn/ai/generalAi/core.ts index e75a92c..ca81677 100644 --- a/app/game-engine/src/turn/ai/generalAi/core.ts +++ b/app/game-engine/src/turn/ai/generalAi/core.ts @@ -10,10 +10,11 @@ import type { } from '@sammo-ts/logic'; import { evaluateConstraints } from '@sammo-ts/logic'; import type { ConstraintContext } from '@sammo-ts/logic'; -import { LiteHashDRBG, RandUtil } from '@sammo-ts/common'; +import { GAME_TICKS_PER_TURN, 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 { GeneralActionPipeline } from '@sammo-ts/logic/actionModules/general.js'; import type { ReservedTurnEntry } from '../../reservedTurnStore.js'; import type { TurnGeneral, TurnWorldState } from '../../types.js'; @@ -50,6 +51,19 @@ const d징병 = 2; const d직전 = 3; const d전쟁 = 4; +export const calculateRecentWarTurn = (general: TurnGeneral, turnTermMinutes: number): number => { + if (general.recentWarTick !== null && general.recentWarTick !== undefined && general.turnTick !== undefined) { + const tickDiff = general.turnTick - general.recentWarTick; + return tickDiff <= 0 ? 0 : Math.floor(tickDiff / GAME_TICKS_PER_TURN); + } + const recent = general.recentWarTime; + if (!recent) return 12000; + const diffMs = general.turnTime.getTime() - recent.getTime(); + if (diffMs <= 0) return 0; + const turnMs = turnTermMinutes * 60 * 1000; + return turnMs > 0 ? Math.floor(diffMs / turnMs) : 12000; +}; + export const selectNpcMessageForTurn = ( message: unknown, rng: Pick, @@ -86,6 +100,66 @@ export const resolveLegacyAiStats = ( }; }; +export const resolveLegacyAiStatsWithModules = ( + general: TurnGeneral, + nation: Nation | null | undefined, + maxStatLevel: number, + modules: TurnCommandEnv['generalActionModules'], + worldRef: AiWorldView | null, + world: TurnWorldState, + startYear: number +) => { + const maxLevel = Math.max(1, maxStatLevel); + const clampStat = (value: number): number => Math.max(0, Math.min(value, maxLevel)); + const pipeline = new GeneralActionPipeline(modules ?? []); + const context = { + general, + nation, + ...(worldRef + ? { + worldView: { + listGenerals: () => worldRef.listGenerals(), + listGeneralsByCity: (cityId: number) => + worldRef.listGenerals().filter((candidate) => candidate.cityId === cityId), + listNations: () => worldRef.listNations(), + }, + } + : {}), + time: { + year: world.currentYear, + month: world.currentMonth, + startYear, + }, + }; + const rawStat = (statName: 'leadership' | 'strength' | 'intelligence'): number => general.stats[statName]; + const calculate = ( + statName: 'leadership' | 'strength' | 'intelligence', + withInjury: boolean, + withStatAdjust: boolean, + truncate: boolean + ): number => { + const injuryRatio = withInjury ? (100 - Math.max(0, Math.min(general.injury, 100))) / 100 : 1; + let value = rawStat(statName) * injuryRatio; + if (withStatAdjust && statName === 'strength') { + value += Math.round(calculate('intelligence', withInjury, false, false) / 4); + } else if (withStatAdjust && statName === 'intelligence') { + value += Math.round(calculate('strength', withInjury, false, false) / 4); + } + value = clampStat(value); + value = clampStat(Number(pipeline.onCalcStat(context, statName, value))); + return truncate ? Math.trunc(value) : value; + }; + + return { + fullLeadership: calculate('leadership', false, true, true), + fullStrength: calculate('strength', false, true, true), + fullIntelligence: calculate('intelligence', false, true, true), + effectiveLeadership: calculate('leadership', true, true, true), + effectiveStrength: calculate('strength', true, true, true), + effectiveIntelligence: calculate('intelligence', true, true, true), + }; +}; + export class GeneralAI { public general: TurnGeneral; public city?: City; @@ -560,6 +634,28 @@ export class GeneralAI { return this.buildCandidate(this.nationDefinitions, this.nationFallback, action, args, reason); } + getLastNationTurn(): Record { + return asRecord(asRecord(this.nation?.meta)[`turn_last_${this.general.officerLevel}`]); + } + + getLastCapitalMoveTrial(): [number, number] | null { + const raw = asRecord(this.nation?.meta).lastCapitalMoveTrial; + if (!Array.isArray(raw) || raw.length < 2) return null; + const officerLevel = Number(raw[0]); + const turnTick = Number(raw[1]); + return Number.isFinite(officerLevel) && Number.isFinite(turnTick) ? [officerLevel, turnTick] : null; + } + + markCapitalMoveTrial(): void { + if (!this.nation || this.general.turnTick === undefined) return; + const nextMeta = { + ...(this.promotionNationMeta ?? this.nation.meta), + lastCapitalMoveTrial: [this.general.officerLevel, this.general.turnTick], + }; + this.nation = { ...this.nation, meta: nextMeta as Nation['meta'] }; + this.promotionNationMeta = nextMeta; + } + getReservedTurn(generalId: number): ReservedTurnEntry { return this.reservedTurnProvider.getGeneralTurn(generalId, 0); } @@ -930,11 +1026,21 @@ export class GeneralAI { } private refreshLegacyFullStats(): void { - const stats = resolveLegacyAiStats( - this.general, - this.nation, - this.commandEnv.maxStatLevel ?? this.scenarioConfig.stat.max - ); + const stats = this.commandEnv.generalActionModules + ? resolveLegacyAiStatsWithModules( + this.general, + this.nation, + this.commandEnv.maxStatLevel ?? this.scenarioConfig.stat.max, + this.commandEnv.generalActionModules, + this.worldRef, + this.world, + this.startYear + ) + : resolveLegacyAiStats( + this.general, + this.nation, + this.commandEnv.maxStatLevel ?? this.scenarioConfig.stat.max + ); this.general.meta = { ...this.general.meta, ...stats, @@ -1129,6 +1235,15 @@ export class GeneralAI { lastAttackable = yearMonth; worldLastAttackable.set(nationId, yearMonth); this.nation!.meta = { ...this.nation!.meta, last_attackable: yearMonth }; + // Ref writes nation_env.last_attackable while constructing each + // GeneralAI instance. Carry that side effect through the existing + // nation-meta patch channel even when no promotion was selected. + // Promotion runs first in quarter months, so preserve a chief_set + // patch already accumulated by choose*Promotion(). + this.promotionNationMeta = { + ...(this.promotionNationMeta ?? this.nation!.meta), + last_attackable: yearMonth, + }; }; if (minWarTerm === null) { @@ -1163,19 +1278,7 @@ export class GeneralAI { } private calcRecentWarTurn(general: TurnGeneral): number { - const recent = general.recentWarTime; - if (!recent) { - return 12000; - } - const diffMs = general.turnTime.getTime() - recent.getTime(); - if (diffMs <= 0) { - return 0; - } - const turnMs = this.turnTermMinutes * 60 * 1000; - if (turnMs <= 0) { - return 12000; - } - return Math.floor(diffMs / turnMs); + return calculateRecentWarTurn(general, this.turnTermMinutes); } } 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 d0cbf62..8478d9b 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 @@ -121,12 +121,17 @@ export const doNPC구출발령 = (ai: GeneralAI) => { if (lostCandidates.length === 0) { return null; } - const destCityId = pickRandomCityId(ai, ai.supplyCities); - if (destCityId === null) { + const candidates = lostCandidates.flatMap((general) => { + const destCityId = pickRandomCityId(ai, ai.supplyCities); + return destCityId === null ? [] : [{ general, destCityId }]; + }); + if (candidates.length === 0) { return null; } - const destGeneral = ai.rng.choice(lostCandidates); - return buildAssignmentCandidate(ai, destGeneral.id, destCityId, 'NPC구출발령'); + // Ref draws one destination city for every lost general, then chooses one + // completed (general, city) pair. The unused pairs still consume RNG. + const picked = ai.rng.choice(candidates); + return buildAssignmentCandidate(ai, picked.general.id, picked.destCityId, 'NPC구출발령'); }; export const doNPC전방발령 = (ai: GeneralAI) => { diff --git a/app/game-engine/src/turn/ai/generalAi/nation/capital.ts b/app/game-engine/src/turn/ai/generalAi/nation/capital.ts index d63205e..c2d96e0 100644 --- a/app/game-engine/src/turn/ai/generalAi/nation/capital.ts +++ b/app/game-engine/src/turn/ai/generalAi/nation/capital.ts @@ -1,4 +1,5 @@ import type { GeneralAI } from '../core.js'; +import { GAME_TICKS_PER_TURN } from '@sammo-ts/common'; import { calcCityDevRatio } from '../../aiUtils.js'; import { searchAllDistanceByCityList } from '../../distance.js'; @@ -9,6 +10,33 @@ export const do천도 = (ai: GeneralAI) => { if (!ai.map) { return null; } + + const lastTurn = ai.getLastNationTurn(); + const lastArgs = + lastTurn.arg && typeof lastTurn.arg === 'object' ? (lastTurn.arg as Record) : null; + const lastDestination = Number(lastArgs?.destCityID ?? lastArgs?.destCityId); + if ( + lastTurn.command === '천도' && + Number.isFinite(lastDestination) && + lastDestination !== ai.nation.capitalCityId + ) { + const continuing = ai.buildNationCandidate('che_천도', { destCityID: lastDestination }, '천도'); + if (continuing) { + ai.markCapitalMoveTrial(); + return continuing; + } + } + + const lastTrial = ai.getLastCapitalMoveTrial(); + const currentTurnTick = ai.general.turnTick; + if ( + lastTrial && + currentTurnTick !== undefined && + Math.abs(currentTurnTick - lastTrial[1]) < Math.floor(GAME_TICKS_PER_TURN / 2) && + lastTrial[0] !== ai.general.officerLevel + ) { + return null; + } const nationCities = Object.values(ai.nationCities); if (nationCities.length <= 1) { return null; @@ -80,5 +108,7 @@ export const do천도 = (ai: GeneralAI) => { } } - return ai.buildNationCandidate('che_천도', { destCityID: targetCityId }, '천도'); + const candidate = ai.buildNationCandidate('che_천도', { destCityID: targetCityId }, '천도'); + if (candidate) ai.markCapitalMoveTrial(); + return candidate; }; diff --git a/app/game-engine/src/turn/inMemoryWorld.ts b/app/game-engine/src/turn/inMemoryWorld.ts index ff321cd..305ec6b 100644 --- a/app/game-engine/src/turn/inMemoryWorld.ts +++ b/app/game-engine/src/turn/inMemoryWorld.ts @@ -1236,11 +1236,26 @@ export class InMemoryTurnWorld { const nextTurnAt = result.nextTurnAt ?? getNextTurnAt(currentGeneral.turnTime, this.schedule); if (!result.deleted?.general) { + const resolvedGeneral = result.general ?? currentGeneral; + const clock = this.getGameClock(); + const currentTurnTick = currentGeneral.turnTick ?? clock.dateToTick(currentGeneral.turnTime); + const nextTurnTick = + currentTurnTick + (clock.dateToTick(nextTurnAt) - clock.dateToTick(currentGeneral.turnTime)); + const recentWarTimeChanged = + (resolvedGeneral.recentWarTime?.getTime() ?? null) !== + (currentGeneral.recentWarTime?.getTime() ?? null); + const recentWarTickChanged = resolvedGeneral.recentWarTick !== currentGeneral.recentWarTick; const nextGeneral = this.normalizeGeneralClock( normalizeGeneralDatabaseIntegers({ - ...(result.general ?? currentGeneral), + ...resolvedGeneral, turnTime: nextTurnAt, - turnTick: undefined, + // Ref advances the logical tick directly. Re-encoding the + // millisecond Date would discard its sub-millisecond tail. + turnTick: nextTurnTick, + // A loaded row always carries recentWarTick (often null). + // When battle logic changes recentWarTime, discard that + // stale tick so normalizeGeneralClock derives the new one. + ...(recentWarTimeChanged && !recentWarTickChanged ? { recentWarTick: undefined } : {}), }) ); this.generals.set(nextGeneral.id, nextGeneral); diff --git a/app/game-engine/src/turn/monthlyProvideNpcTroopLeaderAction.ts b/app/game-engine/src/turn/monthlyProvideNpcTroopLeaderAction.ts index a938943..c4616ed 100644 --- a/app/game-engine/src/turn/monthlyProvideNpcTroopLeaderAction.ts +++ b/app/game-engine/src/turn/monthlyProvideNpcTroopLeaderAction.ts @@ -1,4 +1,4 @@ -import { LiteHashDRBG, RandUtil } from '@sammo-ts/common'; +import { GAME_TICKS_PER_TURN, LiteHashDRBG, RandUtil } from '@sammo-ts/common'; import type { TurnCommandEnv } from '@sammo-ts/logic'; import { simpleSerialize } from '@sammo-ts/logic/war/utils.js'; @@ -25,18 +25,24 @@ const resolveHiddenSeed = (world: InMemoryTurnWorld): string | number => { return typeof value === 'string' || typeof value === 'number' ? value : String(value); }; -const createTurnTime = ( +const createTurnClock = ( rng: RandUtil, environment: MonthlyEventEnvironment, - tickSeconds: number -): Date => { + world: InMemoryTurnWorld +): { turnTime: Date; turnTick: number } => { + const tickSeconds = world.getState().tickSeconds; const turnMinutes = tickSeconds / 60; if (!(turnMinutes > 0) || !Number.isInteger(turnMinutes)) { throw new Error('ProvideNPCTroopLeader requires a positive integer turn term.'); } const seconds = rng.nextRangeInt(0, turnMinutes * 60 - 1); const fraction = rng.nextRangeInt(0, 999_999); - return new Date(environment.turnTime.getTime() + seconds * 1_000 + Math.floor(fraction / 1_000)); + const ticksPerSecond = GAME_TICKS_PER_TURN / tickSeconds; + const turnTick = + world.dateToGameTick(environment.turnTime) + + seconds * ticksPerSecond + + Math.floor((fraction * ticksPerSecond) / 1_000_000); + return { turnTime: world.gameTickToDate(turnTick), turnTick }; }; export const createProvideNpcTroopLeaderHandler = (options: { @@ -51,9 +57,7 @@ export const createProvideNpcTroopLeaderHandler = (options: { } const currentLastId = world.getState().meta.lastNPCTroopLeaderID; let lastNpcTroopLeaderId = - typeof currentLastId === 'number' && Number.isFinite(currentLastId) - ? Math.trunc(currentLastId) - : 0; + typeof currentLastId === 'number' && Number.isFinite(currentLastId) ? Math.trunc(currentLastId) : 0; for (const nation of world.listNations().sort((left, right) => left.id - right.id)) { const maximum = MAX_LEADERS_BY_NATION_LEVEL[nation.level] ?? 0; @@ -85,6 +89,7 @@ export const createProvideNpcTroopLeaderHandler = (options: { const city = rng.choice(cityPool); const id = world.getNextGeneralId(); const age = 20; + const turnClock = createTurnClock(rng, environment, world); const general: TurnGeneral = { id, userId: null, @@ -117,7 +122,7 @@ export const createProvideNpcTroopLeaderHandler = (options: { picture: 'default.jpg', triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} }, lastTurn: { command: '휴식' }, - turnTime: createTurnTime(rng, environment, world.getState().tickSeconds), + ...turnClock, recentWarTime: null, meta: { killturn: 70, diff --git a/app/game-engine/src/turn/monthlySpecialityBetrayAction.ts b/app/game-engine/src/turn/monthlySpecialityBetrayAction.ts index bc51018..b1a676c 100644 --- a/app/game-engine/src/turn/monthlySpecialityBetrayAction.ts +++ b/app/game-engine/src/turn/monthlySpecialityBetrayAction.ts @@ -149,13 +149,9 @@ export const createAssignGeneralSpecialityHandler = (options: { const defaultWar = normalizeCode(world.getScenarioConfig().const.defaultSpecialWar); const retirementYear = readRuntimeNumber(world, 'retirementYear', 80); const scenarioStat = world.getScenarioConfig().stat; - // ref SQL에 ORDER BY가 없으므로 loader가 보존한 DB scan 순서를 두 - // domestic/war pass에서 그대로 재사용한다. - const generals = world.listGenerals().sort((left, right) => { - const leftOrder = readFiniteNumber(left.meta, ['legacyScanOrder']) ?? left.id; - const rightOrder = readFiniteNumber(right.meta, ['legacyScanOrder']) ?? right.id; - return leftOrder - rightOrder; - }); + // Ref explicitly orders both speciality passes by general.no. This + // avoids leaking Aria's deleted-page reuse order into gameplay RNG. + const generals = world.listGenerals().sort((left, right) => left.id - right.id); for (const general of generals) { if ( diff --git a/app/game-engine/src/turn/reservedTurnHandler.ts b/app/game-engine/src/turn/reservedTurnHandler.ts index 7a4d944..55325b0 100644 --- a/app/game-engine/src/turn/reservedTurnHandler.ts +++ b/app/game-engine/src/turn/reservedTurnHandler.ts @@ -1760,6 +1760,18 @@ export const createReservedTurnHandler = async (options: { nationFallback, }); const candidate = ai.chooseGeneralTurn(generalCommand); + // Ref GeneralAI::calcDiplomacyState writes + // nation_env.last_attackable for ordinary generals too. The + // nation-turn path consumes this patch above, but most NPCs + // never execute a nation turn. + const generalAiNationState = ai.consumePromotionPatches(); + if (generalAiNationState.nationMeta && currentNation) { + currentNation = { + ...currentNation, + meta: generalAiNationState.nationMeta as Nation['meta'], + }; + worldOverlay?.applyNationPatch(currentNation.id, { meta: currentNation.meta }); + } const npcMessage = ai.consumeNpcMessage(); if (npcMessage) { const messageTarget = { diff --git a/app/game-engine/test/generalAiLegacyDecisionParity.test.ts b/app/game-engine/test/generalAiLegacyDecisionParity.test.ts index 43c4401..71dc550 100644 --- a/app/game-engine/test/generalAiLegacyDecisionParity.test.ts +++ b/app/game-engine/test/generalAiLegacyDecisionParity.test.ts @@ -2,8 +2,12 @@ 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 { GeneralAI } from '../src/turn/ai/generalAi.js'; +import { + calculateRecentWarTurn, + resolveLegacyAiStats, + resolveLegacyAiStatsWithModules, +} 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'; @@ -12,7 +16,12 @@ 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집합, 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'; +import { do천도 } from '../src/turn/ai/generalAi/nation/capital.js'; +import { + doNPC구출발령, + doNPC전방발령, + doNPC후방발령, +} from '../src/turn/ai/generalAi/nation/assignments/npcAssignments.js'; type Candidate = { action: string; @@ -125,6 +134,19 @@ const baseGeneral = (): General & { turnTime: Date } => ({ meta: { killturn: 100, fullLeadership: 70 }, }); +describe('GeneralAI recent war clock parity', () => { + it('uses raw logical ticks at an exact turn boundary', () => { + const general = { + ...baseGeneral(), + turnTick: 72_000_099, + recentWarTick: 36_000_100, + recentWarTime: new Date('0189-12-31T23:50:00.000Z'), + } as ReturnType & { turnTick: number; recentWarTick: number; recentWarTime: Date }; + + expect(calculateRecentWarTurn(general, 10)).toBe(0); + }); +}); + const baseCity = (): City => ({ id: 1, name: '가상도시', @@ -376,6 +398,52 @@ const makeAi = ( * selection and RNG-sensitive gates, not TypeScript implementation details. */ describe('legacy NPC AI final-decision parity', () => { + it('blocks another officer from starting a capital move within half a turn', () => { + const base = makeAi({ general: { officerLevel: 10, turnTick: 36_000_100 } }); + const ai = Object.assign(Object.create(GeneralAI.prototype), base, { + nation: { ...base.nation!, meta: { ...base.nation!.meta, lastCapitalMoveTrial: [12, 36_000_000] } }, + }) as GeneralAI; + + expect(do천도(ai)).toBeNull(); + }); + + it('continues the same capital move and records the legacy trial tick', () => { + const base = makeAi({ general: { officerLevel: 12, turnTick: 72_000_100 } }); + const ai = Object.assign(Object.create(GeneralAI.prototype), base, { + nation: { + ...base.nation!, + capitalCityId: 1, + meta: { ...base.nation!.meta, turn_last_12: { command: '천도', arg: { destCityID: 2 } } }, + }, + promotionPatches: [], + promotionNationMeta: null, + }) as GeneralAI; + + expect(do천도(ai)).toMatchObject({ action: 'che_천도', args: { destCityID: 2 } }); + expect(ai.consumePromotionPatches().nationMeta).toMatchObject({ + lastCapitalMoveTrial: [12, 72_000_100], + }); + }); + + it('persists the legacy last-attackable month through the nation meta patch channel', () => { + const ai = Object.assign(Object.create(GeneralAI.prototype), { + general: { ...baseGeneral(), nationId: 16 }, + nation: { ...baseNation(), id: 16, meta: { last_attackable: 2234 } }, + world: { currentYear: 187, currentMonth: 2, meta: {} }, + worldRef: { + listDiplomacy: () => [{ fromNationId: 16, toNationId: 2, state: 0, term: 0 }], + listCities: () => [{ ...baseCity(), nationId: 16, frontState: 3 }], + }, + startYear: 180, + promotionPatches: [], + promotionNationMeta: { last_attackable: 2234, chief_set: 3584 }, + }) as GeneralAI; + + (ai as unknown as { calcDiplomacyState: () => void }).calcDiplomacyState(); + + expect(ai.consumePromotionPatches().nationMeta).toMatchObject({ last_attackable: 2245, chief_set: 3584 }); + }); + it('normalizes legacy uppercase destination IDs before AI constraint checks', () => { expect( withCanonicalArgumentAliases({ @@ -409,6 +477,32 @@ describe('legacy NPC AI final-decision parity', () => { effectiveLeadership: 70, }); }); + + it('applies active action modules to the full stats used by legacy AI recruitment', () => { + const general = { + ...baseGeneral(), + stats: { leadership: 68, strength: 40, intelligence: 60 }, + meta: { killturn: 100 }, + }; + const leadershipTrait = { + onCalcStat: (context: { general: General }, statName: string, value: number): number => + statName === 'leadership' ? value + context.general.stats.leadership * 0.25 : value, + }; + const modules = singleActionModuleStack(leadershipTrait); + const world = { + id: 1, + currentYear: 189, + currentMonth: 1, + tickSeconds: 600, + lastTurnTime: new Date('0189-01-01T00:00:00Z'), + meta: {}, + }; + + expect(resolveLegacyAiStatsWithModules(general, baseNation(), 100, modules, null, world, 180)).toMatchObject({ + fullLeadership: 85, + effectiveLeadership: 85, + }); + }); it.each([ ['Core scenario name', '강유'], ['Ref stored name', 'ⓝ강유'], @@ -970,6 +1064,24 @@ describe('legacy NPC AI final-decision parity', () => { expect(rng.choices).toEqual([0, 0]); }); + it('draws a rescue city for every lost NPC before choosing the completed pair', () => { + const rng = makeRng([], [0, 1, 1]); + const first = { ...baseGeneral(), id: 2 }; + const second = { ...baseGeneral(), id: 3 }; + const ai = makeAi({ rng }); + ai.lostGenerals = { 2: first, 3: second }; + ai.supplyCities = { + 40: { ...baseCity(), id: 40, dev: 1, important: 1 }, + 64: { ...baseCity(), id: 64, dev: 1, important: 1 }, + }; + + expect(doNPC구출발령(ai)).toMatchObject({ + action: 'che_발령', + args: { destGeneralId: 3, destCityId: 64 }, + }); + expect(rng.choices).toEqual([]); + }); + 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 }; diff --git a/app/game-engine/test/monthlyProvideNpcTroopLeaderAction.test.ts b/app/game-engine/test/monthlyProvideNpcTroopLeaderAction.test.ts index 403e3e7..7a1537a 100644 --- a/app/game-engine/test/monthlyProvideNpcTroopLeaderAction.test.ts +++ b/app/game-engine/test/monthlyProvideNpcTroopLeaderAction.test.ts @@ -146,11 +146,7 @@ describe('ProvideNPCTroopLeader monthly action', () => { const created = world.peekDirtyState().createdGenerals; expect(created).toHaveLength(3); - expect(created.map((general) => general.name)).toEqual([ - '㉥부대장 9', - '㉥부대장 10', - '㉥부대장 11', - ]); + expect(created.map((general) => general.name)).toEqual(['㉥부대장 9', '㉥부대장 10', '㉥부대장 11']); expect(created[0]).toMatchObject({ nationId: 1, cityId: process.env.REF_HIDDEN_SEED ? 2 : 1, @@ -177,6 +173,9 @@ describe('ProvideNPCTroopLeader monthly action', () => { })) ); for (const general of created) { + expect(general.turnTick).toBeTypeOf('number'); + expect(general.turnTick! - world.dateToGameTick(general.turnTime)).toBeGreaterThanOrEqual(0); + expect(general.turnTick! - world.dateToGameTick(general.turnTime)).toBeLessThan(60); expect(reservedTurns.getGeneralTurns(general.id)).toEqual( Array.from({ length: 30 }, () => ({ action: 'che_집합', args: {} })) ); @@ -186,11 +185,9 @@ describe('ProvideNPCTroopLeader monthly action', () => { const probe = new RandUtil( new LiteHashDRBG(simpleSerialize(process.env.REF_HIDDEN_SEED, 'troopLeader', 200, 1, 1)) ); - expect([ - probe.choice([1, 2]), - probe.nextRangeInt(0, 599), - probe.nextRangeInt(0, 999_999), - ]).toEqual([2, 567, 821_811]); + expect([probe.choice([1, 2]), probe.nextRangeInt(0, 599), probe.nextRangeInt(0, 999_999)]).toEqual([ + 2, 567, 821_811, + ]); expect( created.map((general) => ({ cityId: general.cityId, diff --git a/app/game-engine/test/monthlySpecialityBetrayAction.test.ts b/app/game-engine/test/monthlySpecialityBetrayAction.test.ts index 51a90f8..4d0c9e5 100644 --- a/app/game-engine/test/monthlySpecialityBetrayAction.test.ts +++ b/app/game-engine/test/monthlySpecialityBetrayAction.test.ts @@ -161,7 +161,7 @@ describe('monthly speciality and betrayal actions', () => { const logs = world.peekDirtyState().logs; expect(logs).toHaveLength(6); - expect(logs.slice(2, 4)).toEqual([ + expect(logs.filter((log) => log.generalId === 3)).toEqual([ expect.objectContaining({ generalId: 3, category: LogCategory.HISTORY, @@ -199,7 +199,7 @@ describe('monthly speciality and betrayal actions', () => { expect(world.getGeneralById(1)?.role.specialWar).not.toBeNull(); }); - it('persists creation scan order for speciality RNG across a reload', async () => { + it('uses general ID order instead of persisted Aria scan order', async () => { const world = buildWorld(); const laterId = buildGeneral({ id: 5, @@ -249,7 +249,7 @@ describe('monthly speciality and betrayal actions', () => { .peekDirtyState() .logs.filter((log) => log.category === LogCategory.HISTORY) .map((log) => log.generalId) - ).toEqual([1, 5, 4, 3, 2]); + ).toEqual([1, 4, 5, 2, 3]); }); it('applies the two default scenario betrayal steps only to values within each threshold', async () => { diff --git a/app/game-engine/test/turnOrder.test.ts b/app/game-engine/test/turnOrder.test.ts index cbed91d..7d48e98 100644 --- a/app/game-engine/test/turnOrder.test.ts +++ b/app/game-engine/test/turnOrder.test.ts @@ -42,7 +42,12 @@ describe('InMemoryTurnProcessor ordering', () => { const generals: TurnGeneral[] = [ buildGeneral(1, addMinutes(baseTime, 20)), - buildGeneral(2, addMinutes(baseTime, 10)), + { + ...buildGeneral(2, addMinutes(baseTime, 10)), + turnTick: 6_000_004, + recentWarTime: null, + recentWarTick: null, + }, buildGeneral(3, addMinutes(baseTime, 10)), ]; @@ -140,6 +145,11 @@ describe('InMemoryTurnProcessor ordering', () => { const world = new InMemoryTurnWorld(state, snapshot, { schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] }, + generalTurnHandler: { + execute: ({ general }) => ({ + general: general.id === 2 ? { ...general, recentWarTime: new Date(baseTime.getTime()) } : general, + }), + }, }); const executed: number[] = []; @@ -163,6 +173,9 @@ describe('InMemoryTurnProcessor ordering', () => { const tiedGeneralResult = await processor.run(new Date(addMinutes(baseTime, 10).getTime() + 1), budget); expect(tiedGeneralResult.processedTurns).toBe(0); expect(executed).toEqual([2, 3]); + expect(world.getGeneralById(2)?.recentWarTime?.getTime()).toBe(baseTime.getTime()); + expect(world.getGeneralById(2)?.recentWarTick).not.toBeNull(); + expect(Number(world.getGeneralById(2)?.turnTick) % 10).toBe(4); await processor.run(addMinutes(baseTime, 30), budget); diff --git a/packages/logic/src/actions/turn/actionContextHelpers.ts b/packages/logic/src/actions/turn/actionContextHelpers.ts index 49adcc2..8acdee4 100644 --- a/packages/logic/src/actions/turn/actionContextHelpers.ts +++ b/packages/logic/src/actions/turn/actionContextHelpers.ts @@ -157,6 +157,7 @@ const DEFAULT_WAR_CONFIG = { const DEFAULT_AFTER_CONFIG = { techLevelIncYear: 5, initialAllowedTechLevel: 1, + maxTechLevel: 12, defaultCityWall: 1000, baseGold: 0, baseRice: 2000, @@ -235,7 +236,7 @@ export const buildWarAftermathConfig = ( ['initialAllowedTechLevel'], DEFAULT_AFTER_CONFIG.initialAllowedTechLevel ), - maxTechLevel: resolveNumber(constValues, ['maxTechLevel'], 0), + maxTechLevel: resolveNumber(constValues, ['maxTechLevel'], DEFAULT_AFTER_CONFIG.maxTechLevel), defaultCityWall: resolveNumber(constValues, ['defaultCityWall'], DEFAULT_AFTER_CONFIG.defaultCityWall), baseGold: resolveNumber(constValues, ['baseGold', 'basegold'], DEFAULT_AFTER_CONFIG.baseGold), baseRice: resolveNumber(constValues, ['baseRice', 'baserice'], DEFAULT_AFTER_CONFIG.baseRice), diff --git a/packages/logic/src/actions/turn/general/che_임관.ts b/packages/logic/src/actions/turn/general/che_임관.ts index a92640f..081dc9c 100644 --- a/packages/logic/src/actions/turn/general/che_임관.ts +++ b/packages/logic/src/actions/turn/general/che_임관.ts @@ -23,6 +23,7 @@ import { tryApplyUniqueLottery } from '@sammo-ts/logic/rewards/uniqueLottery.js' import type { GeneralTurnCommandSpec } from './index.js'; import { parseArgsWithSchema } from '../parseArgs.js'; import { JosaUtil } from '@sammo-ts/common'; +import { GeneralActionPipeline } from '@sammo-ts/logic/actionModules/general.js'; const ACTION_NAME = '임관'; const ARGS_SCHEMA = z.object({ @@ -43,7 +44,11 @@ export class ActionDefinition< > implements GeneralActionDefinition { public readonly key = 'che_임관'; public readonly name = ACTION_NAME; - constructor(private readonly env: TurnCommandEnv) {} + private readonly pipeline: GeneralActionPipeline; + + constructor(private readonly env: TurnCommandEnv) { + this.pipeline = new GeneralActionPipeline(env.generalActionModules ?? []); + } getInheritanceActiveActionAmount(): number { return 1; @@ -116,7 +121,11 @@ export class ActionDefinition< troopId: 0, experience: context.general.experience + - (context.destNationGeneralCount < this.env.initialNationGenLimit ? 700 : 100), + this.pipeline.onCalcStat( + context, + 'experience', + context.destNationGeneralCount < this.env.initialNationGenLimit ? 700 : 100 + ), meta: { ...context.general.meta, officer_city: 0, diff --git a/packages/logic/src/actions/turn/general/che_출병.ts b/packages/logic/src/actions/turn/general/che_출병.ts index c9e66f8..703d69c 100644 --- a/packages/logic/src/actions/turn/general/che_출병.ts +++ b/packages/logic/src/actions/turn/general/che_출병.ts @@ -64,6 +64,10 @@ export interface DispatchResolveContext< aftermathConfig: WarAftermathConfig; } +export const orderDefenderGenerals = ( + generals: General[] +): General[] => [...generals].sort((left, right) => left.id - right.id); + const ACTION_NAME = '출병'; const ARGS_SCHEMA = z.object({ destCityId: z.number(), @@ -86,7 +90,12 @@ const fixtureNumber = (value: unknown, fallback = 0): number => typeof value === 'number' && Number.isFinite(value) ? value : fallback; const formatFixtureDate = (value: Date | undefined): string => - value ? value.toISOString().replace('T', ' ').replace(/\.\d{3}Z$/u, '') : '1970-01-01 00:00:00'; + value + ? value + .toISOString() + .replace('T', ' ') + .replace(/\.\d{3}Z$/u, '') + : '1970-01-01 00:00:00'; const buildBattleGeneralFixture = (general: General) => { const meta = general.meta; @@ -105,9 +114,7 @@ const buildBattleGeneralFixture = (gen inheritBuff = parsed; } else if (typeof parsed === 'object' && parsed !== null) { inheritBuff = Object.fromEntries( - Object.entries(parsed).filter( - (entry): entry is [string, number] => typeof entry[1] === 'number' - ) + Object.entries(parsed).filter((entry): entry is [string, number] => typeof entry[1] === 'number') ); } } catch { @@ -556,12 +563,14 @@ export class ActionDefinition< defenderCity.meta.term = 3; const defenderNation = defenderCity.nationId > 0 ? (nationMap.get(defenderCity.nationId) ?? null) : null; - const defenderGenerals = generals.filter( - (general) => - general.cityId === defenderCity.id && - general.nationId === defenderCity.nationId && - general.crew > 0 && - (unitSet.crewTypes?.some((crewType) => crewType.id === general.crewTypeId) ?? false) + const defenderGenerals = orderDefenderGenerals( + generals.filter( + (general) => + general.cityId === defenderCity.id && + general.nationId === defenderCity.nationId && + 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 = @@ -647,9 +656,7 @@ export class ActionDefinition< // 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 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) { diff --git a/packages/logic/src/actions/turn/general/legacyCityTrust.ts b/packages/logic/src/actions/turn/general/legacyCityTrust.ts index fb52198..b8dcfed 100644 --- a/packages/logic/src/actions/turn/general/legacyCityTrust.ts +++ b/packages/logic/src/actions/turn/general/legacyCityTrust.ts @@ -3,6 +3,8 @@ * 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); +import { readLegacyStoredFloat, toLegacyStoredFloat } from '@sammo-ts/logic/compat/legacyFloat.js'; -export const readLegacyCityTrust = (value: number): number => Number(Math.fround(value).toPrecision(6)); +export const storeLegacyCityTrust = (value: number): number => toLegacyStoredFloat(value); + +export const readLegacyCityTrust = (value: number): number => readLegacyStoredFloat(value); diff --git a/packages/logic/src/compat/legacyFloat.ts b/packages/logic/src/compat/legacyFloat.ts index 4440b28..2acf50b 100644 --- a/packages/logic/src/compat/legacyFloat.ts +++ b/packages/logic/src/compat/legacyFloat.ts @@ -3,8 +3,27 @@ // 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)); +const roundHalfEven = (value: number): number => { + const lower = Math.floor(value); + const fraction = value - lower; + const tolerance = Number.EPSILON * Math.max(1, Math.abs(value)) * 4; + if (Math.abs(fraction - 0.5) <= tolerance) { + return lower % 2 === 0 ? lower : lower + 1; + } + return Math.round(value); +}; + +export const readLegacyStoredFloat = (value: number): number => { + const stored = Math.fround(value); + if (!Number.isFinite(stored) || stored === 0) { + return stored; + } + const sign = stored < 0 ? -1 : 1; + const absolute = Math.abs(stored); + const exponent = Math.floor(Math.log10(absolute)); + const scale = 10 ** (5 - exponent); + return sign * (roundHalfEven(absolute * scale) / scale); +}; export const addLegacyStoredFloat = (current: number, delta: number): number => toLegacyStoredFloat(readLegacyStoredFloat(current) + delta); diff --git a/packages/logic/src/domain/entities.ts b/packages/logic/src/domain/entities.ts index 9ba9cf8..cec211e 100644 --- a/packages/logic/src/domain/entities.ts +++ b/packages/logic/src/domain/entities.ts @@ -101,7 +101,9 @@ export interface General 0) { + // Ref branches on WarUnitCity::getPhase(), not accumulated city + // casualties. A city can retain dead casualties from earlier battles + // while being conquered before its wall receives a phase. + const cityPhase = cityReport?.phase ?? ((cityReport?.dead ?? 0) > 0 ? 1 : 0); + if (cityPhase > 0) { const crewTypeIndex = buildCrewTypeIndex(input.unitSet); const crewType = crewTypeIndex.get(input.config.castleCrewTypeId); const riceCoef = crewType?.rice ?? 1; diff --git a/packages/logic/src/war/engine.ts b/packages/logic/src/war/engine.ts index bd7052c..956c429 100644 --- a/packages/logic/src/war/engine.ts +++ b/packages/logic/src/war/engine.ts @@ -165,6 +165,7 @@ const resolveUnitReport = (unit: WarUnit): WarUnitReport => { isAttacker: unit.isAttacker(), killed: unit.getKilled(), dead: unit.getDead(), + phase: unit.getPhase(), }; } @@ -176,6 +177,7 @@ const resolveUnitReport = (unit: WarUnit): WarUnitReport => { isAttacker: unit.isAttacker(), killed: unit.getKilled(), dead: unit.getDead(), + phase: unit.getPhase(), }; } @@ -186,6 +188,7 @@ const resolveUnitReport = (unit: WarUnit): WarUnitReport => { isAttacker: unit.isAttacker(), killed: unit.getKilled(), dead: unit.getDead(), + phase: unit.getPhase(), }; }; diff --git a/packages/logic/src/war/types.ts b/packages/logic/src/war/types.ts index 1baeaae..5118618 100644 --- a/packages/logic/src/war/types.ts +++ b/packages/logic/src/war/types.ts @@ -112,6 +112,8 @@ export interface WarUnitReport { isAttacker: boolean; killed: number; dead: number; + /** Number of battle phases consumed by this unit. */ + phase?: number; } export interface WarBattleMetrics { diff --git a/packages/logic/src/war/units/general.ts b/packages/logic/src/war/units/general.ts index 232b660..41d1f3e 100644 --- a/packages/logic/src/war/units/general.ts +++ b/packages/logic/src/war/units/general.ts @@ -112,10 +112,6 @@ export class WarUnitGeneral< super.setOppose(oppose); increaseMetaNumber(this.general.meta, RANK_WARNUM, 1); - if (!oppose) { - return; - } - const baseTurnTime = this.isAttacker() ? this.general.turnTime : oppose instanceof WarUnitGeneral @@ -126,6 +122,16 @@ export class WarUnitGeneral< } const phase = clamp(this.getRealPhase(), 0, 99); this.general.recentWarTime = new Date(baseTurnTime.getTime()); + const baseTurnTick = this.isAttacker() + ? this.general.turnTick + : oppose instanceof WarUnitGeneral + ? oppose.general.turnTick + : this.general.turnTick; + if (baseTurnTick !== undefined) { + this.general.recentWarTick = baseTurnTick - (baseTurnTick % 100) + phase; + } else { + delete this.general.recentWarTick; + } this.general.meta.recent_war_phase = phase; } diff --git a/packages/logic/test/dispatchWarAction.test.ts b/packages/logic/test/dispatchWarAction.test.ts index d2fe259..2757ce8 100644 --- a/packages/logic/test/dispatchWarAction.test.ts +++ b/packages/logic/test/dispatchWarAction.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'; import type { City, General, Nation } from '../src/domain/entities.js'; import { resolveGeneralAction } from '../src/actions/engine.js'; -import { ActionDefinition } from '../src/actions/turn/general/che_출병.js'; +import { ActionDefinition, orderDefenderGenerals } from '../src/actions/turn/general/che_출병.js'; import type { DispatchResolveContext } from '../src/actions/turn/general/che_출병.js'; import type { TurnSchedule } from '../src/turn/calendar.js'; import type { WarAftermathConfig, WarEngineConfig } from '../src/war/types.js'; @@ -183,6 +183,7 @@ describe('che_출병', () => { const defenderCity = buildCity(2, defenderNation.id); const neutralCity = buildCity(3, 0); const attacker = buildGeneral(1, attackerNation.id, attackerCity.id); + attacker.turnTime = new Date('2000-01-01T00:00:00Z'); const defender = buildGeneral(2, defenderNation.id, defenderCity.id); defender.crew = 0; defenderCity.defence = 0; @@ -204,9 +205,36 @@ describe('che_출병', () => { 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 } }, + { + 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: [ @@ -235,6 +263,7 @@ describe('che_출병', () => { ); expect(resolution.logs.length).toBeGreaterThan(0); + expect(resolution.general.recentWarTime?.toISOString()).toBe(attacker.turnTime.toISOString()); 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({ @@ -250,6 +279,13 @@ describe('che_출병', () => { ).toBe(true); }); + it('orders equal-priority defender inputs by general number before the stable battle sort', () => { + const defender2 = buildGeneral(2, 2, 2); + const defender3 = buildGeneral(3, 2, 2); + + expect(orderDefenderGenerals([defender3, defender2]).map((general) => general.id)).toEqual([2, 3]); + }); + it('prefers an enemy on the shortest route layer before considering the next layer', () => { const attackerNation = buildNation(1); const attackerCity = buildCity(1, attackerNation.id); diff --git a/packages/logic/test/scenarios/general_commands_new.test.ts b/packages/logic/test/scenarios/general_commands_new.test.ts index 9caa166..13b67b0 100644 --- a/packages/logic/test/scenarios/general_commands_new.test.ts +++ b/packages/logic/test/scenarios/general_commands_new.test.ts @@ -20,6 +20,7 @@ import { commandSpec as destroySpec } from '../../src/actions/turn/general/che_ import { commandSpec as agitateSpec } from '../../src/actions/turn/general/che_선동.js'; import { commandSpec as seizeSpec } from '../../src/actions/turn/general/che_탈취.js'; import { commandSpec as fireSpec } from '../../src/actions/turn/general/che_화계.js'; +import { ActionDefinition as AppointmentAction } from '../../src/actions/turn/general/che_임관.js'; import type { TurnCommandEnv } from '../../src/actions/turn/commandEnv.js'; import { createItemActionModules, @@ -42,6 +43,7 @@ import { import { readLegacyCityTrust, storeLegacyCityTrust } from '../../src/actions/turn/general/legacyCityTrust.js'; import { roundLegacyRecruitCost } from '../../src/actions/turn/general/che_징병.js'; import { resolveLegacyDomesticTrust } from '../../src/actions/turn/general/che_상업투자.js'; +import { traitModule as ambitiousPersonality } from '../../src/actionModules/traits/personality/che_출세.js'; describe('General Commands New Scenario', () => { it('truncates generated NPC dex like GeneralBuilder integer arguments', () => { @@ -75,6 +77,8 @@ describe('General Commands New Scenario', () => { 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)); + expect(readLegacyStoredTech(533.3125)).toBe(533.312); + expect(readLegacyStoredTech(533.4375)).toBe(533.438); }); it('separates MariaDB FLOAT trust storage from its six-digit PHP read value', () => { @@ -84,6 +88,7 @@ describe('General Commands New Scenario', () => { expect(stored).not.toBe(readLegacyCityTrust(stored)); expect(readLegacyCityTrust(stored)).toBe(88.3068); expect(readLegacyCityTrust(storeLegacyCityTrust(readLegacyCityTrust(stored) + 10))).toBe(98.3068); + expect(readLegacyCityTrust(storeLegacyCityTrust(93.40625))).toBe(93.4062); }); it('rounds recruitment cost across the PHP half boundary', () => { @@ -100,6 +105,38 @@ describe('General Commands New Scenario', () => { expect(resolveLegacyDomesticTrust(null)).toBe(50); }); + it('applies the personality experience modifier to appointment rewards', () => { + const action = new AppointmentAction({ + initialNationGenLimit: 10, + generalActionModules: [ambitiousPersonality], + } as unknown as TurnCommandEnv); + const general = { + id: 1, + name: 'General', + experience: 1_000, + role: { + personality: 'che_출세', + specialDomestic: null, + specialWar: null, + items: { horse: null, weapon: null, book: null, item: null }, + }, + meta: {}, + } as General; + const result = action.resolve( + { + general, + destNation: { id: 2, name: 'Nation', meta: {} } as Nation, + destNationGeneralCount: 10, + destCityId: 3, + addLog: () => undefined, + } as unknown as Parameters[0], + { destNationId: 2 } + ); + const generalPatch = result.effects.find((effect) => effect.type === 'general:patch'); + + expect(generalPatch).toMatchObject({ patch: { experience: 1_110 } }); + }); + // 1. Setup Environment const systemEnv: TurnCommandEnv = { develCost: 100, diff --git a/packages/logic/test/warAftermath.test.ts b/packages/logic/test/warAftermath.test.ts index 01dfd5b..8c81aa6 100644 --- a/packages/logic/test/warAftermath.test.ts +++ b/packages/logic/test/warAftermath.test.ts @@ -8,6 +8,8 @@ import type { UnitSetDefinition } from '../src/world/types.js'; import { resolveWarAftermath } from '../src/war/aftermath.js'; import type { WarAftermathConfig } from '../src/war/types.js'; import { LogFormat } from '../src/logging/types.js'; +import { buildWarAftermathConfig } from '../src/actions/turn/actionContextHelpers.js'; +import type { ScenarioConfig } from '../src/scenario/types.js'; const buildUnitSet = (): UnitSetDefinition => ({ id: 'test', @@ -129,6 +131,12 @@ const buildGeneral = (id: number, nationId: number, cityId: number): General => }); describe('war aftermath', () => { + it('defaults the omitted legacy maximum tech level to 12', () => { + const config = buildWarAftermathConfig({ const: {} } as ScenarioConfig, 999); + + expect(config.maxTechLevel).toBe(12); + }); + it('updates tech and diplomacy deltas', () => { const attackerNation = buildNation(1); const defenderNation = buildNation(2); @@ -268,6 +276,51 @@ describe('war aftermath', () => { ); }); + it('uses the city battle phase, not retained casualties, for conquered supply-city rice', () => { + const attackerNation = buildNation(1); + const defenderNation = buildNation(2); + defenderNation.rice = 6000; + defenderNation.capitalCityId = 3; + const attackerCity = buildCity(1, 1); + const defenderCity = buildCity(2, 2); + const defenderCapital = buildCity(3, 2); + defenderCity.meta.supply = 1; + const attacker = buildGeneral(1, 1, 1); + + resolveWarAftermath({ + battle: { + attacker, + defenders: [], + defenderCity, + logs: [], + conquered: true, + reports: [ + { + id: defenderCity.id, + type: 'city', + name: defenderCity.name, + isAttacker: false, + killed: 0, + dead: 100, + phase: 0, + }, + ], + }, + attackerNation, + defenderNation, + attackerCity, + defenderCity, + nations: [attackerNation, defenderNation], + cities: [attackerCity, defenderCity, defenderCapital], + generals: [attacker], + unitSet: buildUnitSet(), + config: buildConfig(), + time: { year: 200, month: 1, startYear: 180 }, + }); + + expect(defenderNation.rice).toBe(6500); + }); + it('applies conquest collapse rewards', () => { const rng = new RandUtil(new ConstantRNG(0)); const attackerNation = buildNation(1); diff --git a/packages/logic/test/warEngine.test.ts b/packages/logic/test/warEngine.test.ts index 24bf6f9..119cabd 100644 --- a/packages/logic/test/warEngine.test.ts +++ b/packages/logic/test/warEngine.test.ts @@ -312,6 +312,7 @@ describe('war triggers', () => { const city = buildCity(); const attackerGeneral = buildGeneral(80); attackerGeneral.turnTime = new Date('2026-07-26T13:38:45.000Z'); + attackerGeneral.turnTick = 123_456; const attacker = new WarUnitGeneral( rng, @@ -338,8 +339,13 @@ describe('war triggers', () => { attacker.setOppose(defender); defender.setOppose(attacker); expect(attackerGeneral.recentWarTime?.toISOString()).toBe('2026-07-26T13:38:45.000Z'); + expect(attackerGeneral.recentWarTick).toBe(123_400); expect(attackerGeneral.meta.recent_war_phase).toBe(0); + attackerGeneral.turnTick = 123_556; + attacker.setOppose(null); + expect(attackerGeneral.recentWarTick).toBe(123_500); + attacker.beginPhase(); const [module] = await loadWarTriggerModules(['che_필살']);