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
@@ -36,6 +36,7 @@ export interface ActionContextWorldState {
currentMonth: number;
tickSeconds: number;
lastTurnTime?: Date;
lastTurnTick?: number;
meta?: Record<string, unknown>;
}
@@ -1,4 +1,4 @@
import type { RandomGenerator } from '@sammo-ts/common';
import { GAME_TICKS_PER_TURN, JosaUtil, type RandomGenerator } from '@sammo-ts/common';
import type {
City,
General,
@@ -19,7 +19,6 @@ import type {
import { createGeneralAddEffect } from '@sammo-ts/logic/actions/engine.js';
import { LogCategory, LogFormat, LogScope } from '@sammo-ts/logic/logging/types.js';
import { buildRecruitmentGeneral } from './recruitment.js';
import { JosaUtil } from '@sammo-ts/common';
import type { ActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js';
import { buildWorldSummary } from '@sammo-ts/logic/actions/turn/actionContextHelpers.js';
import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js';
@@ -59,6 +58,8 @@ export interface TalentScoutResolveContext<
createGeneralId: () => number;
turnTermMinutes: number;
turnTimeBase: Date;
turnTimeBaseTick?: number;
ticksPerSecond: number;
}
export interface TalentScoutEnvironment {
@@ -439,6 +440,12 @@ 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 turnTick =
context.turnTimeBaseTick === undefined
? undefined
: context.turnTimeBaseTick +
turnSecond * context.ticksPerSecond +
Math.floor((turnFraction * context.ticksPerSecond) / 1_000_000);
const turnTime = new Date(
context.turnTimeBase.getTime() + turnSecond * 1_000 + Math.floor(turnFraction / 1_000)
);
@@ -489,6 +496,7 @@ export class ActionResolver<
meta,
}),
turnTime,
...(turnTick === undefined ? {} : { turnTick }),
bornYear: birthYear,
deadYear: deathYear,
affinity,
@@ -602,6 +610,8 @@ export const actionContextBuilder: ActionContextBuilder = (base, options) => ({
// 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,
turnTimeBaseTick: options.world.lastTurnTick,
ticksPerSecond: GAME_TICKS_PER_TURN / options.world.tickSeconds,
});
export const commandSpec: GeneralTurnCommandSpec = {
@@ -1,4 +1,4 @@
import type { RandomGenerator } from '@sammo-ts/common';
import { GAME_TICKS_PER_TURN, JosaUtil, type RandomGenerator } from '@sammo-ts/common';
import type {
General,
GeneralMeta,
@@ -25,7 +25,6 @@ import type {
import { createGeneralAddEffect, createLogEffect } from '@sammo-ts/logic/actions/engine.js';
import { LogCategory, LogFormat, LogScope } from '@sammo-ts/logic/logging/types.js';
import { buildRecruitmentGeneral } from '@sammo-ts/logic/actions/turn/general/recruitment.js';
import { JosaUtil } from '@sammo-ts/common';
import type { ActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js';
import {
buildAverageNationGeneralCount,
@@ -64,6 +63,8 @@ export interface VolunteerRecruitResolveContext<
createGeneralId: () => number;
turnTermSeconds: number;
turnTimeBase: Date;
turnTimeBaseTick?: number;
ticksPerSecond: number;
}
export interface VolunteerRecruitEnvironment {
@@ -398,6 +399,12 @@ export class ActionResolver<
candidate.personality ?? legacyChoice(context.rng, this.env.availablePersonalities ?? ['che_안전']);
const turnSecond = randomRangeInt(context.rng, 0, context.turnTermSeconds - 1);
const turnFraction = randomRangeInt(context.rng, 0, 999_999);
const turnTick =
context.turnTimeBaseTick === undefined
? undefined
: context.turnTimeBaseTick +
turnSecond * context.ticksPerSecond +
Math.floor((turnFraction * context.ticksPerSecond) / 1_000_000);
const turnTime = new Date(
context.turnTimeBase.getTime() + turnSecond * 1_000 + Math.floor(turnFraction / 1_000)
);
@@ -444,6 +451,7 @@ export class ActionResolver<
meta,
}),
turnTime,
...(turnTick === undefined ? {} : { turnTick }),
};
effects.push(createGeneralAddEffect(newGeneral));
}
@@ -520,6 +528,8 @@ export const actionContextBuilder: ActionContextBuilder = (base, options) => {
createGeneralId: options.createGeneralId,
turnTermSeconds: Math.max(1, Math.round(options.world.tickSeconds)),
turnTimeBase: options.world.lastTurnTime ?? base.general.turnTime,
turnTimeBaseTick: options.world.lastTurnTick,
ticksPerSecond: GAME_TICKS_PER_TURN / options.world.tickSeconds,
};
};
+21 -6
View File
@@ -143,20 +143,35 @@ const canonicalizeWarTrait = (raw: string | null | undefined): string | 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.
// war catalogue first, then the domestic catalogue, and persists the resolved
// class code. An explicit war slot keeps the single speciality in the domestic
// slot. 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 inferredWar = canonicalizeWarTrait(special);
const retainedDomestic = special && special !== 'None' ? special : null;
const retainedWar = explicitWar && explicitWar !== 'None' ? explicitWar : null;
const resolvedExplicitWar = canonicalizeWarTrait(explicitWar) ?? retainedWar;
if (resolvedExplicitWar !== null) {
return {
specialDomestic: domestic ?? retainedDomestic,
specialWar: resolvedExplicitWar,
};
}
if (inferredWar !== null) {
return {
specialDomestic: null,
specialWar: inferredWar,
};
}
return {
specialDomestic: domestic ?? (inferredWar === null ? retainedDomestic : null),
specialWar: canonicalizeWarTrait(explicitWar) ?? inferredWar ?? retainedWar,
specialDomestic: domestic ?? retainedDomestic,
specialWar: null,
};
};
+6 -6
View File
@@ -192,17 +192,17 @@ describe('scenario bootstrap', () => {
expect(result.snapshot.generals[0]?.role.specialWar).toBeNull();
expect(result.snapshot.generals[1]?.role).toMatchObject({
personality: 'che_출세',
specialDomestic: 'che_event_의술',
specialWar: null,
specialDomestic: null,
specialWar: 'che_의술',
});
expect(result.seed.generals[1]).toMatchObject({
special: 'che_event_의술',
specialWar: null,
special: null,
specialWar: 'che_의술',
});
expect(result.snapshot.generals[2]?.role).toMatchObject({
personality: 'che_패권',
specialDomestic: 'che_event_돌격',
specialWar: null,
specialDomestic: null,
specialWar: 'che_돌격',
});
expect(result.snapshot.generals[0]?.meta).toMatchObject({
explevel: 0,