feat(engine): migrate monthly pre-update boundary

This commit is contained in:
2026-07-25 21:36:34 +00:00
parent bd57a99d0e
commit 5f390a46b9
12 changed files with 666 additions and 59 deletions
+18
View File
@@ -887,6 +887,24 @@ export const createDatabaseTurnHooks = async (
data: buildGeneralUpdate(general),
})
),
...generals
.filter(
(general) =>
typeof general.refreshScoreTotal === 'number' && Number.isFinite(general.refreshScoreTotal)
)
.map((general) =>
prisma.generalAccessLog.upsert({
where: { generalId: general.id },
update: {
refreshScoreTotal: Math.floor(general.refreshScoreTotal ?? 0),
},
create: {
generalId: general.id,
userId: general.userId ?? null,
refreshScoreTotal: Math.floor(general.refreshScoreTotal ?? 0),
},
})
),
...cities.map((city) =>
prisma.city.update({
where: { id: city.id },
@@ -0,0 +1,75 @@
import { asRecord } from '@sammo-ts/common';
import type { TurnCommandEnv } from '@sammo-ts/logic';
import type { InMemoryTurnWorld, TurnCalendarHandler } from './inMemoryWorld.js';
const CITY_STATE_TRANSITIONS = new Map<number, number>([
[31, 0],
[32, 31],
[33, 0],
[34, 33],
[41, 0],
[42, 41],
[43, 42],
]);
const readFiniteNumber = (value: unknown, fallback = 0): number => {
if (typeof value === 'number' && Number.isFinite(value)) {
return value;
}
if (typeof value === 'string') {
const parsed = Number(value);
if (Number.isFinite(parsed)) {
return parsed;
}
}
return fallback;
};
/**
* ref preUpdateMonthly()에서 국가 외 상태를 날짜 변경 전에 갱신한다.
* 연감 저장은 이 handler보다 먼저, MONTH event는 이 handler보다 나중에
* 실행되도록 turnDaemon의 calendar handler 순서가 계약을 보장한다.
*/
export const createMonthlyBoundaryPreHandler = (options: {
getWorld: () => InMemoryTurnWorld | null;
startYear: number;
commandEnv: TurnCommandEnv;
}): TurnCalendarHandler => ({
beforeMonthChanged: (context) => {
const world = options.getWorld();
if (!world) {
return;
}
const develCost = (context.previousYear - options.startYear + 10) * 2;
options.commandEnv.develCost = develCost;
world.updateWorldMeta({ develcost: develCost });
for (const general of world.listGenerals()) {
const meta = asRecord(general.meta);
world.updateGeneral(general.id, {
...(general.refreshScoreTotal === undefined
? {}
: { refreshScoreTotal: Math.floor(readFiniteNumber(general.refreshScoreTotal) * 0.99) }),
meta: {
...general.meta,
makelimit: Math.max(0, Math.floor(readFiniteNumber(meta.makelimit)) - 1),
},
});
}
for (const city of world.listCities()) {
const meta = asRecord(city.meta);
const nextTerm = Math.max(0, Math.floor(readFiniteNumber(meta.term)) - 1);
world.updateCity(city.id, {
state: CITY_STATE_TRANSITIONS.get(city.state) ?? city.state,
conflict: nextTerm === 0 ? {} : city.conflict,
meta: {
...city.meta,
term: nextTerm,
},
});
}
},
});
@@ -15,11 +15,56 @@ const decrementLimit = (value: unknown): number => {
return 0;
};
// ref preUpdateMonthly(): 전략 제한과 외교 제한은 매 월턴마다 1씩 감소한다.
const readRate = (value: unknown): number => {
if (typeof value === 'number' && Number.isFinite(value)) {
return value;
}
if (typeof value === 'string') {
const parsed = Number(value);
if (Number.isFinite(parsed)) {
return parsed;
}
}
return 20;
};
const readSpyRemain = (value: unknown): number => {
if (typeof value === 'number' && Number.isFinite(value)) {
return value;
}
if (typeof value === 'string') {
const parsed = Number(value);
if (Number.isFinite(parsed)) {
return parsed;
}
}
return 0;
};
const decrementSpy = (value: unknown): Record<string, number> => {
let raw: unknown = value;
if (typeof value === 'string') {
try {
raw = JSON.parse(value);
} catch {
raw = {};
}
}
const result: Record<string, number> = {};
for (const [cityId, remain] of Object.entries(asRecord(raw))) {
const numeric = readSpyRemain(remain);
if (numeric > 1) {
result[cityId] = numeric - 1;
}
}
return result;
};
// ref preUpdateMonthly(): 국가 제한·세율·첩보는 MONTH action보다 먼저 갱신한다.
export const createNationTurnMonthlyHandler = (options: {
getWorld: () => InMemoryTurnWorld | null;
}): TurnCalendarHandler => ({
onMonthChanged: () => {
beforeMonthChanged: () => {
const world = options.getWorld();
if (!world) {
return;
@@ -31,6 +76,8 @@ export const createNationTurnMonthlyHandler = (options: {
...nation.meta,
strategic_cmd_limit: decrementLimit(meta.strategic_cmd_limit),
surlimit: decrementLimit(meta.surlimit),
rate_tmp: readRate(meta.rate),
spy: decrementSpy(meta.spy),
},
});
}
@@ -716,6 +716,7 @@ export const createReservedTurnHandler = async (options: {
unitSet?: UnitSetDefinition;
getWorld: () => InMemoryTurnWorld | null;
commandProfile?: TurnCommandProfile;
commandEnv?: TurnCommandEnv;
commandRngFactory?: (input: { kind: 'nation' | 'general'; actionKey: string; seed: string }) => RandUtil;
onActionResolved?: (payload: {
kind: 'nation' | 'general';
@@ -728,7 +729,7 @@ export const createReservedTurnHandler = async (options: {
aiState?: ReturnType<GeneralAI['getDebugState']>;
}) => void;
}): Promise<GeneralTurnHandler> => {
const env = buildCommandEnv(options.scenarioConfig, options.unitSet);
const env = options.commandEnv ?? buildCommandEnv(options.scenarioConfig, options.unitSet);
const itemRegistry = createItemModuleRegistry(await loadItemModules([...ITEM_KEYS]));
const uniqueConfig = resolveUniqueConfig(asRecord(options.scenarioConfig.const));
if (Object.keys(uniqueConfig.allItems).length === 0) {
+10 -2
View File
@@ -26,6 +26,7 @@ import { createGatewayProfileGate } from './gatewayProfileGate.js';
import { composeCalendarHandlers } from './calendarHandlers.js';
import { createIncomeHandler } from './incomeHandler.js';
import { createNationTurnMonthlyHandler } from './nationTurnMonthlyHandler.js';
import { createMonthlyBoundaryPreHandler } from './monthlyBoundaryPreHandler.js';
import { createFrontStateHandler } from './frontStateHandler.js';
import { createReservedTurnHandler } from './reservedTurnHandler.js';
import { createReservedTurnStore } from './reservedTurnStore.js';
@@ -443,6 +444,11 @@ const createTurnDaemonRuntimeWithLease = async (
const nationTurnMonthlyHandler = createNationTurnMonthlyHandler({
getWorld: () => worldRef,
});
const monthlyBoundaryPreHandler = createMonthlyBoundaryPreHandler({
getWorld: () => worldRef,
startYear: snapshot.scenarioMeta?.startYear ?? state.currentYear,
commandEnv: monthlyCommandEnv,
});
const frontStateHandler = createFrontStateHandler({
getWorld: () => worldRef,
map: snapshot.map ?? null,
@@ -467,13 +473,14 @@ const createTurnDaemonRuntimeWithLease = async (
});
const calendarHandler = composeCalendarHandlers(
monthlyEventHandler,
yearbookHandler.handler,
options.calendarHandler ?? unification?.handler,
monthlyBoundaryPreHandler,
nationTurnMonthlyHandler,
hasEventAction('ProcessIncome') ? null : incomeHandler,
frontStateHandler,
neutralAuctionRegistrar.handler,
tournamentAutoStartHandler,
yearbookHandler.handler
tournamentAutoStartHandler
);
const worldOptions: InMemoryTurnWorldOptions = {
schedule,
@@ -487,6 +494,7 @@ const createTurnDaemonRuntimeWithLease = async (
unitSet: snapshot.unitSet,
getWorld: () => worldRef,
commandProfile,
commandEnv: monthlyCommandEnv,
})),
calendarHandler: calendarHandler ?? undefined,
};
+1
View File
@@ -32,6 +32,7 @@ export interface TurnGeneral extends General {
lastTurn?: GeneralLastTurn;
penalty?: unknown;
inheritancePoints?: Record<string, number>;
refreshScoreTotal?: number;
}
export interface TurnDiplomacy {
+30 -15
View File
@@ -5,6 +5,7 @@ import {
type TurnEngineDatabaseClient,
type TurnEngineDiplomacyRow,
type TurnEngineGeneralRow,
type TurnEngineGeneralAccessLogRow,
type TurnEngineInheritancePointRow,
type TurnEngineRankDataRow,
type TurnEngineNationRow,
@@ -170,7 +171,8 @@ const GENERAL_RANK_META_PREFIX_TYPES = new Set([
const mapGeneralRow = (
row: TurnEngineGeneralRow,
rankRows: readonly TurnEngineRankDataRow[],
inheritanceRows: readonly TurnEngineInheritancePointRow[]
inheritanceRows: readonly TurnEngineInheritancePointRow[],
accessRow?: TurnEngineGeneralAccessLogRow
): TurnGeneral => {
const legacySlots: GeneralItemSlots = {
horse: normalizeCode(row.horseCode),
@@ -243,6 +245,7 @@ const mapGeneralRow = (
turnTime: row.turnTime,
recentWarTime: row.recentWarTime ?? null,
inheritancePoints,
...(accessRow ? { refreshScoreTotal: accessRow.refreshScoreTotal } : {}),
};
};
@@ -340,19 +343,29 @@ export const loadTurnWorldFromDatabase = async (options: TurnWorldLoaderOptions)
throw new Error('world_state row is required to start turn daemon.');
}
const [generalRows, rankRows, inheritanceRows, cityRows, nationRows, diplomacyRows, troopRows, eventRows] =
await Promise.all([
prisma.general.findMany(),
prisma.rankData.findMany(),
prisma.inheritancePoint.findMany(),
prisma.city.findMany(),
prisma.nation.findMany(),
prisma.diplomacy.findMany(),
prisma.troop.findMany(),
prisma.event.findMany({
orderBy: [{ priority: 'desc' }, { id: 'asc' }],
}),
]);
const [
generalRows,
rankRows,
inheritanceRows,
accessRows,
cityRows,
nationRows,
diplomacyRows,
troopRows,
eventRows,
] = await Promise.all([
prisma.general.findMany(),
prisma.rankData.findMany(),
prisma.inheritancePoint.findMany(),
prisma.generalAccessLog.findMany(),
prisma.city.findMany(),
prisma.nation.findMany(),
prisma.diplomacy.findMany(),
prisma.troop.findMany(),
prisma.event.findMany({
orderBy: [{ priority: 'desc' }, { id: 'asc' }],
}),
]);
const ranksByGeneral = new Map<number, TurnEngineRankDataRow[]>();
for (const row of rankRows) {
@@ -366,11 +379,13 @@ export const loadTurnWorldFromDatabase = async (options: TurnWorldLoaderOptions)
bucket.push(row);
inheritanceByUser.set(row.userId, bucket);
}
const accessByGeneral = new Map(accessRows.map((row) => [row.generalId, row]));
const generals = generalRows.map((row) =>
mapGeneralRow(
row,
ranksByGeneral.get(row.id) ?? [],
row.userId ? (inheritanceByUser.get(row.userId) ?? []) : []
row.userId ? (inheritanceByUser.get(row.userId) ?? []) : [],
accessByGeneral.get(row.id)
)
);
const cities = cityRows.map(mapCityRow);
+28 -34
View File
@@ -56,12 +56,9 @@ const buildMapSnapshot = (world: InMemoryTurnWorld, year: number, month: number)
return [city.id, city.level, stateValue, city.nationId, region, supplyFlag];
});
const nationList: MapNationCompact[] = world.listNations().map((nation) => [
nation.id,
nation.name,
nation.color,
nation.capitalCityId ?? 0,
]);
const nationList: MapNationCompact[] = world
.listNations()
.map((nation) => [nation.id, nation.name, nation.color, nation.capitalCityId ?? 0]);
return {
result: true,
@@ -84,8 +81,7 @@ const buildNationSnapshot = (world: InMemoryTurnWorld): YearbookNation[] => {
for (const city of cities) {
const entry = cityStatsByNation.get(city.nationId) ?? { popSum: 0, valueSum: 0, maxSum: 0 };
const valueSum =
city.population + city.agriculture + city.commerce + city.security + city.wall + city.defence;
const valueSum = city.population + city.agriculture + city.commerce + city.security + city.wall + city.defence;
const maxSum =
city.populationMax +
city.agricultureMax +
@@ -152,40 +148,38 @@ export const createYearbookHandler = (options: {
const ready = connector.connect();
const handler: TurnCalendarHandler = {
onMonthChanged: (context) => {
beforeMonthChanged: async (context) => {
const world = options.getWorld();
if (!world) {
return;
}
void (async () => {
await ready;
const map = buildMapSnapshot(world, context.previousYear, context.previousMonth);
const nations = buildNationSnapshot(world);
const hash = buildHash(map, nations);
await ready;
const map = buildMapSnapshot(world, context.previousYear, context.previousMonth);
const nations = buildNationSnapshot(world);
const hash = buildHash(map, nations);
await connector.prisma.yearbookHistory.upsert({
where: {
profileName_year_month: {
profileName: options.profileName,
year: context.previousYear,
month: context.previousMonth,
},
},
update: {
map,
nations,
hash,
},
create: {
await connector.prisma.yearbookHistory.upsert({
where: {
profileName_year_month: {
profileName: options.profileName,
year: context.previousYear,
month: context.previousMonth,
map,
nations,
hash,
},
});
})();
},
update: {
map,
nations,
hash,
},
create: {
profileName: options.profileName,
year: context.previousYear,
month: context.previousMonth,
map,
nations,
hash,
},
});
},
};
@@ -194,4 +188,4 @@ export const createYearbookHandler = (options: {
};
return { handler, close };
};
};