예턴을 포함한 테스트
This commit is contained in:
@@ -0,0 +1,323 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import type { TurnSchedule } from '@sammo-ts/logic';
|
||||||
|
import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
|
||||||
|
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
|
||||||
|
import { InMemoryReservedTurnStore } from '../src/turn/reservedTurnStore.js';
|
||||||
|
import { createReservedTurnHandler } from '../src/turn/reservedTurnHandler.js';
|
||||||
|
// Inline MINIMAL_MAP to avoid cross-package relative import issues
|
||||||
|
const MINIMAL_MAP = {
|
||||||
|
id: 'minimal_map',
|
||||||
|
name: '최소형맵',
|
||||||
|
cities: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: '소성A',
|
||||||
|
level: 1,
|
||||||
|
region: 1,
|
||||||
|
position: { x: 50, y: 10 },
|
||||||
|
connections: [2, 3, 5, 6, 8],
|
||||||
|
max: { population: 20000, agriculture: 2000, commerce: 2000, security: 2000, defence: 500, wall: 500 },
|
||||||
|
initial: { population: 5000, agriculture: 100, commerce: 100, security: 100, defence: 100, wall: 100 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
name: '중성B',
|
||||||
|
level: 2,
|
||||||
|
region: 2,
|
||||||
|
position: { x: 20, y: 30 },
|
||||||
|
connections: [1, 4, 5, 6, 9],
|
||||||
|
max: { population: 30000, agriculture: 3000, commerce: 3000, security: 3000, defence: 600, wall: 600 },
|
||||||
|
initial: { population: 8000, agriculture: 200, commerce: 200, security: 200, defence: 200, wall: 200 },
|
||||||
|
},
|
||||||
|
// ... (other cities if needed, but test only uses 1 and 2)
|
||||||
|
],
|
||||||
|
defaults: { trust: 50, trade: 100, supplyState: 1, frontState: 0 },
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Mocks & Helpers ---
|
||||||
|
|
||||||
|
const mockDate = new Date('189-01-01T00:00:00Z');
|
||||||
|
|
||||||
|
// We need a mock Prisma client that satisfies the shape required by InMemoryReservedTurnStore
|
||||||
|
// It expects { generalTurn: { findMany, deleteMany, createMany }, nationTurn: { ... } }
|
||||||
|
const createMockPrisma = (initialGeneralRows: any[] = []) => {
|
||||||
|
let generalRows = [...initialGeneralRows];
|
||||||
|
return {
|
||||||
|
generalTurn: {
|
||||||
|
findMany: vi.fn(async ({ where } = {}) => {
|
||||||
|
if (where?.generalId) {
|
||||||
|
return generalRows
|
||||||
|
.filter((r) => r.generalId === where.generalId)
|
||||||
|
.sort((a, b) => a.turnIdx - b.turnIdx);
|
||||||
|
}
|
||||||
|
return generalRows;
|
||||||
|
}),
|
||||||
|
deleteMany: vi.fn(async ({ where } = {}) => {
|
||||||
|
if (where?.generalId) {
|
||||||
|
generalRows = generalRows.filter((r) => r.generalId !== where.generalId);
|
||||||
|
}
|
||||||
|
return { count: 0 };
|
||||||
|
}),
|
||||||
|
createMany: vi.fn(async ({ data }) => {
|
||||||
|
if (Array.isArray(data)) {
|
||||||
|
generalRows.push(...data);
|
||||||
|
}
|
||||||
|
return { count: data.length };
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
nationTurn: {
|
||||||
|
findMany: vi.fn(async () => []),
|
||||||
|
deleteMany: vi.fn(async () => ({ count: 0 })),
|
||||||
|
createMany: vi.fn(async () => ({ count: 0 })),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('Reserved Turn Execution Integration', () => {
|
||||||
|
it('should execute reserved turns and update world state', async () => {
|
||||||
|
// 1. Setup World Data
|
||||||
|
const generals: TurnGeneral[] = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: 'General_0',
|
||||||
|
nationId: 1,
|
||||||
|
cityId: 1,
|
||||||
|
troopId: 0,
|
||||||
|
stats: { leadership: 80, strength: 80, intelligence: 80 },
|
||||||
|
turnTime: mockDate,
|
||||||
|
role: {
|
||||||
|
items: { horse: null, weapon: null, book: null, item: null },
|
||||||
|
personality: null,
|
||||||
|
specialDomestic: null,
|
||||||
|
specialWar: null,
|
||||||
|
},
|
||||||
|
triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} },
|
||||||
|
meta: {},
|
||||||
|
officerLevel: 5,
|
||||||
|
experience: 0,
|
||||||
|
dedication: 0,
|
||||||
|
injury: 0,
|
||||||
|
gold: 2000,
|
||||||
|
rice: 2000,
|
||||||
|
crew: 0,
|
||||||
|
crewTypeId: 0,
|
||||||
|
train: 0,
|
||||||
|
atmos: 0,
|
||||||
|
age: 30,
|
||||||
|
npcState: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
name: 'General_1',
|
||||||
|
nationId: 1,
|
||||||
|
cityId: 1,
|
||||||
|
troopId: 0,
|
||||||
|
stats: { leadership: 80, strength: 80, intelligence: 80 },
|
||||||
|
turnTime: mockDate,
|
||||||
|
role: {
|
||||||
|
items: { horse: null, weapon: null, book: null, item: null },
|
||||||
|
personality: null,
|
||||||
|
specialDomestic: null,
|
||||||
|
specialWar: null,
|
||||||
|
},
|
||||||
|
triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} },
|
||||||
|
meta: {},
|
||||||
|
officerLevel: 5,
|
||||||
|
experience: 0,
|
||||||
|
dedication: 0,
|
||||||
|
injury: 0,
|
||||||
|
gold: 2000,
|
||||||
|
rice: 2000,
|
||||||
|
crew: 0,
|
||||||
|
crewTypeId: 0,
|
||||||
|
train: 0,
|
||||||
|
atmos: 0,
|
||||||
|
age: 30,
|
||||||
|
npcState: 0,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const cities = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: 'City_1',
|
||||||
|
nationId: 1,
|
||||||
|
viewName: 'City_1',
|
||||||
|
agric: 100, // old prop name check? No, interface uses agriculture
|
||||||
|
agriculture: 100,
|
||||||
|
agricultureMax: 2000,
|
||||||
|
commerce: 100,
|
||||||
|
commerceMax: 2000,
|
||||||
|
security: 100,
|
||||||
|
securityMax: 100,
|
||||||
|
def: 100,
|
||||||
|
defMax: 100,
|
||||||
|
wall: 100,
|
||||||
|
wallMax: 100,
|
||||||
|
pop: 10000,
|
||||||
|
popMax: 50000,
|
||||||
|
trust: 50,
|
||||||
|
supplyState: 1, // Correct property name
|
||||||
|
frontState: 0,
|
||||||
|
tradepoint: 0,
|
||||||
|
meta: {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
name: 'City_2',
|
||||||
|
nationId: 1,
|
||||||
|
viewName: 'City_2',
|
||||||
|
agriculture: 100,
|
||||||
|
agricultureMax: 2000,
|
||||||
|
commerce: 100,
|
||||||
|
commerceMax: 2000,
|
||||||
|
security: 100,
|
||||||
|
securityMax: 100,
|
||||||
|
def: 100,
|
||||||
|
defMax: 100,
|
||||||
|
wall: 100,
|
||||||
|
wallMax: 100,
|
||||||
|
pop: 10000,
|
||||||
|
popMax: 50000,
|
||||||
|
trust: 50,
|
||||||
|
supplyState: 1, // Correct property name
|
||||||
|
frontState: 0,
|
||||||
|
tradepoint: 0,
|
||||||
|
meta: {},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const nations = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: 'TestNation',
|
||||||
|
color: '#FF0000',
|
||||||
|
capitalCityId: 1,
|
||||||
|
chiefGeneralId: 1,
|
||||||
|
gold: 10000,
|
||||||
|
rice: 10000,
|
||||||
|
power: 0,
|
||||||
|
level: 1,
|
||||||
|
typeCode: 'che_def',
|
||||||
|
meta: {},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const snapshot: TurnWorldSnapshot = {
|
||||||
|
generals: generals as any,
|
||||||
|
cities: cities as any,
|
||||||
|
nations: nations as any,
|
||||||
|
troops: [],
|
||||||
|
diplomacy: [],
|
||||||
|
events: [],
|
||||||
|
initialEvents: [],
|
||||||
|
map: MINIMAL_MAP,
|
||||||
|
scenarioConfig: {
|
||||||
|
stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 50, npcMin: 10, chiefMin: 70 },
|
||||||
|
iconPath: '',
|
||||||
|
map: {},
|
||||||
|
const: {},
|
||||||
|
environment: { mapName: 'minimal', unitSet: 'default' },
|
||||||
|
},
|
||||||
|
scenarioMeta: {
|
||||||
|
startYear: 189,
|
||||||
|
// Add other required props if any
|
||||||
|
} as any,
|
||||||
|
unitSet: {
|
||||||
|
// mock unit set
|
||||||
|
} as any,
|
||||||
|
};
|
||||||
|
|
||||||
|
const state: TurnWorldState = {
|
||||||
|
id: 1,
|
||||||
|
currentYear: 189,
|
||||||
|
currentMonth: 1,
|
||||||
|
tickSeconds: 600,
|
||||||
|
lastTurnTime: mockDate,
|
||||||
|
meta: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const schedule: TurnSchedule = {
|
||||||
|
entries: [{ startMinute: 0, tickMinutes: 10 }],
|
||||||
|
};
|
||||||
|
|
||||||
|
// 2. Setup Reserved Turns
|
||||||
|
// Gen 1: Agriculture (x2) -> Move City 2
|
||||||
|
// Gen 2: Commerce (x2) -> Train
|
||||||
|
const initialRows = [
|
||||||
|
{ generalId: 1, turnIdx: 0, actionCode: 'che_농지개간', arg: {} },
|
||||||
|
{ generalId: 1, turnIdx: 1, actionCode: 'che_농지개간', arg: {} },
|
||||||
|
{ generalId: 1, turnIdx: 2, actionCode: 'che_이동', arg: { destCityId: 2 } },
|
||||||
|
|
||||||
|
{ generalId: 2, turnIdx: 0, actionCode: 'che_상업투자', arg: {} },
|
||||||
|
{ generalId: 2, turnIdx: 1, actionCode: 'che_상업투자', arg: {} },
|
||||||
|
{ generalId: 2, turnIdx: 2, actionCode: 'che_훈련', arg: {} },
|
||||||
|
];
|
||||||
|
|
||||||
|
const mockPrisma = createMockPrisma(initialRows);
|
||||||
|
const reservedTurnStore = new InMemoryReservedTurnStore(mockPrisma as any, {
|
||||||
|
maxGeneralTurns: 10,
|
||||||
|
maxNationTurns: 10,
|
||||||
|
});
|
||||||
|
await reservedTurnStore.loadAll();
|
||||||
|
|
||||||
|
// 3. Setup Handler & World (Circular dependency resolution)
|
||||||
|
const wrapper = { world: null as InMemoryTurnWorld | null };
|
||||||
|
|
||||||
|
const handler = await createReservedTurnHandler({
|
||||||
|
reservedTurns: reservedTurnStore,
|
||||||
|
scenarioConfig: snapshot.scenarioConfig,
|
||||||
|
scenarioMeta: snapshot.scenarioMeta,
|
||||||
|
map: MINIMAL_MAP,
|
||||||
|
unitSet: snapshot.unitSet,
|
||||||
|
getWorld: () => wrapper.world,
|
||||||
|
});
|
||||||
|
|
||||||
|
const world = new InMemoryTurnWorld(state, snapshot, {
|
||||||
|
schedule,
|
||||||
|
generalTurnHandler: handler,
|
||||||
|
});
|
||||||
|
wrapper.world = world;
|
||||||
|
|
||||||
|
// 4. Run Execution Loop (3 Turns)
|
||||||
|
const limitTurns = 3;
|
||||||
|
for (let i = 0; i < limitTurns; i++) {
|
||||||
|
const activeGenerals = world.listGenerals();
|
||||||
|
|
||||||
|
// In real engine, we might sort by turn time.
|
||||||
|
// Here assuming synchronous execution for test simplicity
|
||||||
|
for (const gen of activeGenerals) {
|
||||||
|
world.executeGeneralTurn(gen);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flush changes to mock DB (simulate persistence)
|
||||||
|
await reservedTurnStore.flushChanges();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Verify Results
|
||||||
|
const finalGen1 = world.getGeneralById(1)!;
|
||||||
|
const finalGen2 = world.getGeneralById(2)!;
|
||||||
|
const finalCity1 = world.getCityById(1)!;
|
||||||
|
|
||||||
|
// Gen 1 moved to City 2?
|
||||||
|
expect(finalGen1.cityId).toBe(2);
|
||||||
|
|
||||||
|
// Gen 2 stayed in City 1?
|
||||||
|
expect(finalGen2.cityId).toBe(1);
|
||||||
|
|
||||||
|
// City 1 Agric increased (100 -> 300)
|
||||||
|
expect(finalCity1.agriculture).toBeGreaterThanOrEqual(300);
|
||||||
|
|
||||||
|
// City 1 Commerce increased (100 -> ~178)
|
||||||
|
expect(finalCity1.commerce).toBeGreaterThanOrEqual(170);
|
||||||
|
|
||||||
|
// Gen 1 reserved turns should be shifted and empty/default
|
||||||
|
const gen1Turns = reservedTurnStore.getGeneralTurns(1);
|
||||||
|
expect(gen1Turns[0].action).toBe('휴식'); // Since we consumed 3 turns, next should be rest (default)
|
||||||
|
// Wait, initial had 3 items. After 3 turns:
|
||||||
|
// Turn 0 exec -> Shift -1
|
||||||
|
// Turn 1 exec -> Shift -1
|
||||||
|
// Turn 2 exec -> Shift -1
|
||||||
|
// Turns should indeed be empty/default now.
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,316 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import type { RandomGenerator } from '@sammo-ts/common';
|
||||||
|
import { produce } from 'immer';
|
||||||
|
import { MINIMAL_MAP } from '../fixtures/minimalMap.js';
|
||||||
|
import type { TestCommand } from '../testEnv.js';
|
||||||
|
import { InMemoryWorld, TestGameRunner } from '../testEnv.js';
|
||||||
|
import { buildScenarioBootstrap } from '../../src/world/bootstrap.js';
|
||||||
|
import type { ScenarioDefinition } from '../../src/scenario/types.js';
|
||||||
|
import type { Nation } from '../../src/domain/entities.js';
|
||||||
|
import type { TurnCommandEnv } from '../../src/actions/turn/commandEnv.js';
|
||||||
|
import type { TurnSchedule } from '../../src/turn/calendar.js';
|
||||||
|
import { getNextTurnAt } from '../../src/turn/calendar.js';
|
||||||
|
import { resolveGeneralAction } from '../../src/actions/engine.js';
|
||||||
|
|
||||||
|
// Import Command Specs
|
||||||
|
import { commandSpec as agricultureSpec } from '../../src/actions/turn/general/che_농지개간.js';
|
||||||
|
import { commandSpec as commerceSpec } from '../../src/actions/turn/general/che_상업투자.js';
|
||||||
|
import { commandSpec as trainSpec } from '../../src/actions/turn/general/che_훈련.js';
|
||||||
|
import { commandSpec as moveSpec } from '../../src/actions/turn/general/che_이동.js';
|
||||||
|
import { commandSpec as uprisingSpec } from '../../src/actions/turn/general/che_거병.js';
|
||||||
|
import { commandSpec as appointmentSpec } from '../../src/actions/turn/general/che_임관.js';
|
||||||
|
import { commandSpec as foundNationSpec } from '../../src/actions/turn/general/che_건국.js';
|
||||||
|
|
||||||
|
// Define Command System Env
|
||||||
|
const systemEnv: TurnCommandEnv = {
|
||||||
|
develCost: 50,
|
||||||
|
trainDelta: 5,
|
||||||
|
atmosDelta: 5,
|
||||||
|
maxTrainByCommand: 100,
|
||||||
|
maxAtmosByCommand: 100,
|
||||||
|
sabotageDefaultProb: 0.5,
|
||||||
|
sabotageProbCoefByStat: 0.1,
|
||||||
|
sabotageDefenceCoefByGeneralCount: 0.1,
|
||||||
|
sabotageDamageMin: 10,
|
||||||
|
sabotageDamageMax: 20,
|
||||||
|
openingPartYear: 200,
|
||||||
|
maxGeneral: 10,
|
||||||
|
defaultNpcGold: 1000,
|
||||||
|
defaultNpcRice: 1000,
|
||||||
|
defaultCrewTypeId: 1,
|
||||||
|
defaultSpecialDomestic: null,
|
||||||
|
defaultSpecialWar: null,
|
||||||
|
initialNationGenLimit: 10,
|
||||||
|
maxTechLevel: 10,
|
||||||
|
baseGold: 1000,
|
||||||
|
baseRice: 1000,
|
||||||
|
maxResourceActionAmount: 1000,
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Helper Types for Reserved Turn ---
|
||||||
|
|
||||||
|
interface ReservedCommand {
|
||||||
|
commandKey: string;
|
||||||
|
args: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ReservedTurnMap = Record<number, ReservedCommand[]>;
|
||||||
|
|
||||||
|
class ReservedTurnRunner extends TestGameRunner {
|
||||||
|
private schedule: TurnSchedule;
|
||||||
|
|
||||||
|
constructor(world: InMemoryWorld, startYear: number, startMonth: number, turnMinutes: number = 60) {
|
||||||
|
super(world, startYear, startMonth);
|
||||||
|
this.schedule = {
|
||||||
|
entries: [{ startMinute: 0, tickMinutes: turnMinutes }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Override runTurn to throw error or handle differently if needed,
|
||||||
|
// but here we define a new method for scheduled execution.
|
||||||
|
async runScheduler(reservedTurns: ReservedTurnMap, limitTurns: number = 10) {
|
||||||
|
let turnsProcessed = 0;
|
||||||
|
|
||||||
|
// Clone reservedTurns to consume them
|
||||||
|
const queues: Record<number, ReservedCommand[]> = {};
|
||||||
|
for (const [genId, cmds] of Object.entries(reservedTurns)) {
|
||||||
|
queues[Number(genId)] = [...cmds];
|
||||||
|
}
|
||||||
|
|
||||||
|
while (turnsProcessed < limitTurns) {
|
||||||
|
// Determine next turn time
|
||||||
|
const nextTurnAt = getNextTurnAt(this.currentDate, this.schedule);
|
||||||
|
|
||||||
|
// Collect commands for this turn
|
||||||
|
const turnCommands: TestCommand[] = [];
|
||||||
|
const activeGenerals = this.world.getAllGenerals();
|
||||||
|
|
||||||
|
for (const general of activeGenerals) {
|
||||||
|
const queue = queues[general.id];
|
||||||
|
if (queue && queue.length > 0) {
|
||||||
|
const cmd = queue.shift()!; // Dequeue the first command
|
||||||
|
|
||||||
|
// Resolve spec helper
|
||||||
|
let resolver;
|
||||||
|
if (cmd.commandKey === 'che_농지개간') resolver = agricultureSpec.createDefinition(systemEnv);
|
||||||
|
else if (cmd.commandKey === 'che_상업투자') resolver = commerceSpec.createDefinition(systemEnv);
|
||||||
|
else if (cmd.commandKey === 'che_훈련') resolver = trainSpec.createDefinition(systemEnv);
|
||||||
|
else if (cmd.commandKey === 'che_이동') resolver = moveSpec.createDefinition(systemEnv);
|
||||||
|
else if (cmd.commandKey === 'che_거병') resolver = uprisingSpec.createDefinition(systemEnv);
|
||||||
|
else if (cmd.commandKey === 'che_임관') resolver = appointmentSpec.createDefinition(systemEnv);
|
||||||
|
else if (cmd.commandKey === 'che_건국') resolver = foundNationSpec.createDefinition(systemEnv);
|
||||||
|
else throw new Error(`Unknown command key: ${cmd.commandKey}`);
|
||||||
|
|
||||||
|
turnCommands.push({
|
||||||
|
generalId: general.id,
|
||||||
|
commandKey: cmd.commandKey,
|
||||||
|
resolver,
|
||||||
|
args: cmd.args,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute the commands
|
||||||
|
for (const cmd of turnCommands) {
|
||||||
|
const general = this.world.getGeneral(cmd.generalId);
|
||||||
|
if (!general) continue;
|
||||||
|
|
||||||
|
const city = this.world.getCity(general.cityId);
|
||||||
|
if (!city) throw new Error(`General ${general.id} is in non-existent city ${general.cityId}`);
|
||||||
|
const nation = general.nationId ? this.world.getNation(general.nationId) : null;
|
||||||
|
|
||||||
|
// Simple RNG mock
|
||||||
|
const rng: RandomGenerator = {
|
||||||
|
nextFloat: () => 0.5,
|
||||||
|
nextBool: () => true,
|
||||||
|
nextInt: (min: number, _max: number) => min,
|
||||||
|
};
|
||||||
|
|
||||||
|
const inputContext = {
|
||||||
|
general,
|
||||||
|
city,
|
||||||
|
nation: nation || null,
|
||||||
|
rng,
|
||||||
|
year: this.currentDate.getFullYear(),
|
||||||
|
month: this.currentDate.getMonth() + 1,
|
||||||
|
season: Math.floor(this.currentDate.getMonth() / 3),
|
||||||
|
map: this.world.snapshot.map,
|
||||||
|
unitSet: this.world.snapshot.unitSet,
|
||||||
|
cities: this.world.snapshot.cities,
|
||||||
|
...cmd.context,
|
||||||
|
};
|
||||||
|
|
||||||
|
const scheduleContext = {
|
||||||
|
now: this.currentDate,
|
||||||
|
schedule: this.schedule,
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolution = resolveGeneralAction(cmd.resolver, inputContext as any, scheduleContext, cmd.args);
|
||||||
|
await this.world.applyResolution(resolution);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Advance time
|
||||||
|
this.currentDate = nextTurnAt;
|
||||||
|
turnsProcessed++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Test Setup ---
|
||||||
|
|
||||||
|
const MOCK_SCENARIO: ScenarioDefinition = {
|
||||||
|
title: 'Reserved Turn Scenario',
|
||||||
|
startYear: 189,
|
||||||
|
life: null,
|
||||||
|
fiction: 0,
|
||||||
|
history: [],
|
||||||
|
ignoreDefaultEvents: false,
|
||||||
|
nations: [],
|
||||||
|
diplomacy: [],
|
||||||
|
generals: [],
|
||||||
|
generalsEx: [],
|
||||||
|
generalsNeutral: [],
|
||||||
|
cities: [],
|
||||||
|
events: [],
|
||||||
|
initialEvents: [],
|
||||||
|
config: {
|
||||||
|
stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 50, npcMin: 10, chiefMin: 70 },
|
||||||
|
iconPath: '',
|
||||||
|
map: {},
|
||||||
|
const: {},
|
||||||
|
environment: {
|
||||||
|
mapName: 'minimal_map',
|
||||||
|
unitSet: 'test_set',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const MOCK_GENERALS = Array.from({ length: 3 }, (_, i) => ({
|
||||||
|
name: `General_${i}`,
|
||||||
|
nation: null,
|
||||||
|
city: MINIMAL_MAP.cities[0]?.name || 'Unknown', // Start at City 1 (소성A)
|
||||||
|
officerLevel: 0,
|
||||||
|
birthYear: 160,
|
||||||
|
deathYear: 220,
|
||||||
|
strength: 80,
|
||||||
|
intelligence: 80,
|
||||||
|
leadership: 80,
|
||||||
|
personality: null,
|
||||||
|
special: null,
|
||||||
|
specialWar: null,
|
||||||
|
affinity: 0,
|
||||||
|
picture: null,
|
||||||
|
horse: null,
|
||||||
|
weapon: null,
|
||||||
|
book: null,
|
||||||
|
item: null,
|
||||||
|
text: null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const scenarioWithGenerals = produce(MOCK_SCENARIO, (draft) => {
|
||||||
|
draft.generalsNeutral = MOCK_GENERALS;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Reserved Turn Execution', () => {
|
||||||
|
it('should execute queued commands for multiple generals over time without intervention', async () => {
|
||||||
|
// 1. Initialize World
|
||||||
|
const bootstrapResult = buildScenarioBootstrap({
|
||||||
|
scenario: scenarioWithGenerals,
|
||||||
|
map: MINIMAL_MAP,
|
||||||
|
options: {
|
||||||
|
includeNeutralNation: true,
|
||||||
|
defaultGeneralGold: 2000,
|
||||||
|
defaultGeneralRice: 2000,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Ensure City 1 has enough defaults so actions work
|
||||||
|
const snapshot = produce(bootstrapResult.snapshot, (draft) => {
|
||||||
|
const city1 = draft.cities.find((c) => c.id === 1)!;
|
||||||
|
city1.agriculture = 100;
|
||||||
|
city1.agricultureMax = 2000;
|
||||||
|
city1.commerce = 100;
|
||||||
|
city1.commerceMax = 2000;
|
||||||
|
|
||||||
|
// Make generals belong to a nation so they can do domestic actions if needed
|
||||||
|
const nation: Nation = {
|
||||||
|
id: 1,
|
||||||
|
name: 'TestNation',
|
||||||
|
color: '#FF0000',
|
||||||
|
capitalCityId: 1,
|
||||||
|
chiefGeneralId: draft.generals[0]!.id,
|
||||||
|
gold: 10000,
|
||||||
|
rice: 10000,
|
||||||
|
power: 0,
|
||||||
|
level: 1,
|
||||||
|
typeCode: 'che_def',
|
||||||
|
meta: {},
|
||||||
|
};
|
||||||
|
draft.nations.push(nation);
|
||||||
|
|
||||||
|
draft.generals.forEach((g) => {
|
||||||
|
g.nationId = 1;
|
||||||
|
g.officerLevel = 5; // General
|
||||||
|
});
|
||||||
|
if (draft.cities[0]) draft.cities[0].nationId = 1;
|
||||||
|
if (draft.cities[1]) draft.cities[1].nationId = 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
const mutableSnapshot = JSON.parse(JSON.stringify(snapshot));
|
||||||
|
const world = new InMemoryWorld(mutableSnapshot);
|
||||||
|
|
||||||
|
// 2. Setup Runner with 10 minute turns
|
||||||
|
// This is where "Setting turn time" happens
|
||||||
|
const runner = new ReservedTurnRunner(world, 189, 1, 10);
|
||||||
|
|
||||||
|
// 3. Define Reserved Turns
|
||||||
|
// This is "Setting all generals' reserved turns"
|
||||||
|
const gen0 = world.getAllGenerals().find((g) => g.name === 'General_0')!;
|
||||||
|
const gen1 = world.getAllGenerals().find((g) => g.name === 'General_1')!;
|
||||||
|
const gen2 = world.getAllGenerals().find((g) => g.name === 'General_2')!;
|
||||||
|
|
||||||
|
const reservedTurns: ReservedTurnMap = {
|
||||||
|
[gen0.id]: [
|
||||||
|
{ commandKey: 'che_농지개간', args: {} },
|
||||||
|
{ commandKey: 'che_농지개간', args: {} },
|
||||||
|
{ commandKey: 'che_이동', args: { destCityId: 2 } },
|
||||||
|
],
|
||||||
|
[gen1.id]: [
|
||||||
|
{ commandKey: 'che_상업투자', args: {} },
|
||||||
|
{ commandKey: 'che_상업투자', args: {} },
|
||||||
|
{ commandKey: 'che_훈련', args: {} },
|
||||||
|
],
|
||||||
|
[gen2.id]: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
// 4. Run Scheduler for 3 turns
|
||||||
|
// This is "'Not touching it' and execute"
|
||||||
|
await runner.runScheduler(reservedTurns, 3);
|
||||||
|
|
||||||
|
// 5. Verify Results
|
||||||
|
|
||||||
|
// Timer Check: 3 turns of 10 mins = 30 mins elapsed?
|
||||||
|
// 189-01-01 00:00 -> 00:10 -> 00:20 -> 00:30.
|
||||||
|
expect(runner.currentDate.getMinutes()).toBe(30);
|
||||||
|
|
||||||
|
const finalGen0 = world.getGeneral(gen0.id)!;
|
||||||
|
const finalGen1 = world.getGeneral(gen1.id)!;
|
||||||
|
const finalCity1 = world.getCity(1)!;
|
||||||
|
|
||||||
|
// Gen 0 moved to City 2
|
||||||
|
expect(finalGen0.cityId).toBe(2);
|
||||||
|
|
||||||
|
// Gen 1 stayed in City 1
|
||||||
|
expect(finalGen1.cityId).toBe(1);
|
||||||
|
|
||||||
|
// City 1 Agric/Commerce increased
|
||||||
|
// Agric is fixed +100 per turn: 100 -> 200 -> 300.
|
||||||
|
expect(finalCity1.agriculture).toBeGreaterThanOrEqual(300);
|
||||||
|
// Commerce depends on trust/stats. Default trust 50, so ~40 per turn.
|
||||||
|
// 100 -> 140 -> 180.
|
||||||
|
expect(finalCity1.commerce).toBeGreaterThanOrEqual(180);
|
||||||
|
|
||||||
|
// Gen 1 Trained
|
||||||
|
expect(finalGen1.train).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user