diff --git a/app/game-engine/test/reservedTurnExecution.test.ts b/app/game-engine/test/reservedTurnExecution.test.ts new file mode 100644 index 00000000..c87c8790 --- /dev/null +++ b/app/game-engine/test/reservedTurnExecution.test.ts @@ -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. + }); +}); diff --git a/packages/logic/test/scenarios/reservedTurnExecution.test.ts b/packages/logic/test/scenarios/reservedTurnExecution.test.ts new file mode 100644 index 00000000..62a8f915 --- /dev/null +++ b/packages/logic/test/scenarios/reservedTurnExecution.test.ts @@ -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; + +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 = {}; + 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); + }); +});