fix(parity): preserve legacy monthly turn ordering

This commit is contained in:
2026-08-15 09:40:14 +00:00
parent 47e629eb4b
commit b9e45c6e76
26 changed files with 393 additions and 58 deletions
+16 -1
View File
@@ -858,7 +858,22 @@ export class GeneralAI {
continue;
}
if (candidate.stats.leadership >= this.nationPolicy.minNpcWarLeadership) {
const fullLeadership = this.commandEnv.generalActionModules
? resolveLegacyAiStatsWithModules(
candidate,
this.nation,
this.commandEnv.maxStatLevel ?? this.scenarioConfig.stat.max,
this.commandEnv.generalActionModules,
this.worldRef,
this.world,
this.startYear
).fullLeadership
: resolveLegacyAiStats(
candidate,
this.nation,
this.commandEnv.maxStatLevel ?? this.scenarioConfig.stat.max
).fullLeadership;
if (fullLeadership >= this.nationPolicy.minNpcWarLeadership) {
npcWarGenerals[candidate.id] = candidate;
} else {
npcCivilGenerals[candidate.id] = candidate;
@@ -41,7 +41,11 @@ export const doNPC헌납 = (ai: GeneralAI) => {
genRes >= ai.aiConst.minNationalRice / 2
) {
const amount = genRes < ai.aiConst.minNationalRice ? genRes : genRes / 2;
args.push([{ isGold: false, amount }, amount]);
// Ref passes the literal string "rice" here. che_헌납::argTest()
// rejects that candidate because isGold is not boolean; preserving
// the malformed weighted candidate also preserves the RNG draw and
// lets the priority loop continue when it is selected.
args.push([{ isGold: 'rice', amount }, amount]);
}
if (genRes < reqRes * 1.5) {
continue;
@@ -128,11 +128,14 @@ export const do부대후방발령 = (ai: GeneralAI) => {
return null;
}
// Ref consumes the troop-leader draw before the destination-city draw.
// Both selections share the nation command RNG, so reversing them can
// assign the same two outcomes to different leaders and cities.
const leader = ai.rng.choice(troopCandidates);
const destCityId = Number(ai.rng.choiceUsingWeight(cityCandidates));
if (!Number.isFinite(destCityId)) {
return null;
}
const leader = ai.rng.choice(troopCandidates);
return buildAssignmentCandidate(ai, leader.id, destCityId, '부대후방발령');
};
@@ -162,10 +165,10 @@ export const do부대구출발령 = (ai: GeneralAI) => {
return null;
}
const leader = ai.rng.choice(troopCandidates);
const destCityId = pickRandomCityId(ai, ai.frontCities);
if (destCityId === null) {
return null;
}
const leader = ai.rng.choice(troopCandidates);
return buildAssignmentCandidate(ai, leader.id, destCityId, '부대구출발령');
};
@@ -1,4 +1,5 @@
import type { GeneralAI } from '../core.js';
import { GeneralActionPipeline } from '@sammo-ts/logic/actionModules/general.js';
import { findCrewTypeById, getTechCost } from '@sammo-ts/logic/world/unitSet.js';
import type { TurnGeneral } from '../../../types.js';
import { asRecord, readMetaNumber, readRequiredMetaNumber } from '../../aiUtils.js';
@@ -45,6 +46,35 @@ const clampLegacy = (value: number, min: number | null, max: number | null): num
};
const getFullLeadership = (ai: GeneralAI, general: TurnGeneral): number => {
const modules = ai.commandEnv.generalActionModules;
if (modules && modules.length > 0) {
const pipeline = new GeneralActionPipeline(modules);
const adjusted = pipeline.onCalcStat(
{
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,
},
},
'leadership',
general.stats.leadership
);
const maxStat = ai.commandEnv.maxStatLevel ?? ai.scenarioConfig.stat.max;
return Math.trunc(Math.max(0, Math.min(Number(adjusted), maxStat)));
}
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;
+10 -11
View File
@@ -48,7 +48,9 @@ export interface DatabaseTurnHooks {
}
const uniqueSortedIds = (values: Iterable<number>): number[] =>
[...new Set(values)].filter((value) => Number.isSafeInteger(value) && value > 0).sort((left, right) => left - right);
[...new Set(values)]
.filter((value) => Number.isSafeInteger(value) && value > 0)
.sort((left, right) => left - right);
export type ReadModelSignatures = {
content: string;
@@ -153,7 +155,8 @@ const changedProjectionIds = (
baseline: ReadonlyMap<number, ReadModelSignatures>,
final: ReadonlyMap<number, ReadModelSignatures>,
projection: keyof ReadModelSignatures
): number[] => uniqueSortedIds(candidateIds.filter((id) => baseline.get(id)?.[projection] !== final.get(id)?.[projection]));
): number[] =>
uniqueSortedIds(candidateIds.filter((id) => baseline.get(id)?.[projection] !== final.get(id)?.[projection]));
const buildFinalSignatures = <Entity extends { id: number }>(
entities: readonly Entity[],
@@ -219,10 +222,7 @@ export const summarizeRealtimeReadModelChanges = (
...changes.deletedNations,
...changes.deletedNationSnapshots.map((snapshot) => snapshot.nation.id),
]);
const finalGenerals = buildFinalSignatures(
[...changes.generals, ...changes.createdGenerals],
generalSignatures
);
const finalGenerals = buildFinalSignatures([...changes.generals, ...changes.createdGenerals], generalSignatures);
const finalCities = buildFinalSignatures(changes.cities, citySignatures);
const finalNations = buildFinalSignatures([...changes.nations, ...changes.createdNations], nationSignatures);
const generalIds = baseline
@@ -237,9 +237,7 @@ export const summarizeRealtimeReadModelChanges = (
const mapGeneralIds = baseline
? changedProjectionIds(generalCandidates, baseline.generals, finalGenerals, 'map')
: generalIds;
const mapCityIds = baseline
? changedProjectionIds(cityCandidates, baseline.cities, finalCities, 'map')
: cityIds;
const mapCityIds = baseline ? changedProjectionIds(cityCandidates, baseline.cities, finalCities, 'map') : cityIds;
const mapNationIds = baseline
? changedProjectionIds(nationCandidates, baseline.nations, finalNations, 'map')
: nationIds;
@@ -760,13 +758,14 @@ const buildGeneralCreate = (
const buildCityUpdate = (
city: ReturnType<InMemoryTurnWorld['consumeDirtyState']>['cities'][number]
): TurnEngineCityUpdateInput => {
const meta = {
const meta: Record<string, unknown> = {
...(city.meta as Record<string, unknown>),
state: city.state,
};
const trust = readMetaNumber(meta, 'trust');
const trade = readMetaNumber(meta, 'trade');
const region = readMetaNumber(meta, 'region');
const { trust: _projectedTrust, trade: _projectedTrade, region: _projectedRegion, ...persistedMeta } = meta;
const data: TurnEngineCityUpdateInput = {
name: city.name,
@@ -787,7 +786,7 @@ const buildCityUpdate = (
wall: city.wall,
wallMax: city.wallMax,
...(city.conflict ? { conflict: asJson(city.conflict) } : {}),
meta: asJson(meta),
meta: asJson(persistedMeta),
};
if (trust !== null) {
@@ -100,6 +100,7 @@ export class InMemoryTurnProcessor implements TurnProcessor {
processedGenerals += 1;
nextCheckpoint = {
turnTime: executedAt.toISOString(),
turnTick: general.turnTick,
generalId: general.id,
year: this.world.getState().currentYear,
month: this.world.getState().currentMonth,
+22
View File
@@ -190,6 +190,12 @@ export interface InMemoryTurnWorldInspection {
}
const compareTurnOrder = (left: TurnGeneral, right: TurnGeneral): number => {
if (left.turnTick !== undefined && right.turnTick !== undefined) {
const tickDiff = left.turnTick - right.turnTick;
if (tickDiff !== 0) {
return tickDiff;
}
}
const timeDiff = left.turnTime.getTime() - right.turnTime.getTime();
if (timeDiff !== 0) {
return timeDiff;
@@ -201,6 +207,18 @@ const shouldProcessByCheckpoint = (general: TurnGeneral, checkpoint?: TurnCheckp
if (!checkpoint) {
return true;
}
if (general.turnTick !== undefined && checkpoint.turnTick !== undefined) {
if (general.turnTick < checkpoint.turnTick) {
return false;
}
if (general.turnTick > checkpoint.turnTick) {
return true;
}
if (checkpoint.generalId === undefined) {
return false;
}
return general.id > checkpoint.generalId;
}
const generalTime = general.turnTime.getTime();
const checkpointTime = new Date(checkpoint.turnTime).getTime();
if (generalTime < checkpointTime) {
@@ -1211,10 +1229,14 @@ export class InMemoryTurnWorld {
listDueGenerals(targetTime: Date, checkpoint?: TurnCheckpoint): TurnGeneral[] {
const targetMs = targetTime.getTime();
const targetTick = this.getGameClock().dateToTick(targetTime);
const due = Array.from(this.generals.values()).filter((general) => {
if (!shouldProcessByCheckpoint(general, checkpoint)) {
return false;
}
if (general.turnTick !== undefined) {
return general.turnTick <= targetTick;
}
return general.turnTime.getTime() <= targetMs;
});
due.sort(compareTurnOrder);
@@ -1,4 +1,4 @@
import { JosaUtil, LiteHashDRBG, RandUtil, asRecord } from '@sammo-ts/common';
import { GAME_TICKS_PER_TURN, JosaUtil, LiteHashDRBG, RandUtil, asRecord } from '@sammo-ts/common';
import { LogCategory, LogFormat, LogScope, type TurnCommandEnv } from '@sammo-ts/logic';
import { simpleSerialize } from '@sammo-ts/logic/war/utils.js';
@@ -137,10 +137,12 @@ const buildNpc = (options: {
}
const turnSecond = rng.nextRangeInt(0, 60 * turnMinutes - 1);
const turnFraction = rng.nextRangeInt(0, 999_999);
// core DB는 millisecond precision이므로 레거시 microsecond 값을 내림해
// 저장한다. 먼 과거 연도에서 IEEE-754 덧셈이 반올림하지 않도록 먼저
// 정수화한다.
const turnTime = new Date(environment.turnTime.getTime() + turnSecond * 1_000 + Math.floor(turnFraction / 1_000));
const ticksPerSecond = GAME_TICKS_PER_TURN / world.getState().tickSeconds;
const turnTick =
world.dateToGameTick(environment.turnTime) +
turnSecond * ticksPerSecond +
Math.floor((turnFraction * ticksPerSecond) / 1_000_000);
const turnTime = world.gameTickToDate(turnTick);
const killturn = (deadYear - environment.year) * 12 + rng.nextRangeInt(0, 11) + environment.month - 1;
const id = world.getNextGeneralId();
const general: TurnGeneral = {
@@ -181,6 +183,7 @@ const buildNpc = (options: {
},
lastTurn: { command: '휴식' },
turnTime,
turnTick,
recentWarTime: null,
meta: {
killturn,
@@ -1,4 +1,4 @@
import { LiteHashDRBG, RandUtil, asRecord } from '@sammo-ts/common';
import { GAME_TICKS_PER_TURN, LiteHashDRBG, RandUtil, asRecord } from '@sammo-ts/common';
import {
LogCategory,
LogFormat,
@@ -145,9 +145,12 @@ const createNpcGeneral = (options: {
}
const turnSecond = rng.nextRangeInt(0, turnMinutes * 60 - 1);
const turnFraction = rng.nextRangeInt(0, 999_999);
const turnTime = new Date(
environment.turnTime.getTime() + turnSecond * 1_000 + Math.floor(turnFraction / 1_000)
);
const ticksPerSecond = GAME_TICKS_PER_TURN / world.getState().tickSeconds;
const turnTick =
world.dateToGameTick(environment.turnTime) +
turnSecond * ticksPerSecond +
Math.floor((turnFraction * ticksPerSecond) / 1_000_000);
const turnTime = world.gameTickToDate(turnTick);
const killturn =
options.killturn ??
(options.deadYear - environment.year) * 12 +
@@ -188,6 +191,7 @@ const createNpcGeneral = (options: {
triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} },
lastTurn: { command: '휴식' },
turnTime,
turnTick,
recentWarTime: null,
meta: {
killturn,
@@ -1,4 +1,4 @@
import { JosaUtil, LiteHashDRBG, RandUtil, asRecord } from '@sammo-ts/common';
import { GAME_TICKS_PER_TURN, JosaUtil, LiteHashDRBG, RandUtil, asRecord } from '@sammo-ts/common';
import {
DOMESTIC_TRAIT_KEYS,
LogCategory,
@@ -307,9 +307,12 @@ export const createRegisterNpcHandler = (options: {
}
const turnSecond = rng.nextRangeInt(0, 60 * turnMinutes - 1);
const turnFraction = rng.nextRangeInt(0, 999_999);
const turnTime = new Date(
environment.turnTime.getTime() + turnSecond * 1_000 + Math.floor(turnFraction / 1_000)
);
const ticksPerSecond = GAME_TICKS_PER_TURN / world.getState().tickSeconds;
const turnTick =
world.dateToGameTick(environment.turnTime) +
turnSecond * ticksPerSecond +
Math.floor((turnFraction * ticksPerSecond) / 1_000_000);
const turnTime = world.gameTickToDate(turnTick);
const killturn =
(parsed.deathYear - environment.year) * 12 +
rng.nextRangeInt(0, 11) +
@@ -358,6 +361,7 @@ export const createRegisterNpcHandler = (options: {
},
lastTurn: { command: '휴식' },
turnTime,
turnTick,
recentWarTime: null,
meta: {
killturn,
@@ -44,7 +44,6 @@ import { asRecord, JosaUtil, LEGACY_RANK_DATA_TYPES, LiteHashDRBG, RandUtil } fr
import type { ConstraintContext, StateView } from '@sammo-ts/logic';
import type { GeneralTurnHandler, GeneralTurnResult } from './inMemoryWorld.js';
import { normalizeGeneralDatabaseIntegers } from './inMemoryWorld.js';
import type { InMemoryTurnWorld } from './inMemoryWorld.js';
import type { TurnDiplomacy, TurnGeneral, TurnWorldState } from './types.js';
import type { ReservedTurnEntry } from './reservedTurnStore.js';
@@ -1692,11 +1691,11 @@ export const createReservedTurnHandler = async (options: {
nationAiState = ai.getDebugState();
}
const nationResult = runAction('nation', nationDefinitions, nationFallback, nationCommand, false);
// Ref persists a completed nation command before it chooses and
// executes the general command for the same turn. Preserve that
// MariaDB INT boundary so fractional rewards cannot leak into the
// following command or its AI refresh.
currentGeneral = normalizeGeneralDatabaseIntegers(currentGeneral);
// Ref persists the nation command here, but LazyVarUpdater only
// clears its dirty flags: it does not replace the same PHP
// General object's fractional values with the MariaDB INT row.
// The following general command therefore observes and adds to
// those fractions before the turn's final persistence boundary.
worldOverlay?.syncGeneral(currentGeneral);
if (
worldView &&
+3 -1
View File
@@ -278,7 +278,9 @@ const mapGeneralRow = (
};
const mapCityRow = (row: TurnEngineCityRow): City => {
const meta = asTriggerRecord(row.meta);
// trust/trade/region are projected columns. Old flushes also copied them
// into JSON meta; never let a stale duplicate override a nullable column.
const { trust: _storedTrust, trade: _storedTrade, region: _storedRegion, ...meta } = asTriggerRecord(row.meta);
const state = typeof meta.state === 'number' && Number.isFinite(meta.state) ? Math.floor(meta.state) : 0;
return {
id: row.id,