fix: 커맨드 차등 생명주기와 로그 그래프를 보강
장수 55종과 수뇌 35종의 상태, 로그, 메시지, 예약 턴 비교를 닫습니다. 실제 시나리오 프로필과 대표 PostgreSQL 수명주기, 즉시 외교와 출병 회귀를 추가하고 발견된 Ref 로그 및 생성 장수 저장 차이를 교정합니다.
This commit is contained in:
@@ -49,7 +49,7 @@ import type { DatabaseTurnDaemonLease } from '../lifecycle/databaseTurnDaemonLea
|
||||
import { calculateNationBettingRewards } from '../betting/nationBettingSettlement.js';
|
||||
import type { NationBettingCandidate, PendingNationBettingFinish, PendingNationBettingOpen } from './types.js';
|
||||
import type { TurnGeneral } from './types.js';
|
||||
import { buildPersistedRankRows } from './rankData.js';
|
||||
import { buildInitialRankRows, buildPersistedRankRows } from './rankData.js';
|
||||
import { persistUnificationFinalization } from './unificationPersistence.js';
|
||||
import { buildOldNationArchiveData } from './oldNationArchive.js';
|
||||
import { persistYearbookSnapshot } from './yearbookPersistence.js';
|
||||
@@ -769,17 +769,6 @@ const buildPersistedGeneralMeta = (
|
||||
return asJson(meta);
|
||||
};
|
||||
|
||||
const buildInitialRankRows = (
|
||||
general: ReturnType<InMemoryTurnWorld['consumeDirtyState']>['generals'][number]
|
||||
): Array<{ generalId: number; nationId: number; type: string; value: number }> =>
|
||||
buildPersistedRankRows(general).map((row) => ({
|
||||
...row,
|
||||
nationId: 0,
|
||||
// Ref Join은 전체 rank_data를 0으로 만든 직후 장수 생성에 사용한
|
||||
// 유산 포인트만 inherit_spent_dyn에 반영한다.
|
||||
value: row.type === 'inherit_spent_dyn' ? row.value : 0,
|
||||
}));
|
||||
|
||||
const RANK_DATA_UPSERT_BATCH_SIZE = 1_000;
|
||||
|
||||
const upsertRankRows = async (
|
||||
|
||||
@@ -1933,9 +1933,17 @@ export class InMemoryTurnWorld {
|
||||
continue;
|
||||
}
|
||||
delete conflict[key];
|
||||
// Ref decodes a non-empty JSON object into a PHP array. Removing
|
||||
// its last nation key and encoding that value persists `[]`, not
|
||||
// `{}`. Preserve that observable storage shape until the next
|
||||
// world load (where an empty conflict is normalized for logic).
|
||||
const persistedConflict =
|
||||
Object.keys(conflict).length === 0
|
||||
? ([] as unknown as City['conflict'])
|
||||
: (conflict as City['conflict']);
|
||||
this.cities.set(city.id, {
|
||||
...city,
|
||||
conflict: conflict as City['conflict'],
|
||||
conflict: persistedConflict,
|
||||
});
|
||||
this.dirtyCityIds.add(city.id);
|
||||
}
|
||||
|
||||
@@ -64,12 +64,34 @@ export const buildPersistedRankRows = (general: RankedGeneralState): PersistedRa
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Ref GeneralBuilder/Join initializes every rank row in nation 0 with value 0.
|
||||
* The one exception is a user-creation inheritance debit already carried in
|
||||
* `inherit_spent_dyn`. Keep this persistence boundary shared by the database
|
||||
* hooks and differential projection.
|
||||
*/
|
||||
export const buildInitialRankRows = (general: RankedGeneralState): PersistedRankRow[] =>
|
||||
buildPersistedRankRows(general).map((row) => ({
|
||||
...row,
|
||||
nationId: 0,
|
||||
value: row.type === 'inherit_spent_dyn' ? row.value : 0,
|
||||
}));
|
||||
|
||||
export const buildLegacyComparableInitialRankRows = (
|
||||
general: RankedGeneralState
|
||||
): Array<PersistedRankRow & { type: LegacyRankDataType }> => {
|
||||
const legacyTypes = new Set<RankDataType>(LEGACY_RANK_DATA_TYPES);
|
||||
return buildInitialRankRows(general).filter((row): row is PersistedRankRow & { type: LegacyRankDataType } =>
|
||||
legacyTypes.has(row.type)
|
||||
);
|
||||
};
|
||||
|
||||
export const buildLegacyComparableRankRows = (
|
||||
general: RankedGeneralState
|
||||
): Array<PersistedRankRow & { type: LegacyRankDataType }> => {
|
||||
const legacyTypes = new Set<RankDataType>(LEGACY_RANK_DATA_TYPES);
|
||||
return buildPersistedRankRows(general).filter(
|
||||
(row): row is PersistedRankRow & { type: LegacyRankDataType } => legacyTypes.has(row.type)
|
||||
return buildPersistedRankRows(general).filter((row): row is PersistedRankRow & { type: LegacyRankDataType } =>
|
||||
legacyTypes.has(row.type)
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
getNextTurnAt,
|
||||
getBillByLevel,
|
||||
LEGACY_DEFAULT_MAX_LEVEL,
|
||||
orderLegacyActionLoggerFlush,
|
||||
type ItemModule,
|
||||
type UniqueLotteryRunner,
|
||||
} from '@sammo-ts/logic';
|
||||
@@ -121,6 +122,42 @@ const LEGACY_STAT_CHANGE_GENERAL_ACTIONS = new Set([
|
||||
'che_전투태세',
|
||||
]);
|
||||
|
||||
// 아래 Ref 커맨드는 성공 로그보다 addExperience/addDedication을 먼저 호출한다.
|
||||
// 이 차이는 같은 ActionLogger의 GENERAL/ACTION 버퍼 안에서 보이며, 등용수락은
|
||||
// 메시지 즉시 실행과 예약 턴 차등 fixture 모두 같은 순서를 사용한다.
|
||||
const LEGACY_PROGRESSION_BEFORE_ACTION_LOGS = new Set([
|
||||
'che_등용수락',
|
||||
'che_감축',
|
||||
'che_국기변경',
|
||||
'che_국호변경',
|
||||
'che_무작위수도이전',
|
||||
'che_증축',
|
||||
'che_천도',
|
||||
'che_초토화',
|
||||
'cr_인구이동',
|
||||
'event_극병연구',
|
||||
'event_대검병연구',
|
||||
'event_무희연구',
|
||||
'event_산저병연구',
|
||||
'event_상병연구',
|
||||
'event_원융노병연구',
|
||||
'event_음귀병연구',
|
||||
'event_화륜차연구',
|
||||
'event_화시병연구',
|
||||
]);
|
||||
|
||||
const orderLegacyCommandLogs = (
|
||||
actionKey: string,
|
||||
actionLogs: readonly LogEntryDraft[],
|
||||
progressionLogs: readonly LogEntryDraft[],
|
||||
postProgressionLogs: readonly LogEntryDraft[]
|
||||
): LogEntryDraft[] =>
|
||||
orderLegacyActionLoggerFlush(
|
||||
LEGACY_PROGRESSION_BEFORE_ACTION_LOGS.has(actionKey)
|
||||
? [...progressionLogs, ...actionLogs, ...postProgressionLogs]
|
||||
: [...actionLogs, ...progressionLogs, ...postProgressionLogs]
|
||||
);
|
||||
|
||||
export const applyLegacyGeneralProgression = (
|
||||
general: TurnGeneral,
|
||||
previousGeneral: TurnGeneral,
|
||||
@@ -152,36 +189,49 @@ export const applyLegacyGeneralProgression = (
|
||||
actionKey === 'che_선양' ||
|
||||
actionKey === 'che_출병' ||
|
||||
actionKey === 'che_물자조달';
|
||||
if (!preserveLevel && (forceRefreshLevel || general.experience !== previousGeneral.experience)) {
|
||||
const preservesResolvedProcurementLevel = actionKey === 'che_물자조달';
|
||||
if (
|
||||
preservesResolvedProcurementLevel ||
|
||||
(!preserveLevel && (forceRefreshLevel || general.experience !== previousGeneral.experience))
|
||||
) {
|
||||
const previousExpLevel = readMetaNumber(previousGeneral.meta, 'explevel', 0);
|
||||
const actionResolvedExpLevel = readMetaNumber(general.meta, 'explevel', previousExpLevel);
|
||||
meta.explevel = expLevel;
|
||||
if (expLevel !== previousExpLevel && actionResolvedExpLevel !== expLevel) {
|
||||
const josaRo = JosaUtil.pick(String(expLevel), '로');
|
||||
const nextExpLevel = preservesResolvedProcurementLevel ? actionResolvedExpLevel : expLevel;
|
||||
meta.explevel = nextExpLevel;
|
||||
if (
|
||||
nextExpLevel !== previousExpLevel &&
|
||||
(preservesResolvedProcurementLevel || actionResolvedExpLevel !== nextExpLevel)
|
||||
) {
|
||||
const josaRo = JosaUtil.pick(String(nextExpLevel), '로');
|
||||
logs.push(
|
||||
createGeneralActionLog(
|
||||
general.id,
|
||||
expLevel > previousExpLevel
|
||||
? `<C>Lv ${expLevel}</>${josaRo} <C>레벨업</>!`
|
||||
: `<C>Lv ${expLevel}</>${josaRo} <R>레벨다운</>!`,
|
||||
nextExpLevel > previousExpLevel
|
||||
? `<C>Lv ${nextExpLevel}</>${josaRo} <C>레벨업</>!`
|
||||
: `<C>Lv ${nextExpLevel}</>${josaRo} <R>레벨다운</>!`,
|
||||
{ format: LogFormat.PLAIN }
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
if (!preserveLevel && (forceRefreshLevel || general.dedication !== previousGeneral.dedication)) {
|
||||
if (
|
||||
preservesResolvedProcurementLevel ||
|
||||
(!preserveLevel && (forceRefreshLevel || general.dedication !== previousGeneral.dedication))
|
||||
) {
|
||||
const previousDedicationLevel = readMetaNumber(previousGeneral.meta, 'dedlevel', 0);
|
||||
meta.dedlevel = dedicationLevel;
|
||||
if (dedicationLevel !== previousDedicationLevel) {
|
||||
const actionResolvedDedicationLevel = readMetaNumber(general.meta, 'dedlevel', previousDedicationLevel);
|
||||
const nextDedicationLevel = preservesResolvedProcurementLevel ? actionResolvedDedicationLevel : dedicationLevel;
|
||||
meta.dedlevel = nextDedicationLevel;
|
||||
if (nextDedicationLevel !== previousDedicationLevel) {
|
||||
const dedicationLevelText =
|
||||
dedicationLevel === 0 ? '무품관' : `${maxDedicationLevel - dedicationLevel + 1}품관`;
|
||||
const billText = getBillByLevel(dedicationLevel).toLocaleString('en-US');
|
||||
nextDedicationLevel === 0 ? '무품관' : `${maxDedicationLevel - nextDedicationLevel + 1}품관`;
|
||||
const billText = getBillByLevel(nextDedicationLevel).toLocaleString('en-US');
|
||||
const josaRoDedication = JosaUtil.pick(dedicationLevelText, '로');
|
||||
const josaRoBill = JosaUtil.pick(billText, '로');
|
||||
logs.push(
|
||||
createGeneralActionLog(
|
||||
general.id,
|
||||
dedicationLevel > previousDedicationLevel
|
||||
nextDedicationLevel > previousDedicationLevel
|
||||
? `<Y>${dedicationLevelText}</>${josaRoDedication} <C>승급</>하여 봉록이 <C>${billText}</>${josaRoBill} <C>상승</>했습니다!`
|
||||
: `<Y>${dedicationLevelText}</>${josaRoDedication} <R>강등</>되어 봉록이 <C>${billText}</>${josaRoBill} <R>하락</>했습니다!`,
|
||||
{ format: LogFormat.PLAIN }
|
||||
@@ -778,8 +828,14 @@ const createGeneralActionLog = (
|
||||
const resolveDefinition = (
|
||||
actionKey: string,
|
||||
definitions: Map<string, GeneralActionDefinition>,
|
||||
fallback: GeneralActionDefinition
|
||||
): GeneralActionDefinition => definitions.get(actionKey) ?? fallback;
|
||||
kind: 'general' | 'nation'
|
||||
): GeneralActionDefinition => {
|
||||
const definition = definitions.get(actionKey);
|
||||
if (!definition) {
|
||||
throw new Error(`Unknown reserved ${kind} turn command: ${actionKey}`);
|
||||
}
|
||||
return definition;
|
||||
};
|
||||
|
||||
export const createReservedTurnHandler = async (options: {
|
||||
reservedTurns: InMemoryReservedTurnStore;
|
||||
@@ -790,6 +846,8 @@ export const createReservedTurnHandler = async (options: {
|
||||
getWorld: () => InMemoryTurnWorld | null;
|
||||
commandProfile?: TurnCommandProfile;
|
||||
commandEnv?: TurnCommandEnv;
|
||||
now?: () => Date;
|
||||
messageSharedIconBaseUrl?: string;
|
||||
commandRngFactory?: (input: { kind: 'nation' | 'general'; actionKey: string; seed: string }) => RandUtil;
|
||||
getAdditionalOccupiedUniqueItemKeys?: () => Iterable<string | null | undefined>;
|
||||
calculateNpcNationFinance?: (
|
||||
@@ -957,6 +1015,9 @@ export const createReservedTurnHandler = async (options: {
|
||||
let currentGeneral = context.general;
|
||||
let currentCity = context.city;
|
||||
let currentNation = context.nation ?? null;
|
||||
// Ref는 장수와 첫 커맨드를 만들 때 getNationStaticInfo 캐시를 채운다.
|
||||
// 같은 장수 lifecycle의 국호변경은 뒤이은 유니크 획득 로그의 국호를 바꾸지 않는다.
|
||||
const legacyStaticNationName = currentNation?.name ?? '재야';
|
||||
|
||||
const runAction = (
|
||||
kind: 'nation' | 'general',
|
||||
@@ -973,7 +1034,7 @@ export const createReservedTurnHandler = async (options: {
|
||||
completed: boolean;
|
||||
blockedReason?: string;
|
||||
} => {
|
||||
const resolvedDefinition = resolveDefinition(command.action, definitionMap, fallbackDefinition);
|
||||
const resolvedDefinition = resolveDefinition(command.action, definitionMap, kind);
|
||||
const rawArgs = extractArgsRecord(command.args);
|
||||
const parsedArgs = resolvedDefinition.parseArgs(rawArgs);
|
||||
let definition = resolvedDefinition;
|
||||
@@ -1098,12 +1159,15 @@ export const createReservedTurnHandler = async (options: {
|
||||
time: actionTime,
|
||||
maxTechLevel: env.maxTechLevel,
|
||||
uniqueLottery,
|
||||
legacyStaticNationName,
|
||||
};
|
||||
let specificContext = buildActionContext(
|
||||
actionKey,
|
||||
baseContext,
|
||||
{
|
||||
world: context.world,
|
||||
gameNow: options.now?.() ?? currentGeneral.turnTime,
|
||||
messageSharedIconBaseUrl: options.messageSharedIconBaseUrl,
|
||||
scenarioConfig: options.scenarioConfig,
|
||||
scenarioMeta: options.scenarioMeta,
|
||||
map: options.map,
|
||||
@@ -1280,15 +1344,24 @@ export const createReservedTurnHandler = async (options: {
|
||||
};
|
||||
}
|
||||
}
|
||||
const progressionLogs: LogEntryDraft[] = [];
|
||||
if (!resolution.alternative && !usedFallback && resolution.completed) {
|
||||
currentGeneral = applyLegacyGeneralProgression(
|
||||
currentGeneral,
|
||||
generalBeforeExecution,
|
||||
actionKey,
|
||||
env,
|
||||
logs
|
||||
progressionLogs
|
||||
);
|
||||
}
|
||||
logs.push(
|
||||
...orderLegacyCommandLogs(
|
||||
actionKey,
|
||||
resolution.logs,
|
||||
progressionLogs,
|
||||
resolution.postProgressionLogs
|
||||
)
|
||||
);
|
||||
if (
|
||||
!resolution.alternative &&
|
||||
kind === 'nation' &&
|
||||
@@ -1418,7 +1491,6 @@ export const createReservedTurnHandler = async (options: {
|
||||
};
|
||||
}
|
||||
|
||||
logs.push(...resolution.logs);
|
||||
for (const nationId of resolution.destroyedNationIds ?? []) {
|
||||
destroyedNationIds.add(nationId);
|
||||
}
|
||||
@@ -1535,10 +1607,9 @@ export const createReservedTurnHandler = async (options: {
|
||||
if (resolution.created?.generals) {
|
||||
const newGenerals = resolution.created.generals as TurnGeneral[];
|
||||
createdGenerals.push(...newGenerals);
|
||||
if (worldOverlay) {
|
||||
for (const general of newGenerals) {
|
||||
worldOverlay.syncGeneral(general);
|
||||
}
|
||||
for (const general of newGenerals) {
|
||||
worldOverlay?.syncGeneral(general);
|
||||
options.reservedTurns.ensureGeneralTurns(general.id);
|
||||
}
|
||||
}
|
||||
if (resolution.created?.nations) {
|
||||
@@ -1990,7 +2061,7 @@ export const createReservedTurnHandler = async (options: {
|
||||
src: messageTarget,
|
||||
dest: messageTarget,
|
||||
text: npcMessage,
|
||||
time: new Date(context.world.lastTurnTime),
|
||||
time: options.now?.() ?? new Date(context.world.lastTurnTime),
|
||||
validUntil: new Date('9999-12-31T00:00:00.000Z'),
|
||||
option: {},
|
||||
});
|
||||
@@ -2438,6 +2509,7 @@ export const createImmediateGeneralActionExecutor = async (options: {
|
||||
},
|
||||
maxTechLevel: env.maxTechLevel,
|
||||
uniqueLottery,
|
||||
legacyStaticNationName: nation?.name ?? '재야',
|
||||
};
|
||||
const actionContext =
|
||||
buildActionContext(
|
||||
@@ -2476,7 +2548,10 @@ export const createImmediateGeneralActionExecutor = async (options: {
|
||||
);
|
||||
|
||||
if (input.actionKey === 'che_접경귀환' && (resolution.general as TurnGeneral).cityId === general.cityId) {
|
||||
for (const log of resolution.logs) {
|
||||
for (const log of orderLegacyActionLoggerFlush([
|
||||
...resolution.logs,
|
||||
...resolution.postProgressionLogs,
|
||||
])) {
|
||||
options.world.pushLog(log, general.turnTime);
|
||||
}
|
||||
return { ok: false, reason: '가까운 아국 도시가 없습니다.' };
|
||||
@@ -2514,7 +2589,11 @@ export const createImmediateGeneralActionExecutor = async (options: {
|
||||
},
|
||||
};
|
||||
}
|
||||
if (input.actionKey === 'che_거병') {
|
||||
// Ref's immediate uprising and recruitment-accept commands both
|
||||
// finish their actor addExperience/addDedication calls before the
|
||||
// actor logger is applied. Keep the same level/rank state and logs
|
||||
// outside the ordinary reserved-turn lifecycle.
|
||||
if (input.actionKey === 'che_거병' || input.actionKey === 'che_등용수락') {
|
||||
nextGeneral = applyLegacyGeneralProgression(
|
||||
{
|
||||
...nextGeneral,
|
||||
@@ -2566,7 +2645,12 @@ export const createImmediateGeneralActionExecutor = async (options: {
|
||||
for (const troopId of resolution.deletedTroopIds ?? []) {
|
||||
options.world.removeTroop(troopId);
|
||||
}
|
||||
for (const log of [...resolution.logs, ...progressionLogs]) {
|
||||
for (const log of orderLegacyCommandLogs(
|
||||
input.actionKey,
|
||||
resolution.logs,
|
||||
progressionLogs,
|
||||
resolution.postProgressionLogs
|
||||
)) {
|
||||
options.world.pushLog(log, general.turnTime);
|
||||
}
|
||||
options.world.updateGeneral(input.generalId, nextGeneral);
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
import { DEFAULT_TURN_COMMAND_PROFILE, parseTurnCommandProfile, type TurnCommandProfile } from '@sammo-ts/logic';
|
||||
import {
|
||||
parseTurnCommandProfile,
|
||||
resolveScenarioTurnCommandProfile,
|
||||
type ScenarioTurnCommandProfileResolution,
|
||||
type TurnCommandProfile,
|
||||
} from '@sammo-ts/logic';
|
||||
|
||||
import { resolveWorkspaceRoot } from '../paths.js';
|
||||
|
||||
@@ -10,6 +15,7 @@ const DEFAULT_PROFILE_PATH = path.resolve(REPO_ROOT, 'resources', 'turn-commands
|
||||
|
||||
export interface TurnCommandProfileOptions {
|
||||
filePath?: string;
|
||||
scenarioConst?: unknown;
|
||||
}
|
||||
|
||||
const readCommandProfile = async (filePath: string): Promise<TurnCommandProfile> => {
|
||||
@@ -17,14 +23,13 @@ const readCommandProfile = async (filePath: string): Promise<TurnCommandProfile>
|
||||
return parseTurnCommandProfile(JSON.parse(raw) as unknown);
|
||||
};
|
||||
|
||||
export const loadTurnCommandProfile = async (options?: TurnCommandProfileOptions): Promise<TurnCommandProfile> => {
|
||||
export const loadScenarioTurnCommandProfile = async (
|
||||
options?: TurnCommandProfileOptions
|
||||
): Promise<ScenarioTurnCommandProfileResolution> => {
|
||||
const filePath = options?.filePath ?? process.env.TURN_COMMANDS_PATH ?? DEFAULT_PROFILE_PATH;
|
||||
try {
|
||||
return await readCommandProfile(filePath);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return DEFAULT_TURN_COMMAND_PROFILE;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const fallback = await readCommandProfile(filePath);
|
||||
return resolveScenarioTurnCommandProfile(options?.scenarioConst, fallback);
|
||||
};
|
||||
|
||||
export const loadTurnCommandProfile = async (options?: TurnCommandProfileOptions): Promise<TurnCommandProfile> =>
|
||||
(await loadScenarioTurnCommandProfile(options)).profile;
|
||||
|
||||
@@ -663,11 +663,10 @@ const createTurnDaemonRuntimeWithLease = async (
|
||||
});
|
||||
const commandProfile =
|
||||
options.commandProfile ??
|
||||
(options.commandProfilePath
|
||||
? await loadTurnCommandProfile({
|
||||
filePath: options.commandProfilePath,
|
||||
})
|
||||
: await loadTurnCommandProfile());
|
||||
(await loadTurnCommandProfile({
|
||||
...(options.commandProfilePath ? { filePath: options.commandProfilePath } : {}),
|
||||
scenarioConst: snapshot.scenarioConfig.const,
|
||||
}));
|
||||
let worldRef: InMemoryTurnWorld | null = null;
|
||||
let redisConnector: RedisConnector | null = null;
|
||||
const nationTraits = await loadNationTraitModules([...NATION_TRAIT_KEYS], new NationTraitLoader());
|
||||
@@ -730,6 +729,10 @@ const createTurnDaemonRuntimeWithLease = async (
|
||||
map: snapshot.map,
|
||||
unitSet: snapshot.unitSet,
|
||||
getWorld: () => worldRef,
|
||||
now: () => {
|
||||
const wallNow = new Date(clock.nowMs());
|
||||
return worldRef?.getGameNow(wallNow) ?? wallNow;
|
||||
},
|
||||
commandProfile,
|
||||
commandEnv: monthlyCommandEnv,
|
||||
calculateNpcNationFinance: (financeWorld, nation, currentMonth) =>
|
||||
|
||||
Reference in New Issue
Block a user