merge: migrate monthly pre-update boundary
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -32,6 +32,7 @@ export interface TurnGeneral extends General {
|
||||
lastTurn?: GeneralLastTurn;
|
||||
penalty?: unknown;
|
||||
inheritancePoints?: Record<string, number>;
|
||||
refreshScoreTotal?: number;
|
||||
}
|
||||
|
||||
export interface TurnDiplomacy {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -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>;
|
||||
|
||||
Reference in New Issue
Block a user