feat(engine): migrate monthly pre-update boundary
This commit is contained in:
@@ -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: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user