diff --git a/app/game-engine/test/reservedTurnExecution.test.ts b/app/game-engine/test/reservedTurnExecution.test.ts index b40e30d..60ed52e 100644 --- a/app/game-engine/test/reservedTurnExecution.test.ts +++ b/app/game-engine/test/reservedTurnExecution.test.ts @@ -37,7 +37,7 @@ const MINIMAL_MAP = { // --- Mocks & Helpers --- -const mockDate = new Date('189-01-01T00:00:00Z'); +const mockDate = new Date('0189-01-01T00:00:00Z'); // We need a mock Prisma client that satisfies the shape required by InMemoryReservedTurnStore // It expects { generalTurn: { findMany, deleteMany, createMany }, nationTurn: { ... } } @@ -322,6 +322,310 @@ describe('Reserved Turn Execution Integration', () => { // Turns should indeed be empty/default now. }); + it('should fall back to rest when args are invalid', async () => { + const invalidSnapshot: TurnWorldSnapshot = { + generals: [ + { + id: 1, + name: 'Fallback_General', + 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, + } as TurnGeneral, + ], + cities: [ + { + id: 1, + name: 'City_1', + nationId: 1, + viewName: 'City_1', + 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, + frontState: 0, + tradepoint: 0, + meta: {}, + } as any, + ], + nations: [ + { + id: 1, + name: 'FallbackNation', + color: '#FF0000', + capitalCityId: 1, + chiefGeneralId: 1, + gold: 10000, + rice: 10000, + power: 0, + level: 1, + typeCode: 'che_def', + meta: {}, + } as any, + ], + troops: [], + diplomacy: [], + events: [], + initialEvents: [], + map: MINIMAL_MAP as any, + 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, + } as any, + unitSet: {} as any, + }; + + const invalidState: TurnWorldState = { + id: 1, + currentYear: 189, + currentMonth: 1, + tickSeconds: 600, + lastTurnTime: mockDate, + meta: {}, + }; + + const invalidRows = [ + { generalId: 1, turnIdx: 0, actionCode: 'che_이동', arg: { destCityId: 'bad' } }, + ]; + + const mockPrisma = createMockPrisma(invalidRows); + const reservedTurnStore = new InMemoryReservedTurnStore(mockPrisma as any, { + maxGeneralTurns: 10, + maxNationTurns: 10, + }); + await reservedTurnStore.loadAll(); + + const wrapper = { world: null as InMemoryTurnWorld | null }; + const handler = await createReservedTurnHandler({ + reservedTurns: reservedTurnStore, + scenarioConfig: invalidSnapshot.scenarioConfig, + scenarioMeta: invalidSnapshot.scenarioMeta, + map: MINIMAL_MAP, + unitSet: invalidSnapshot.unitSet, + getWorld: () => wrapper.world, + }); + + const world = new InMemoryTurnWorld(invalidState, invalidSnapshot, { + schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] }, + generalTurnHandler: handler, + }); + wrapper.world = world; + + const processor = new InMemoryTurnProcessor(world, { tickMinutes: 10 }); + await processor.run(new Date(mockDate.getTime() + 10 * 60 * 1000), { + budgetMs: 1000, + maxGenerals: 100, + catchUpCap: 10, + }); + + const dirty = world.consumeDirtyState(); + expect(world.getGeneralById(1)!.cityId).toBe(1); + expect(dirty.logs.some((log) => log.text.includes('예약된 명령을 실행하지 못했습니다.'))).toBe(true); + expect(dirty.logs.some((log) => log.text.includes('아무것도 실행하지 않았습니다.'))).toBe(true); + }); + + it('should fall back to rest when constraints fail', async () => { + const invalidSnapshot: TurnWorldSnapshot = { + generals: [ + { + id: 1, + name: 'Fallback_General', + 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, + } as TurnGeneral, + ], + cities: [ + { + id: 1, + name: 'City_1', + nationId: 1, + viewName: 'City_1', + 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, + frontState: 0, + tradepoint: 0, + meta: {}, + } as any, + { + 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, + frontState: 0, + tradepoint: 0, + meta: {}, + } as any, + ], + nations: [ + { + id: 1, + name: 'FallbackNation', + color: '#FF0000', + capitalCityId: 1, + chiefGeneralId: 1, + gold: 10000, + rice: 10000, + power: 0, + level: 1, + typeCode: 'che_def', + meta: {}, + } as any, + ], + troops: [], + diplomacy: [], + events: [], + initialEvents: [], + map: MINIMAL_MAP as any, + 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, + } as any, + unitSet: {} as any, + }; + + const invalidState: TurnWorldState = { + id: 1, + currentYear: 189, + currentMonth: 1, + tickSeconds: 600, + lastTurnTime: mockDate, + meta: {}, + }; + + const invalidRows = [ + { generalId: 1, turnIdx: 0, actionCode: 'che_이동', arg: { destCityId: 1 } }, + ]; + + const mockPrisma = createMockPrisma(invalidRows); + const reservedTurnStore = new InMemoryReservedTurnStore(mockPrisma as any, { + maxGeneralTurns: 10, + maxNationTurns: 10, + }); + await reservedTurnStore.loadAll(); + + const wrapper = { world: null as InMemoryTurnWorld | null }; + const handler = await createReservedTurnHandler({ + reservedTurns: reservedTurnStore, + scenarioConfig: invalidSnapshot.scenarioConfig, + scenarioMeta: invalidSnapshot.scenarioMeta, + map: MINIMAL_MAP, + unitSet: invalidSnapshot.unitSet, + getWorld: () => wrapper.world, + }); + + const world = new InMemoryTurnWorld(invalidState, invalidSnapshot, { + schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] }, + generalTurnHandler: handler, + }); + wrapper.world = world; + + const processor = new InMemoryTurnProcessor(world, { tickMinutes: 10 }); + await processor.run(new Date(mockDate.getTime() + 10 * 60 * 1000), { + budgetMs: 1000, + maxGenerals: 100, + catchUpCap: 10, + }); + + const dirty = world.consumeDirtyState(); + expect(world.getGeneralById(1)!.cityId).toBe(1); + const denyLog = dirty.logs.find((log) => log.text.includes('같은 도시입니다.')); + expect(denyLog?.meta?.constraintName).toBe('notSameDestCity'); + expect(dirty.logs.some((log) => log.text.includes('아무것도 실행하지 않았습니다.'))).toBe(true); + }); + describe('Uprising and Founding Execution', () => { it('should execute uprising, fail founding in wrong city, fail domestic in wandering nation, then succeed founding and domestic', async () => { const mockDate = new Date('0189-01-01T00:00:00Z'); diff --git a/app/game-engine/test/turnOrder.test.ts b/app/game-engine/test/turnOrder.test.ts new file mode 100644 index 0000000..555c066 --- /dev/null +++ b/app/game-engine/test/turnOrder.test.ts @@ -0,0 +1,161 @@ +import { describe, expect, it } from 'vitest'; + +import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js'; +import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js'; +import { InMemoryTurnProcessor } from '../src/turn/inMemoryTurnProcessor.js'; + +const addMinutes = (time: Date, minutes: number): Date => new Date(time.getTime() + minutes * 60_000); + +const buildGeneral = (id: number, turnTime: Date): TurnGeneral => ({ + id, + name: `General_${id}`, + nationId: 1, + cityId: 1, + troopId: 0, + stats: { leadership: 50, strength: 50, intelligence: 50 }, + turnTime, + 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: 1000, + rice: 1000, + crew: 0, + crewTypeId: 0, + train: 0, + atmos: 0, + age: 30, + npcState: 0, +}); + +describe('InMemoryTurnProcessor ordering', () => { + it('executes generals by turnTime then id, not insertion order', async () => { + const baseTime = new Date('0189-01-01T00:00:00Z'); + + const generals: TurnGeneral[] = [ + buildGeneral(1, addMinutes(baseTime, 20)), + buildGeneral(2, addMinutes(baseTime, 10)), + buildGeneral(3, addMinutes(baseTime, 10)), + ]; + + const cities = [ + { + id: 1, + name: 'City_1', + nationId: 1, + viewName: 'City_1', + 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, + frontState: 0, + tradepoint: 0, + level: 1, + 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 map = { + id: 'test_map', + name: 'TestMap', + cities: [ + { + id: 1, + name: 'City_1', + level: 1, + region: 1, + position: { x: 0, y: 0 }, + connections: [], + max: {} as any, + initial: {} as any, + }, + ], + defaults: { trust: 50, trade: 100, supplyState: 1, frontState: 0 }, + }; + + const snapshot: TurnWorldSnapshot = { + generals: generals as any, + cities: cities as any, + nations: nations as any, + troops: [], + diplomacy: [], + events: [], + initialEvents: [], + map: map as any, + scenarioConfig: { + stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 50, npcMin: 10, chiefMin: 70 }, + iconPath: '', + map: {}, + const: {}, + environment: { mapName: 'test_map', unitSet: 'default' }, + }, + scenarioMeta: { + startYear: 189, + } as any, + unitSet: {} as any, + }; + + const state: TurnWorldState = { + id: 1, + currentYear: 189, + currentMonth: 1, + tickSeconds: 3600, + lastTurnTime: baseTime, + meta: {}, + }; + + const world = new InMemoryTurnWorld(state, snapshot, { + schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] }, + }); + + const executed: number[] = []; + const processor = new InMemoryTurnProcessor(world, { + tickMinutes: 10, + beforeExecuteGeneral: async (general) => { + executed.push(general.id); + }, + }); + + await processor.run(addMinutes(baseTime, 30), { + budgetMs: 1000, + maxGenerals: 10, + catchUpCap: 1, + }); + + expect(executed).toEqual([2, 3, 1]); + }); +}); diff --git a/packages/logic/test/scenarios/general/che_강행.test.ts b/packages/logic/test/scenarios/general/che_강행.test.ts index 4eaf969..ab17a63 100644 --- a/packages/logic/test/scenarios/general/che_강행.test.ts +++ b/packages/logic/test/scenarios/general/che_강행.test.ts @@ -247,6 +247,8 @@ describe('che_강행', () => { expect(updated?.atmos).toBe(45); // Cost: 10 * 5 = 50. Gold 1000 -> 950. expect(updated?.gold).toBe(950); + expect(updated?.experience).toBe(1100); + expect(updated?.meta.leadership_exp).toBe(1); }); it('should deny move to far city', async () => { diff --git a/packages/logic/test/scenarios/general/che_이동.test.ts b/packages/logic/test/scenarios/general/che_이동.test.ts new file mode 100644 index 0000000..6fe94e2 --- /dev/null +++ b/packages/logic/test/scenarios/general/che_이동.test.ts @@ -0,0 +1,137 @@ +import { describe, expect, it } from 'vitest'; +import type { General, Nation } from '../../../src/domain/entities.js'; +import { buildScenarioBootstrap } from '../../../src/world/bootstrap.js'; +import { InMemoryWorld, TestGameRunner } from '../../testEnv.js'; +import type { MapDefinition } from '../../../src/world/types.js'; + +const MOCK_SCENARIO_BASE = { + title: 'Test', + startYear: 200, + 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: 'linear_map', unitSet: 'test_set' }, + }, +}; + +const LINEAR_MAP: MapDefinition = { + id: 'linear_map', + name: 'Linear Map', + cities: [ + { + id: 101, + name: 'City1', + level: 1, + region: 1, + position: { x: 0, y: 0 }, + connections: [102], + max: {} as any, + initial: {} as any, + }, + { + id: 102, + name: 'City2', + level: 1, + region: 1, + position: { x: 0, y: 0 }, + connections: [101], + max: {} as any, + initial: {} as any, + }, + ], + defaults: { trust: 50, trade: 100, supplyState: 1, frontState: 0 }, +}; + +describe('che_이동', () => { + it('applies movement side effects (gold/atmos/exp/leadership_exp)', async () => { + const bootstrapResult = buildScenarioBootstrap({ + scenario: MOCK_SCENARIO_BASE, + map: LINEAR_MAP, + options: { defaultGeneralGold: 1000 }, + }); + const world = new InMemoryWorld(bootstrapResult.snapshot); + const runner = new TestGameRunner(world, 200, 1); + + const general: General = { + id: 1, + name: 'Mover', + nationId: 1, + cityId: 101, + 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: 1000, + rice: 1000, + crew: 0, + crewTypeId: 0, + train: 0, + atmos: 10, + age: 20, + npcState: 0, + triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} }, + meta: {}, + }; + const nation: Nation = { + id: 1, + name: 'MyNation', + color: '#000', + capitalCityId: 101, + chiefGeneralId: 1, + gold: 0, + rice: 0, + power: 0, + level: 1, + typeCode: 'che_def', + meta: {}, + }; + world.snapshot.generals.push(general); + world.snapshot.nations.push(nation); + + const moveDef = (await import('../../../src/actions/turn/general/che_이동.js')).commandSpec.createDefinition( + {} as any + ); + + await runner.runTurn([ + { + generalId: general.id, + commandKey: 'che_이동', + resolver: moveDef, + args: { destCityId: 102 }, + context: { + map: LINEAR_MAP, + develCost: 100, + }, + }, + ]); + + const updated = world.getGeneral(general.id); + expect(updated?.cityId).toBe(102); + expect(updated?.gold).toBe(900); + expect(updated?.atmos).toBe(20); + expect(updated?.experience).toBe(50); + expect(updated?.meta.leadership_exp).toBe(1); + }); +});