import { describe, expect, it } from 'vitest'; import { produce } from 'immer'; import { MINIMAL_MAP } from '../fixtures/minimalMap.js'; import { InMemoryWorld, TestGameRunner } from '../testEnv.js'; import { buildScenarioBootstrap } from '../../src/world/bootstrap.js'; import type { ScenarioDefinition, ScenarioGeneral } from '../../src/scenario/types.js'; import type { Nation, City, General } from '../../src/domain/entities.js'; import { commandSpec as foundNationSpec } 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 type { TurnCommandEnv } from '../../src/actions/turn/commandEnv.js'; import type { ConstraintContext, RequirementKey, StateView } from '../../src/constraints/types.js'; import { evaluateActionConstraints } from '../../src/constraints/evaluate.js'; // Mock Scenario Definition const MOCK_SCENARIO: ScenarioDefinition = { title: 'Test Scenario', startYear: 189, life: null, fiction: 0, history: [], ignoreDefaultEvents: false, nations: [], // No nations initially 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', }, }, }; // Mock General Data const MOCK_GENERALS: ScenarioGeneral[] = Array.from({ length: 10 }, (_, i) => { const cityDef = MINIMAL_MAP.cities[i % MINIMAL_MAP.cities.length]; if (!cityDef) throw new Error('City definition missing'); return { name: `General_${i}`, nation: null, // neutral city: cityDef.name, officerLevel: 0, birthYear: 160, deathYear: 220, strength: 70 + i, intelligence: 70 + i, leadership: 70 + i, personality: null, special: null, specialWar: null, affinity: 0, picture: null, horse: null, weapon: null, book: null, item: null, text: null, }; }); // We need to inject generals into the scenario object for bootstrap const scenarioWithGenerals = produce(MOCK_SCENARIO, (draft) => { draft.generalsNeutral = MOCK_GENERALS; }); 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, }; describe('Blank Start Scenario', () => { function createConstraintContext(actor: General, year: number = 189, args: any = {}): ConstraintContext { return { actorId: actor.id, cityId: actor.cityId, nationId: actor.nationId || 0, args, env: { ...systemEnv, world: { currentYear: year }, openingPartYear: systemEnv.openingPartYear, }, mode: 'full', }; } function createViewState(world: InMemoryWorld, year: number = 189, env: TurnCommandEnv = systemEnv): StateView { return { has: (req: RequirementKey) => { if (req.kind === 'general') return world.getGeneral(req.id) !== undefined; if (req.kind === 'nation') return world.getNation(req.id) !== undefined; if (req.kind === 'city') return world.snapshot.cities.some((c) => c.id === req.id); if (req.kind === 'generalList') return true; if (req.kind === 'nationList') return true; if (req.kind === 'env') return true; return false; }, get: (req: RequirementKey) => { if (req.kind === 'general') return world.getGeneral(req.id) || null; if (req.kind === 'nation') return world.getNation(req.id) || null; if (req.kind === 'city') return world.snapshot.cities.find((c) => c.id === req.id) || null; if (req.kind === 'generalList') return world.getAllGenerals(); if (req.kind === 'nationList') return world.snapshot.nations; if (req.kind === 'env') { if (req.key === 'world') return { currentYear: year }; if (req.key === 'openingPartYear') return env.openingPartYear; if (req.key === 'relYear') return year - 189; if (req.key === 'year') return year; } return null; }, }; } const FOUNDING_ARGS = { nationName: 'NewChosen', nationType: 'che_도적', colorType: 1, }; it('should follow the correct founding scenario (uprising -> appointment -> founding)', async () => { // 1. Setup World const bootstrapResult = buildScenarioBootstrap({ scenario: scenarioWithGenerals, map: MINIMAL_MAP, options: { includeNeutralNation: true, defaultGeneralGold: 1000, defaultGeneralRice: 1000, }, }); const snapshot = bootstrapResult.snapshot; // Ensure initial cities have level 5 or 6 for founding test later snapshot.cities = snapshot.cities.map((c) => ({ ...c, level: 5 })); const world = new InMemoryWorld(snapshot); const runner = new TestGameRunner(world, 189, 1); const gen0 = world.getAllGenerals().find((g) => g.name.endsWith('General_0'))!; const gen1 = world.getAllGenerals().find((g) => g.name.endsWith('General_1'))!; const foundNationDef = foundNationSpec.createDefinition(systemEnv); const uprisingDef = uprisingSpec.createDefinition(systemEnv); const appointmentDef = appointmentSpec.createDefinition(systemEnv); // --- Step 1: Gen 0 performs Uprising --- await runner.runTurn([ { generalId: gen0.id, commandKey: 'che_거병', resolver: uprisingDef, args: {}, }, ]); const gen0AfterUprising = world.getGeneral(gen0.id)!; expect(gen0AfterUprising.nationId).toBeGreaterThan(0); expect(gen0AfterUprising.officerLevel).toBe(12); const newNationId = gen0AfterUprising.nationId; const newNation = world.getNation(newNationId)!; expect(newNation.chiefGeneralId).toBe(gen0.id); expect(newNation.level).toBe(0); // Wandering Nation // Ref che_거병 does not install a nation-specific NPC policy. Leaving // this key absent preserves AutorunNationPolicy's 50,000 population // floor instead of silently overriding it with zero. expect(newNation.meta).not.toHaveProperty('npc_nation_policy'); // --- Step 2: Gen 1 performs Appointment --- // Before appointment, Gen 0 should FAIL Founding because general count = 1 const ctxBefore = createConstraintContext(world.getGeneral(gen0.id)!, 189, FOUNDING_ARGS); const viewBefore = createViewState(world, 189, ctxBefore.env as any); const resultFailCount = evaluateActionConstraints(foundNationDef, ctxBefore, viewBefore, FOUNDING_ARGS); expect(resultFailCount.kind).toBe('deny'); if (resultFailCount.kind === 'deny') { expect(resultFailCount.constraintName).toBe('reqNationGeneralCount'); } await runner.runTurn([ { generalId: gen1.id, commandKey: 'che_임관', resolver: appointmentDef, args: { destNationId: newNationId }, }, ]); // Simulate Appointment Daemon const gen1Idx = world.snapshot.generals.findIndex((g) => g.id === gen1.id); world.snapshot.generals[gen1Idx] = { ...world.snapshot.generals[gen1Idx], nationId: newNationId, officerLevel: 1, } as General; world.getNation(newNationId)!.meta.gennum = 2; // --- Step 3: Gen 0 performs Founding --- // First, check name duplicate const duplicateNation: Nation = { id: 2, name: 'TakenName', color: '#00FF00', capitalCityId: 2, chiefGeneralId: 998, gold: 10000, rice: 10000, power: 0, level: 1, typeCode: 'che_def', meta: { gennum: 2 }, }; world.snapshot.nations.push(duplicateNation); const ctxDuplicate = createConstraintContext(world.getGeneral(gen0.id)!, 189, { ...FOUNDING_ARGS, nationName: 'TakenName', }); const resDuplicate = evaluateActionConstraints( foundNationDef, ctxDuplicate, createViewState(world, 189, ctxDuplicate.env as any), { ...FOUNDING_ARGS, nationName: 'TakenName' } ); expect(resDuplicate.kind).toBe('deny'); if (resDuplicate.kind === 'deny') { expect(resDuplicate.constraintName).toBe('checkNationNameDuplicate'); } // Now it should pass constraints const finalGen0 = world.getGeneral(gen0.id)!; const finalCtx = createConstraintContext(finalGen0, 189, FOUNDING_ARGS); const finalView = createViewState(world, 189, finalCtx.env as any); const finalResult = evaluateActionConstraints(foundNationDef, finalCtx, finalView, FOUNDING_ARGS); expect(finalResult.kind).toBe('allow'); await runner.runTurn([ { generalId: gen0.id, commandKey: 'che_건국', resolver: foundNationDef, args: FOUNDING_ARGS, context: { currentYearMonth: 189 * 12 + 2, initYearMonth: 189 * 12, }, }, ]); const nationAfterFounding = world.getNation(newNationId)!; expect(nationAfterFounding.level).toBe(1); expect(nationAfterFounding.name).toBe(FOUNDING_ARGS.nationName); expect(nationAfterFounding.capitalCityId).toBe(gen0.cityId); }); it('should fail founding if city is not level 5 or 6', async () => { const bootstrapResult = buildScenarioBootstrap({ scenario: scenarioWithGenerals, map: MINIMAL_MAP, options: { includeNeutralNation: true, defaultGeneralGold: 1000, defaultGeneralRice: 1000 }, }); const world = new InMemoryWorld(bootstrapResult.snapshot); const gen0 = world.getAllGenerals().find((g) => g.name.endsWith('General_0'))!; // Setup monarch in wandering nation but city level is 1 (수) const newNation: Nation = { id: 1, name: 'Test', color: '#FF0000', capitalCityId: gen0.cityId, chiefGeneralId: gen0.id, gold: 10000, rice: 10000, power: 0, level: 0, typeCode: 'che_def', meta: { gennum: 2 }, }; world.snapshot.nations.push(newNation); const gen0Idx = world.snapshot.generals.findIndex((g) => g.id === gen0.id); world.snapshot.generals[gen0Idx] = { ...gen0, nationId: 1, officerLevel: 12 } as General; // Add another general to satisfy reqNationGeneralCount(2) const gen1 = world.getAllGenerals().find((g) => g.id !== gen0.id)!; const gen1Idx = world.snapshot.generals.findIndex((g) => g.id === gen1.id); world.snapshot.generals[gen1Idx] = { ...gen1, nationId: 1 } as General; const cityIdx = world.snapshot.cities.findIndex((c) => c.id === gen0.cityId); world.snapshot.cities[cityIdx] = { ...world.snapshot.cities[cityIdx], level: 1 } as City; const foundNationDef = foundNationSpec.createDefinition(systemEnv); const ctx = createConstraintContext(world.getGeneral(gen0.id)!, 189, FOUNDING_ARGS); const view = createViewState(world, 189, ctx.env as any); const resLevel = evaluateActionConstraints(foundNationDef, ctx, view, FOUNDING_ARGS); expect(resLevel.kind).toBe('deny'); if (resLevel.kind === 'deny') { expect(resLevel.constraintName).toBe('constructableCity'); } }); it('should fail founding after opening part', async () => { const bootstrapResult = buildScenarioBootstrap({ scenario: scenarioWithGenerals, map: MINIMAL_MAP, options: { includeNeutralNation: true }, }); const world = new InMemoryWorld(bootstrapResult.snapshot); const gen0 = world.getAllGenerals().find((g) => g.name.endsWith('General_0'))!; const foundNationDef = foundNationSpec.createDefinition(systemEnv); // Setup monarch and enough generals const newNation: Nation = { id: 1, name: 'Test', color: '#FF0000', capitalCityId: gen0.cityId, chiefGeneralId: gen0.id, gold: 10000, rice: 10000, power: 0, level: 0, typeCode: 'che_def', meta: {}, }; world.snapshot.nations.push(newNation); const gen0Idx = world.snapshot.generals.findIndex((g) => g.id === gen0.id); world.snapshot.generals[gen0Idx] = { ...gen0, nationId: 1, officerLevel: 12 } as General; const gen1 = world.getAllGenerals().find((g) => g.id !== gen0.id)!; const gen1Idx = world.snapshot.generals.findIndex((g) => g.id === gen1.id); world.snapshot.generals[gen1Idx] = { ...gen1, nationId: 1 } as General; // startYear가 189였음 const year = 190; const ctx = createConstraintContext(world.getGeneral(gen0.id)!, year, FOUNDING_ARGS); // OpeningPartYear를 1로 설정하여 189(rel 0)만 허용하고 190(rel 1)은 실패하게 함 ctx.env.openingPartYear = 1; // Ensure city level doesn't block evaluation before beOpeningPart const cityIdx = world.snapshot.cities.findIndex((c) => c.id === gen0.cityId); world.snapshot.cities[cityIdx] = { ...world.snapshot.cities[cityIdx], level: 5 } as City; const view = createViewState(world, year, ctx.env as any); const res = evaluateActionConstraints(foundNationDef, ctx, view, FOUNDING_ARGS); expect(res.kind).toBe('deny'); if (res.kind === 'deny') { expect(res.constraintName).toBe('beOpeningPart'); } }); });