merge: migrate monthly pre-update boundary

This commit is contained in:
2026-07-25 21:36:43 +00:00
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 };
};
};
@@ -0,0 +1,173 @@
import { describe, expect, it } from 'vitest';
import type { City, Nation, TurnCommandEnv } from '@sammo-ts/logic';
import { composeCalendarHandlers } from '../src/turn/calendarHandlers.js';
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
import { createMonthlyBoundaryPreHandler } from '../src/turn/monthlyBoundaryPreHandler.js';
import { createNationTurnMonthlyHandler } from '../src/turn/nationTurnMonthlyHandler.js';
import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
const buildGeneral = (id: number, makeLimit: number, refreshScoreTotal: number): TurnGeneral => ({
id,
name: `장수${id}`,
nationId: 0,
cityId: id,
troopId: 0,
stats: { leadership: 50, strength: 50, intelligence: 50 },
experience: 0,
dedication: 0,
officerLevel: 1,
role: {
personality: null,
specialDomestic: null,
specialWar: null,
items: { horse: null, weapon: null, book: null, item: null },
},
injury: 0,
gold: 1_000,
rice: 1_000,
crew: 0,
crewTypeId: 0,
train: 0,
atmos: 0,
age: 20,
npcState: 2,
triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} },
meta: { killturn: 0, makelimit: makeLimit },
refreshScoreTotal,
turnTime: new Date('0200-12-01T00:00:00.000Z'),
});
const buildCity = (id: number, state: number, term: number): City => ({
id,
name: `도시${id}`,
nationId: id <= 2 ? 1 : 0,
level: 1,
state,
population: 1_000,
populationMax: 2_000,
agriculture: 100,
agricultureMax: 200,
commerce: 100,
commerceMax: 200,
security: 100,
securityMax: 200,
supplyState: 1,
frontState: 0,
defence: 100,
defenceMax: 200,
wall: 100,
wallMax: 200,
conflict: { 1: id * 10 },
meta: { term },
});
const nation: Nation = {
id: 1,
name: 'fixture-nation-1',
color: '#777777',
capitalCityId: 1,
chiefGeneralId: null,
gold: 0,
rice: 0,
power: 0,
level: 1,
typeCode: 'che_중립',
meta: {
rate: 35,
rate_tmp: 10,
strategic_cmd_limit: 2,
surlimit: 1,
spy: { 1: 1, 2: 2 },
},
};
const commandEnv = { develCost: 18 } as TurnCommandEnv;
describe('monthly pre-update boundary', () => {
it('matches the fixed legacy preUpdateMonthly state transition before MONTH actions', async () => {
const state: TurnWorldState = {
id: 1,
currentYear: 200,
currentMonth: 12,
tickSeconds: 600,
lastTurnTime: new Date('0200-12-01T00:00:00.000Z'),
meta: { develcost: 18 },
};
const snapshot: TurnWorldSnapshot = {
scenarioConfig: {
stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 50, npcMin: 10, chiefMin: 70 },
iconPath: '',
map: {},
const: {},
environment: { mapName: 'test', unitSet: 'default' },
},
map: { id: 'test', name: 'test', cities: [] },
diplomacy: [],
events: [],
initialEvents: [],
generals: [buildGeneral(1, 2, 101), buildGeneral(2, 0, 1)],
cities: [
buildCity(1, 31, 1),
buildCity(2, 32, 2),
buildCity(3, 33, 0),
buildCity(4, 34, 3),
buildCity(5, 41, 1),
buildCity(6, 42, 2),
buildCity(7, 43, 3),
],
nations: [nation],
troops: [],
};
let world: InMemoryTurnWorld | null = null;
const trace: unknown[] = [];
const boundaryHandler = createMonthlyBoundaryPreHandler({
getWorld: () => world,
startYear: 190,
commandEnv,
});
const nationHandler = createNationTurnMonthlyHandler({ getWorld: () => world });
world = new InMemoryTurnWorld(state, snapshot, {
schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] },
calendarHandler: composeCalendarHandlers(boundaryHandler, nationHandler, {
onMonthChanged: () => {
trace.push({
state: world?.listCities().map((city) => city.state),
rate: world?.getNationById(1)?.meta.rate_tmp,
develCost: commandEnv.develCost,
});
},
}),
});
await world.advanceMonth(new Date('0201-01-01T00:00:00.000Z'));
expect(world.listGenerals().map((general) => [general.meta.makelimit, general.refreshScoreTotal])).toEqual([
[1, 99],
[0, 0],
]);
expect(world.getNationById(1)?.meta).toEqual(
expect.objectContaining({
rate: 35,
rate_tmp: 35,
strategic_cmd_limit: 1,
surlimit: 0,
spy: { 2: 1 },
})
);
expect(world.listCities().map((city) => city.state)).toEqual([0, 31, 0, 33, 0, 41, 42]);
expect(world.listCities().map((city) => city.meta.term)).toEqual([0, 1, 0, 2, 0, 1, 2]);
expect(world.listCities().map((city) => city.conflict)).toEqual([
{},
{ 1: 20 },
{},
{ 1: 40 },
{},
{ 1: 60 },
{ 1: 70 },
]);
expect(world.getState().meta.develcost).toBe(40);
expect(commandEnv.develCost).toBe(40);
expect(trace).toEqual([{ state: [0, 31, 0, 33, 0, 41, 42], rate: 35, develCost: 40 }]);
});
});
@@ -0,0 +1,240 @@
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import type { TurnCommandEnv } from '@sammo-ts/logic';
import { createGamePostgresConnector, type GamePrismaClient } from '@sammo-ts/infra';
import { composeCalendarHandlers } from '../src/turn/calendarHandlers.js';
import { createDatabaseTurnHooks } from '../src/turn/databaseHooks.js';
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
import { createMonthlyBoundaryPreHandler } from '../src/turn/monthlyBoundaryPreHandler.js';
import { createNationTurnMonthlyHandler } from '../src/turn/nationTurnMonthlyHandler.js';
import { loadTurnWorldFromDatabase } from '../src/turn/worldLoader.js';
import { createYearbookHandler } from '../src/turn/yearbookHandler.js';
const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL;
const integration = describe.skipIf(!databaseUrl);
const generalIds = [991_201, 991_202];
const cityIds = [991_201, 991_202, 991_203, 991_204, 991_205, 991_206, 991_207];
const nationId = 991_201;
const yearbookProfile = 'monthly-boundary-pre-persistence';
integration('monthly pre-update persistence', () => {
let db: GamePrismaClient;
let closeDb: (() => Promise<void>) | undefined;
beforeAll(async () => {
const connector = createGamePostgresConnector({ url: databaseUrl! });
await connector.connect();
db = connector.prisma;
closeDb = () => connector.disconnect();
await db.generalAccessLog.deleteMany({ where: { generalId: { in: generalIds } } });
await db.rankData.deleteMany({ where: { generalId: { in: generalIds } } });
await db.general.deleteMany({ where: { id: { in: generalIds } } });
await db.city.deleteMany({ where: { id: { in: cityIds } } });
await db.nation.deleteMany({ where: { id: nationId } });
await db.worldState.deleteMany({ where: { scenarioCode: 'monthly-boundary-pre-persistence' } });
await db.yearbookHistory.deleteMany({ where: { profileName: yearbookProfile } });
});
afterAll(async () => {
await db.generalAccessLog.deleteMany({ where: { generalId: { in: generalIds } } });
await db.rankData.deleteMany({ where: { generalId: { in: generalIds } } });
await db.general.deleteMany({ where: { id: { in: generalIds } } });
await db.city.deleteMany({ where: { id: { in: cityIds } } });
await db.nation.deleteMany({ where: { id: nationId } });
await db.worldState.deleteMany({ where: { scenarioCode: 'monthly-boundary-pre-persistence' } });
await db.yearbookHistory.deleteMany({ where: { profileName: yearbookProfile } });
await closeDb?.();
});
it('loads and commits access score, limits, nation metadata, world development cost, and city state', async () => {
await db.nation.create({
data: {
id: nationId,
name: 'pre검증국',
color: '#777777',
level: 1,
typeCode: 'che_중립',
meta: {
rate: 35,
rate_tmp: 10,
strategic_cmd_limit: 2,
surlimit: 1,
spy: { 1: 1, 2: 2 },
},
},
});
await db.city.createMany({
data: cityIds.map((id, index) => ({
id,
name: `pre도시${index + 1}`,
level: 1,
nationId: index < 2 ? nationId : 0,
population: 1_000,
populationMax: 2_000,
agriculture: 100,
agricultureMax: 200,
commerce: 100,
commerceMax: 200,
security: 100,
securityMax: 200,
defence: 100,
defenceMax: 200,
wall: 100,
wallMax: 200,
region: 1,
conflict: { 1: (index + 1) * 10 },
meta: {
state: [31, 32, 33, 34, 41, 42, 43][index],
term: [1, 2, 0, 3, 1, 2, 3][index],
},
})),
});
await db.general.createMany({
data: generalIds.map((id, index) => ({
id,
name: `pre장수${index + 1}`,
nationId: 0,
cityId: cityIds[index]!,
npcState: 2,
turnTime: new Date('0200-12-01T00:00:00.000Z'),
meta: { killturn: 0, makelimit: index === 0 ? 2 : 0 },
})),
});
await db.generalAccessLog.createMany({
data: [
{ generalId: generalIds[0]!, refreshScoreTotal: 101 },
{ generalId: generalIds[1]!, refreshScoreTotal: 1 },
],
});
const worldRow = await db.worldState.create({
data: {
scenarioCode: 'monthly-boundary-pre-persistence',
currentYear: 200,
currentMonth: 12,
tickSeconds: 600,
config: {
stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 50, npcMin: 10, chiefMin: 70 },
iconPath: '.',
map: {},
const: {},
environment: { mapName: 'che', unitSet: 'che' },
},
meta: {
develcost: 18,
scenarioMeta: {
title: 'pre persistence',
startYear: 190,
life: null,
fiction: null,
history: [],
ignoreDefaultEvents: false,
},
},
},
});
const loaded = await loadTurnWorldFromDatabase({ databaseUrl: databaseUrl! });
expect(
loaded.snapshot.generals
.filter((general) => generalIds.includes(general.id))
.map((general) => general.refreshScoreTotal)
).toEqual([101, 1]);
let world: InMemoryTurnWorld | null = null;
const commandEnv = { develCost: 18 } as TurnCommandEnv;
const boundary = createMonthlyBoundaryPreHandler({
getWorld: () => world,
startYear: 190,
commandEnv,
});
const nations = createNationTurnMonthlyHandler({ getWorld: () => world });
const yearbook = createYearbookHandler({
databaseUrl: databaseUrl!,
profileName: yearbookProfile,
getWorld: () => world,
});
world = new InMemoryTurnWorld(loaded.state, loaded.snapshot, {
schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] },
calendarHandler: composeCalendarHandlers(yearbook.handler, boundary, nations),
});
const hooks = await createDatabaseTurnHooks(databaseUrl!, world);
try {
await world.advanceMonth(new Date('0201-01-01T00:00:00.000Z'));
await hooks.hooks.flushChanges?.({
lastTurnTime: '0201-01-01T00:00:00.000Z',
processedGenerals: 0,
processedTurns: 0,
durationMs: 0,
partial: false,
});
expect(
await db.generalAccessLog.findMany({
where: { generalId: { in: generalIds } },
orderBy: { generalId: 'asc' },
select: { refreshScoreTotal: true },
})
).toEqual([{ refreshScoreTotal: 99 }, { refreshScoreTotal: 0 }]);
expect(
(
await db.general.findMany({
where: { id: { in: generalIds } },
orderBy: { id: 'asc' },
select: { meta: true },
})
).map((row) => (row.meta as Record<string, unknown>).makelimit)
).toEqual([1, 0]);
expect(await db.nation.findUniqueOrThrow({ where: { id: nationId } })).toMatchObject({
meta: expect.objectContaining({
rate: 35,
rate_tmp: 35,
strategic_cmd_limit: 1,
surlimit: 0,
spy: { 2: 1 },
}),
});
expect(await db.worldState.findUniqueOrThrow({ where: { id: worldRow.id } })).toMatchObject({
currentYear: 201,
currentMonth: 1,
meta: expect.objectContaining({ develcost: 40 }),
});
const cityRows = await db.city.findMany({
where: { id: { in: cityIds } },
orderBy: { id: 'asc' },
select: { conflict: true, meta: true },
});
expect(cityRows.map((row) => (row.meta as Record<string, unknown>).state)).toEqual([
0, 31, 0, 33, 0, 41, 42,
]);
expect(cityRows.map((row) => (row.meta as Record<string, unknown>).term)).toEqual([0, 1, 0, 2, 0, 1, 2]);
expect(cityRows.map((row) => row.conflict)).toEqual([
{},
{ 1: 20 },
{},
{ 1: 40 },
{},
{ 1: 60 },
{ 1: 70 },
]);
const yearbookRow = await db.yearbookHistory.findUniqueOrThrow({
where: {
profileName_year_month: {
profileName: yearbookProfile,
year: 200,
month: 12,
},
},
});
const yearbookStates = new Map(
(yearbookRow.map as { cityList: Array<[number, number, number]> }).cityList.map(([id, , state]) => [
id,
state,
])
);
expect(cityIds.map((id) => yearbookStates.get(id))).toEqual([31, 32, 33, 34, 41, 42, 43]);
} finally {
await hooks.close();
await yearbook.close();
}
});
});
@@ -164,10 +164,20 @@ describe('레거시 사령부 턴 실행 호환성', () => {
);
});
it('월 경계마다 전략·외교 제한을 1씩 감소시키고 0 아래로 내리지 않는다', () => {
it('MONTH action 전에 전략·외교 제한, 임시 세율, 첩보 기간을 갱신한다', () => {
const updates: Array<{ id: number; patch: Record<string, unknown> }> = [];
const nations = [
{ id: 1, meta: { strategic_cmd_limit: 2, surlimit: '1', keep: true } },
{
id: 1,
meta: {
strategic_cmd_limit: 2,
surlimit: '1',
rate: 35,
rate_tmp: 10,
spy: '{"1":1,"2":2}',
keep: true,
},
},
{ id: 2, meta: { strategic_cmd_limit: 0, surlimit: 0 } },
];
const handler = createNationTurnMonthlyHandler({
@@ -178,16 +188,32 @@ describe('레거시 사령부 턴 실행 호환성', () => {
}) as never,
});
handler.onMonthChanged?.({} as never);
handler.beforeMonthChanged?.({} as never);
expect(updates).toEqual([
{
id: 1,
patch: { meta: { strategic_cmd_limit: 1, surlimit: 0, keep: true } },
patch: {
meta: {
strategic_cmd_limit: 1,
surlimit: 0,
rate: 35,
rate_tmp: 35,
spy: { 2: 1 },
keep: true,
},
},
},
{
id: 2,
patch: { meta: { strategic_cmd_limit: 0, surlimit: 0 } },
patch: {
meta: {
strategic_cmd_limit: 0,
surlimit: 0,
rate_tmp: 20,
spy: {},
},
},
},
]);
});
+9
View File
@@ -68,6 +68,12 @@ export interface TurnEngineInheritancePointRow {
value: number;
}
export interface TurnEngineGeneralAccessLogRow {
generalId: number;
userId: string | null;
refreshScoreTotal: number;
}
export interface TurnEngineCityRow {
id: number;
name: string;
@@ -382,6 +388,9 @@ export interface TurnEngineDatabaseClient {
inheritancePoint: {
findMany(args?: unknown): Promise<TurnEngineInheritancePointRow[]>;
};
generalAccessLog: {
findMany(args?: unknown): Promise<TurnEngineGeneralAccessLogRow[]>;
};
city: {
findMany(args?: unknown): Promise<TurnEngineCityRow[]>;
createMany(args: { data: TurnEngineCityCreateManyInput[] }): Promise<unknown>;