시나리오 기본 시작 연도와 출병 제한 기준 통일
This commit is contained in:
@@ -7,6 +7,9 @@ import {
|
||||
type RandomAppointmentResolveContext,
|
||||
} from '@sammo-ts/logic/actions/turn/general/che_랜덤임관.js';
|
||||
import type { ActionContextWorldRef } from '@sammo-ts/logic/actions/turn/actionContext.js';
|
||||
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
|
||||
import { InMemoryReservedTurnStore } from '../src/turn/reservedTurnStore.js';
|
||||
import { createReservedTurnHandler } from '../src/turn/reservedTurnHandler.js';
|
||||
import { buildCommandEnv } from '../src/turn/reservedTurnCommands.js';
|
||||
import { loadTurnWorldFromDatabase } from '../src/turn/worldLoader.js';
|
||||
import { resolveDatabaseUrl } from '../src/scenario/databaseUrl.js';
|
||||
@@ -103,6 +106,129 @@ const canRun = await canConnectToDatabase(databaseUrl);
|
||||
const describeDb = describe.runIf(canRun);
|
||||
|
||||
describeDb('scenario database seed', () => {
|
||||
test.each([2020, 1031])(
|
||||
'executes reserved neutral-city sortie only after scenario %i opening boundary',
|
||||
async (scenarioId) => {
|
||||
await seedScenarioToDatabase({
|
||||
scenarioId,
|
||||
databaseUrl,
|
||||
resetTables: true,
|
||||
now: new Date('2030-01-01T00:00:00Z'),
|
||||
installOptions: { sync: false, turnTermMinutes: 1 },
|
||||
});
|
||||
const connector = createGamePostgresConnector({ url: databaseUrl });
|
||||
try {
|
||||
await connector.connect();
|
||||
for (const allowed of [false, true]) {
|
||||
// 매번 DB에서 재로드하여 이전 전투 결과나 생성 당시 객체를 재사용하지 않는다.
|
||||
const loaded = await loadTurnWorldFromDatabase({ databaseUrl });
|
||||
const snapshot = loaded.snapshot;
|
||||
const startYear = snapshot.scenarioMeta!.startYear!;
|
||||
const source = snapshot.cities.find(
|
||||
(city) =>
|
||||
city.nationId > 0 &&
|
||||
snapshot.map.cities.find((entry) => entry.id === city.id)!.connections.length > 0
|
||||
)!;
|
||||
const destId = snapshot.map.cities.find((entry) => entry.id === source.id)!.connections[0]!;
|
||||
const actor = {
|
||||
...snapshot.generals[0]!,
|
||||
nationId: source.nationId,
|
||||
cityId: source.id,
|
||||
npcState: 0,
|
||||
officerLevel: 1,
|
||||
crew: 10000,
|
||||
rice: 100000,
|
||||
train: 100,
|
||||
atmos: 100,
|
||||
crewTypeId: snapshot.unitSet!.defaultCrewTypeId!,
|
||||
stats: { leadership: 100, strength: 100, intelligence: 100 },
|
||||
meta: { ...snapshot.generals[0]!.meta, killturn: 1000 },
|
||||
};
|
||||
snapshot.generals = [actor];
|
||||
snapshot.cities = snapshot.cities.map((city) =>
|
||||
city.id === destId ? { ...city, nationId: 0, defence: 0, wall: 0, population: 100 } : city
|
||||
);
|
||||
loaded.state.currentYear = startYear + (allowed ? 3 : 2);
|
||||
loaded.state.currentMonth = allowed ? 1 : 12;
|
||||
const store = new InMemoryReservedTurnStore(connector.prisma, {
|
||||
maxGeneralTurns: 30,
|
||||
maxNationTurns: 12,
|
||||
});
|
||||
await store.loadAll();
|
||||
store.setGeneralTurn(actor.id, 0, { action: 'che_출병', args: { destCityId: destId } });
|
||||
let world: InMemoryTurnWorld | null = null;
|
||||
const handler = await createReservedTurnHandler({
|
||||
reservedTurns: store,
|
||||
scenarioConfig: snapshot.scenarioConfig,
|
||||
scenarioMeta: snapshot.scenarioMeta,
|
||||
map: snapshot.map,
|
||||
unitSet: snapshot.unitSet,
|
||||
getWorld: () => world,
|
||||
});
|
||||
world = new InMemoryTurnWorld(loaded.state, snapshot, {
|
||||
schedule: { entries: [{ startMinute: 0, tickMinutes: 1 }] },
|
||||
generalTurnHandler: handler,
|
||||
});
|
||||
world.executeGeneralTurn(actor);
|
||||
const logs = world
|
||||
.peekDirtyState()
|
||||
.logs.map((entry) => entry.text)
|
||||
.join('\n');
|
||||
if (allowed) {
|
||||
expect(logs).not.toContain('초반 제한');
|
||||
expect(world.getCityById(destId)?.nationId).toBe(source.nationId);
|
||||
} else {
|
||||
expect(logs).toContain('초반 제한');
|
||||
expect(world.getCityById(destId)?.nationId).toBe(0);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await connector.disconnect();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
test.each([2020, 1031, 915])(
|
||||
'persists and reloads scenario %i calendar independently of the raw appearance year',
|
||||
async (scenarioId) => {
|
||||
const scenario = await loadScenarioDefinitionById(scenarioId);
|
||||
const baseYear = scenario.startYear ?? 180;
|
||||
for (const sync of [false, true]) {
|
||||
await seedScenarioToDatabase({
|
||||
scenarioId,
|
||||
databaseUrl,
|
||||
resetTables: true,
|
||||
now: new Date('2030-01-01T00:03:00Z'),
|
||||
installOptions: { sync, turnTermMinutes: 1, openAt: new Date('2030-01-01T00:03:00Z') },
|
||||
});
|
||||
const connector = createGamePostgresConnector({ url: databaseUrl });
|
||||
try {
|
||||
await connector.connect();
|
||||
const persisted = await connector.prisma.worldState.findFirstOrThrow();
|
||||
const meta = asRecord(persisted.meta);
|
||||
expect(asRecord(meta.scenarioMeta).startYear).toBe(baseYear);
|
||||
expect(persisted.currentYear).toBe(sync ? baseYear - 1 : baseYear);
|
||||
expect(persisted.currentMonth).toBe(sync ? 4 : 1);
|
||||
expect(meta.initYear).toBe(persisted.currentYear);
|
||||
expect(meta.initMonth).toBe(persisted.currentMonth);
|
||||
expect(meta.develcost).toBe(sync ? 18 : 20);
|
||||
const loaded = await loadTurnWorldFromDatabase({ databaseUrl });
|
||||
expect(loaded.snapshot.scenarioMeta?.startYear).toBe(baseYear);
|
||||
expect(loaded.state.currentYear).toBe(persisted.currentYear);
|
||||
if (scenario.startYear === null) {
|
||||
expect(await connector.prisma.general.count()).toBe(
|
||||
scenario.generals.length + scenario.generalsEx.length + scenario.generalsNeutral.length
|
||||
);
|
||||
const general = await connector.prisma.general.findFirstOrThrow();
|
||||
expect(general.age).toBeGreaterThan(0);
|
||||
}
|
||||
} finally {
|
||||
await connector.disconnect();
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
test.each([
|
||||
{ fiction: 1, expectedHistorical: false },
|
||||
{ fiction: 0, expectedHistorical: true },
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { buildScenarioBootstrap, type ConstraintContext } from '@sammo-ts/logic';
|
||||
import { ActionDefinition as Sortie } from '@sammo-ts/logic/actions/turn/general/che_출병.js';
|
||||
import { loadScenarioDefinitionById, resolveScenarioDefaultsPath } from '../src/scenario/scenarioLoader.js';
|
||||
import { loadMapDefinitionByName } from '../src/scenario/mapLoader.js';
|
||||
import { buildCommandEnv } from '../src/turn/reservedTurnCommands.js';
|
||||
import { resolveConstraintEnv } from '../src/turn/ai/generalAi/constraint.js';
|
||||
import { createMonthlyEventHandler } from '../src/turn/monthlyEventHandler.js';
|
||||
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
|
||||
import type { TurnEvent, TurnWorldState } from '../src/turn/types.js';
|
||||
|
||||
const files = await fs.readdir(path.dirname(resolveScenarioDefaultsPath()));
|
||||
const ids = files
|
||||
.flatMap((file) => {
|
||||
const match = /^scenario_(\d+)\.json$/.exec(file);
|
||||
return match ? [Number(match[1])] : [];
|
||||
})
|
||||
.sort((a, b) => a - b);
|
||||
|
||||
const isSortieNotice = (raw: unknown): raw is unknown[] =>
|
||||
Array.isArray(raw) && JSON.stringify(raw).includes('출병 제한');
|
||||
|
||||
const build = async (id: number, openingPartYear?: number) => {
|
||||
const scenario = await loadScenarioDefinitionById(id);
|
||||
if (openingPartYear !== undefined) scenario.config.const.openingPartYear = openingPartYear;
|
||||
const map = await loadMapDefinitionByName(scenario.config.environment.mapName);
|
||||
return { scenario, ...buildScenarioBootstrap({ scenario, map, options: { hiddenSeed: 'start-year-regression' } }) };
|
||||
};
|
||||
|
||||
describe('scenario calendar and opening contract', () => {
|
||||
it.each(ids)('scenario %i preserves raw appearance rules and resolves its game calendar', async (id) => {
|
||||
const { scenario, seed, snapshot } = await build(id);
|
||||
const startYear = scenario.startYear ?? 180;
|
||||
expect(seed.scenarioMeta.startYear).toBe(startYear);
|
||||
expect(snapshot.scenarioMeta?.startYear).toBe(startYear);
|
||||
if (scenario.startYear === null) {
|
||||
expect(seed.generals).toHaveLength(
|
||||
scenario.generals.length + scenario.generalsEx.length + scenario.generalsNeutral.length
|
||||
);
|
||||
}
|
||||
const command = new Sortie();
|
||||
const commandEnv = buildCommandEnv(scenario.config);
|
||||
const opening = commandEnv.openingPartYear as number;
|
||||
const dates = [
|
||||
{ year: startYear - 1, month: 4, reserve: false, execute: false },
|
||||
{ year: startYear + opening - 3, month: 12, reserve: false, execute: false },
|
||||
{ year: startYear + opening - 1, month: 12, reserve: true, execute: false },
|
||||
{ year: startYear + opening, month: 1, reserve: true, execute: true },
|
||||
];
|
||||
for (const date of dates) {
|
||||
const state: TurnWorldState = {
|
||||
id: 1,
|
||||
meta: {},
|
||||
currentYear: date.year,
|
||||
currentMonth: date.month,
|
||||
tickSeconds: 60,
|
||||
lastTurnTime: new Date('2030-01-01T00:00:00Z'),
|
||||
};
|
||||
const ctx: ConstraintContext = {
|
||||
actorId: 1,
|
||||
args: { destCityId: 2 },
|
||||
mode: 'full',
|
||||
env: resolveConstraintEnv(state, snapshot.scenarioMeta, commandEnv),
|
||||
};
|
||||
const view = { has: () => false, get: () => null };
|
||||
expect(command.buildMinConstraints(ctx, { destCityId: 2 })[0]!.test(ctx, view).kind).toBe(
|
||||
date.reserve ? 'allow' : 'deny'
|
||||
);
|
||||
expect(command.buildConstraints(ctx, { destCityId: 2 })[0]!.test(ctx, view).kind).toBe(
|
||||
date.execute ? 'allow' : 'deny'
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it.each([2020, 1031, 915])(
|
||||
'scenario %i announces unlock at the same execution boundary after reload',
|
||||
async (id) => {
|
||||
const { scenario, seed, snapshot } = await build(id);
|
||||
const startYear = seed.scenarioMeta.startYear!;
|
||||
const opening = buildCommandEnv(scenario.config).openingPartYear as number;
|
||||
const events: TurnEvent[] = seed.events.filter(isSortieNotice).map((raw, index) => ({
|
||||
id: index + 1,
|
||||
targetCode: String(raw[0]).toLowerCase(),
|
||||
priority: Number(raw[1]),
|
||||
condition: raw[2],
|
||||
action: raw.slice(3),
|
||||
meta: {},
|
||||
}));
|
||||
expect(events).toHaveLength(4);
|
||||
const notices: string[] = [];
|
||||
const state: TurnWorldState = {
|
||||
id: 1,
|
||||
meta: {},
|
||||
currentYear: startYear + opening - 1,
|
||||
currentMonth: 11,
|
||||
tickSeconds: 60,
|
||||
lastTurnTime: new Date('2030-01-01T00:00:00Z'),
|
||||
};
|
||||
let world: InMemoryTurnWorld | null = null;
|
||||
const handler = createMonthlyEventHandler({
|
||||
getWorld: () => world,
|
||||
startYear,
|
||||
actions: new Map([
|
||||
[
|
||||
'NoticeToHistoryLog',
|
||||
(args) => {
|
||||
notices.push(String(args[0]));
|
||||
},
|
||||
],
|
||||
]),
|
||||
});
|
||||
world = new InMemoryTurnWorld(
|
||||
state,
|
||||
{
|
||||
...snapshot,
|
||||
diplomacy: [],
|
||||
initialEvents: [],
|
||||
generals: [],
|
||||
events: JSON.parse(JSON.stringify(events)) as TurnEvent[],
|
||||
},
|
||||
{ schedule: { entries: [{ startMinute: 0, tickMinutes: 1 }] }, calendarHandler: handler }
|
||||
);
|
||||
await world.advanceMonth(new Date('2030-01-01T00:01:00Z'));
|
||||
expect(notices).toEqual([]);
|
||||
await world.advanceMonth(new Date('2030-01-01T00:02:00Z'));
|
||||
expect(notices).toEqual(['<S>출병 제한이 풀렸습니다.</>']);
|
||||
expect(
|
||||
world.listEvents().some((event) => JSON.stringify(event.action).includes('출병 제한이 풀렸습니다'))
|
||||
).toBe(false);
|
||||
}
|
||||
);
|
||||
|
||||
it('moves default notices with an explicitly configured opening period', async () => {
|
||||
const { seed } = await build(2020, 5);
|
||||
expect(seed.events.filter(isSortieNotice).map((event) => event[2])).toEqual([
|
||||
['DateRelative', '==', 3, 1],
|
||||
['DateRelative', '==', 4, 1],
|
||||
['DateRelative', '==', 4, 7],
|
||||
['DateRelative', '==', 5, 1],
|
||||
]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user