import { describe, expect, it } from 'vitest'; import type { City, General, Nation } from '../../../src/domain/entities.js'; import type { WorldSnapshot } from '../../../src/world/types.js'; import { InMemoryWorld, TestGameRunner } from '../../testEnv.js'; import { MINIMAL_MAP } from '../../fixtures/minimalMap.js'; import type { TurnCommandEnv, TurnCommandItemCatalogEntry } from '../../../src/actions/turn/commandEnv.js'; import { commandSpec as rebellionSpec } from '../../../src/actions/turn/general/che_모반시도.js'; import { commandSpec as abdicationSpec } from '../../../src/actions/turn/general/che_선양.js'; import { commandSpec as giftSpec } from '../../../src/actions/turn/general/che_증여.js'; import { commandSpec as disbandSpec } from '../../../src/actions/turn/general/che_해산.js'; import { commandSpec as foundNationSpec } from '../../../src/actions/turn/general/cr_건국.js'; import { commandSpec as tradeItemSpec } from '../../../src/actions/turn/general/che_장비매매.js'; import type { ConstraintContext, RequirementKey, StateView } from '../../../src/constraints/types.js'; import { evaluateActionConstraints } from '../../../src/constraints/evaluate.js'; const SYSTEM_ENV: TurnCommandEnv = { develCost: 100, trainDelta: 35, atmosDelta: 35, maxTrainByCommand: 100, maxAtmosByCommand: 100, sabotageDefaultProb: 0.5, sabotageProbCoefByStat: 0.1, sabotageDefenceCoefByGeneralCount: 0.1, sabotageDamageMin: 10, sabotageDamageMax: 30, openingPartYear: 200, maxGeneral: 10, defaultNpcGold: 1000, defaultNpcRice: 1000, defaultCrewTypeId: 1, defaultSpecialDomestic: null, defaultSpecialWar: null, initialNationGenLimit: 10, maxTechLevel: 10, baseGold: 1000, baseRice: 1000, maxResourceActionAmount: 10000, }; const makeGeneral = (params: { id: number; nationId: number; cityId: number; name?: string; officerLevel?: number; experience?: number; dedication?: number; gold?: number; rice?: number; crew?: number; meta?: Record; items?: Partial; }): General => ({ id: params.id, name: params.name ?? `장수${params.id}`, nationId: params.nationId, cityId: params.cityId, troopId: 0, stats: { leadership: 80, strength: 70, intelligence: 60 }, experience: params.experience ?? 0, dedication: params.dedication ?? 0, officerLevel: params.officerLevel ?? 1, role: { personality: null, specialDomestic: null, specialWar: null, items: { horse: params.items?.horse ?? null, weapon: params.items?.weapon ?? null, book: params.items?.book ?? null, item: params.items?.item ?? null, }, }, injury: 0, gold: params.gold ?? 1000, rice: params.rice ?? 1000, crew: params.crew ?? 500, crewTypeId: 1, train: 20, atmos: 20, age: 30, npcState: 0, triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} }, meta: { killturn: 24, ...(params.meta ?? {}), }, }); const makeNation = (params: { id: number; name?: string; level?: number; typeCode?: string; capitalCityId?: number | null; chiefGeneralId?: number | null; gold?: number; rice?: number; meta?: Record; }): Nation => ({ id: params.id, name: params.name ?? `국가${params.id}`, color: '#ff0000', capitalCityId: params.capitalCityId ?? 1, chiefGeneralId: params.chiefGeneralId ?? 1, gold: params.gold ?? 10000, rice: params.rice ?? 10000, power: 0, level: params.level ?? 1, typeCode: params.typeCode ?? 'che_def', meta: { killturn: 24, ...(params.meta ?? {}), }, }); const makeCity = (params: { id: number; nationId: number; level?: number; supplyState?: number }): City => ({ id: params.id, name: `도시${params.id}`, nationId: params.nationId, level: params.level ?? 1, state: 0, population: 20000, populationMax: 50000, agriculture: 500, agricultureMax: 1000, commerce: 500, commerceMax: 1000, security: 500, securityMax: 1000, defence: 300, defenceMax: 1000, wall: 300, wallMax: 1000, supplyState: params.supplyState ?? 1, frontState: 0, meta: { trust: 50, trade: 100 }, }); const makeSnapshot = (params: { generals: General[]; nations: Nation[]; cities: City[]; startYear?: number; }): WorldSnapshot => ({ scenarioConfig: { environment: { mapName: 'minimal_map', unitSet: 'default' }, options: {}, const: {}, } as any, scenarioMeta: { title: '테스트', startYear: params.startYear ?? 200, life: 0, fiction: 0, history: [], ignoreDefaultEvents: false, }, map: MINIMAL_MAP, unitSet: { id: 'default', name: 'default', crewTypes: [] } as any, nations: params.nations, cities: params.cities, generals: params.generals, troops: [], diplomacy: [], events: [], initialEvents: [], }); describe('migrated general commands', () => { it('che_모반시도: 군주를 찬탈한다', async () => { const lord = makeGeneral({ id: 1, nationId: 1, cityId: 1, name: '군주', officerLevel: 12, experience: 1000 }); const chief = makeGeneral({ id: 2, nationId: 1, cityId: 1, name: '중신', officerLevel: 2, experience: 300 }); const nation = makeNation({ id: 1, name: '위', chiefGeneralId: 1, capitalCityId: 1, level: 1 }); const city = makeCity({ id: 1, nationId: 1 }); const world = new InMemoryWorld(makeSnapshot({ generals: [lord, chief], nations: [nation], cities: [city] })); const runner = new TestGameRunner(world, 201, 1); await runner.runTurn([ { generalId: chief.id, commandKey: 'che_모반시도', resolver: rebellionSpec.createDefinition(SYSTEM_ENV), args: {}, context: { nationGenerals: [lord, chief], }, }, ]); const updatedChief = world.getGeneral(chief.id)!; const updatedLord = world.getGeneral(lord.id)!; expect(updatedChief.officerLevel).toBe(12); expect(updatedLord.officerLevel).toBe(1); expect(updatedLord.experience).toBe(700); }); it('che_선양: 제약 없는 대상에게 선양한다', async () => { const lord = makeGeneral({ id: 1, nationId: 1, cityId: 1, name: '군주', officerLevel: 12, experience: 1000 }); const heir = makeGeneral({ id: 2, nationId: 1, cityId: 1, name: '후계자', officerLevel: 1, experience: 200 }); const nation = makeNation({ id: 1, name: '촉', chiefGeneralId: 1, capitalCityId: 1, level: 1 }); const city = makeCity({ id: 1, nationId: 1 }); const world = new InMemoryWorld(makeSnapshot({ generals: [lord, heir], nations: [nation], cities: [city] })); const runner = new TestGameRunner(world, 201, 1); await runner.runTurn([ { generalId: lord.id, commandKey: 'che_선양', resolver: abdicationSpec.createDefinition(SYSTEM_ENV), args: { destGeneralID: heir.id }, context: { destGeneral: heir, }, }, ]); const updatedLord = world.getGeneral(lord.id)!; const updatedHeir = world.getGeneral(heir.id)!; expect(updatedHeir.officerLevel).toBe(12); expect(updatedLord.officerLevel).toBe(1); expect(updatedLord.experience).toBe(700); expect(world.getNation(1)!.chiefGeneralId).toBe(heir.id); }); it('che_증여: 금은 레거시 최소 보유량 0을 적용해 요청한 금액을 이전한다', async () => { const actor = makeGeneral({ id: 1, nationId: 1, cityId: 1, name: '증여자', gold: 1300 }); const dest = makeGeneral({ id: 2, nationId: 1, cityId: 1, name: '수령자', gold: 200 }); const nation = makeNation({ id: 1, name: '오', chiefGeneralId: 1, capitalCityId: 1, level: 1 }); const city = makeCity({ id: 1, nationId: 1 }); const world = new InMemoryWorld(makeSnapshot({ generals: [actor, dest], nations: [nation], cities: [city] })); const runner = new TestGameRunner(world, 201, 1); await runner.runTurn([ { generalId: actor.id, commandKey: 'che_증여', resolver: giftSpec.createDefinition(SYSTEM_ENV), args: { isGold: true, amount: 500, destGeneralID: dest.id }, context: { destGeneral: dest, }, }, ]); expect(world.getGeneral(actor.id)!.gold).toBe(800); expect(world.getGeneral(dest.id)!.gold).toBe(700); }); it('che_해산: 방랑군 해산 시 세력과 소속을 정리한다', async () => { const lord = makeGeneral({ id: 1, nationId: 1, cityId: 1, name: '군주', officerLevel: 12, gold: 1500, rice: 1800 }); const member = makeGeneral({ id: 2, nationId: 1, cityId: 2, name: '부하', officerLevel: 1, gold: 2500, rice: 500 }); const nation = makeNation({ id: 1, name: '방랑군', chiefGeneralId: 1, capitalCityId: 1, level: 0, typeCode: 'None', meta: {}, }); const city1 = makeCity({ id: 1, nationId: 1, level: 5 }); const city2 = makeCity({ id: 2, nationId: 1, level: 5 }); const world = new InMemoryWorld( makeSnapshot({ generals: [lord, member], nations: [nation], cities: [city1, city2] }) ); const runner = new TestGameRunner(world, 201, 2); await runner.runTurn([ { generalId: lord.id, commandKey: 'che_해산', resolver: disbandSpec.createDefinition(SYSTEM_ENV), args: {}, context: { nationGenerals: [lord, member], nationCities: [city1, city2], currentYearMonth: 201 * 12 + 2 - 1, initYearMonth: 201 * 12 + 1 - 1, }, }, ]); const updatedLord = world.getGeneral(lord.id)!; const updatedMember = world.getGeneral(member.id)!; expect(updatedLord.nationId).toBe(0); expect(updatedMember.nationId).toBe(0); expect(updatedLord.meta.makelimit).toBe(12); expect(updatedLord.gold).toBe(1000); expect(updatedMember.gold).toBe(1000); expect(world.getCity(1)!.nationId).toBe(0); expect(world.getCity(2)!.nationId).toBe(0); expect(world.getNation(1)!.meta.collapsed).toBe(true); }); it('cr_건국: 국가 정보를 건국 상태로 갱신한다', async () => { const lord = makeGeneral({ id: 1, nationId: 1, cityId: 1, name: '건국자', officerLevel: 12, experience: 200, dedication: 300, }); const follower = makeGeneral({ id: 2, nationId: 1, cityId: 1, name: '추종자', officerLevel: 1 }); const nation = makeNation({ id: 1, name: '옛국가', chiefGeneralId: 1, capitalCityId: null, level: 0, typeCode: 'None', meta: {}, }); const city = makeCity({ id: 1, nationId: 0, level: 5 }); const world = new InMemoryWorld( makeSnapshot({ generals: [lord, follower], nations: [nation], cities: [city], startYear: 200 }) ); const runner = new TestGameRunner(world, 201, 1); await runner.runTurn([ { generalId: lord.id, commandKey: 'cr_건국', resolver: foundNationSpec.createDefinition(SYSTEM_ENV), args: { nationName: '신국', nationType: 'che_def', colorType: 1 }, }, ]); const updatedNation = world.getNation(1)!; const updatedLord = world.getGeneral(1)!; expect(updatedNation.name).toBe('신국'); expect(updatedNation.level).toBe(1); expect(updatedNation.capitalCityId).toBe(1); expect(world.getCity(1)!.nationId).toBe(1); expect(updatedLord.experience).toBe(1200); expect(updatedLord.dedication).toBe(1300); }); it('che_장비매매: 동일 장비 재구매는 AlwaysFail 제약으로 막는다', () => { const general = makeGeneral({ id: 10, nationId: 1, cityId: 1, gold: 5000, items: { weapon: 'testSword' }, }); const city = makeCity({ id: 1, nationId: 1, level: 5 }); const nation = makeNation({ id: 1, level: 1, chiefGeneralId: 10, capitalCityId: 1 }); const itemCatalog: Record = { testSword: { slot: 'weapon', name: '테스트검', rawName: '테스트검', cost: 500, reqSecu: 0, buyable: true, unique: false, }, }; const definition = tradeItemSpec.createDefinition({ ...SYSTEM_ENV, itemCatalog, }); const args = { itemType: 'weapon' as const, itemCode: 'testSword' }; const context: ConstraintContext = { actorId: general.id, cityId: city.id, nationId: nation.id, args, env: {}, mode: 'full', }; const view: StateView = { has: (req: RequirementKey) => { if (req.kind === 'general') return req.id === general.id; if (req.kind === 'city') return req.id === city.id; if (req.kind === 'nation') return req.id === nation.id; return false; }, get: (req: RequirementKey) => { if (req.kind === 'general' && req.id === general.id) return general; if (req.kind === 'city' && req.id === city.id) return city; if (req.kind === 'nation' && req.id === nation.id) return nation; return null; }, }; const result = evaluateActionConstraints(definition, context, view, args); expect(result.kind).toBe('deny'); if (result.kind === 'deny') { expect(result.constraintName).toBe('AlwaysFail'); } }); });