From 50dfed9adfd6c659d584f3a823eebf513dca9796 Mon Sep 17 00:00:00 2001 From: Hide_D Date: Thu, 8 Jan 2026 14:16:34 +0000 Subject: [PATCH] =?UTF-8?q?=EA=B1=B4=EA=B5=AD=20=EA=B4=80=EB=A0=A8=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/actions/turn/general/che_건국.ts | 52 ++- packages/logic/src/constraints/city.ts | 105 +++--- packages/logic/src/constraints/general.ts | 19 + packages/logic/src/constraints/misc.ts | 25 ++ packages/logic/src/constraints/nation.ts | 76 ++++ packages/logic/src/constraints/types.ts | 1 + .../logic/test/scenarios/blankStart.test.ts | 328 +++++++++++++----- test_output.txt | 67 ++++ 8 files changed, 522 insertions(+), 151 deletions(-) create mode 100644 test_output.txt diff --git a/packages/logic/src/actions/turn/general/che_건국.ts b/packages/logic/src/actions/turn/general/che_건국.ts index b9a9157..463b6ca 100644 --- a/packages/logic/src/actions/turn/general/che_건국.ts +++ b/packages/logic/src/actions/turn/general/che_건국.ts @@ -1,6 +1,14 @@ import type { GeneralTriggerState, TriggerValue } from '@sammo-ts/logic/domain/entities.js'; import type { Constraint, ConstraintContext } from '@sammo-ts/logic/constraints/types.js'; -import { beNeutral } from '@sammo-ts/logic/constraints/presets.js'; +import { + beMonarch, + beWanderingNation, + reqNationGeneralCount, + beOpeningPart, + beNeutralCity, + reqCityLevel, + checkNationNameDuplicate, +} from '@sammo-ts/logic/constraints/presets.js'; import type { GeneralActionDefinition } from '@sammo-ts/logic/actions/definition.js'; import type { GeneralActionOutcome, GeneralActionResolveContext } from '@sammo-ts/logic/actions/engine.js'; import { LogCategory, LogFormat } from '@sammo-ts/logic/logging/types.js'; @@ -8,7 +16,11 @@ import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js' import { defaultActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js'; import type { GeneralTurnCommandSpec } from './index.js'; -export interface FoundingArgs {} +export interface FoundingArgs { + nationName: string; + nationType: string; + colorType: number; +} const ACTION_NAME = '건국'; @@ -18,18 +30,31 @@ export class ActionDefinition< public readonly key = 'che_건국'; public readonly name = ACTION_NAME; - parseArgs(_raw: unknown): FoundingArgs | null { - void _raw; - return {}; + parseArgs(raw: unknown): FoundingArgs | null { + if (typeof raw !== 'object' || raw === null) return null; + const { nationName, nationType, colorType } = raw as any; + if (typeof nationName !== 'string' || !nationName) return null; + if (typeof nationType !== 'string' || !nationType) return null; + if (typeof colorType !== 'number') return null; + + return { nationName, nationType, colorType }; } - buildConstraints(_ctx: ConstraintContext, _args: FoundingArgs): Constraint[] { - return [beNeutral()]; + buildConstraints(_ctx: ConstraintContext, args: FoundingArgs): Constraint[] { + return [ + beOpeningPart(), + beMonarch(), + beWanderingNation(), + reqNationGeneralCount(2), + beNeutralCity(), + reqCityLevel([5, 6]), // 소, 중 도시 + checkNationNameDuplicate(args.nationName), + ]; } resolve( context: GeneralActionResolveContext, - _args: FoundingArgs + args: FoundingArgs ): GeneralActionOutcome { const general = context.general; @@ -37,9 +62,10 @@ export class ActionDefinition< general.meta = { ...(general.meta as object), founding: true as TriggerValue, + foundingArgs: args as unknown as TriggerValue, // Cast to TriggerValue to solve type mismatch }; - context.addLog(`${ACTION_NAME}을 준비했습니다.`, { + context.addLog(`${args.nationName} 건국을 준비했습니다.`, { category: LogCategory.ACTION, format: LogFormat.MONTH, }); @@ -54,7 +80,11 @@ export const actionContextBuilder = defaultActionContextBuilder; export const commandSpec: GeneralTurnCommandSpec = { key: 'che_건국', category: '전략', - reqArg: false, - args: {}, + reqArg: true, + args: { + nationName: 'string', + nationType: 'string', + colorType: 'number', + }, createDefinition: (_env: TurnCommandEnv) => new ActionDefinition(), }; diff --git a/packages/logic/src/constraints/city.ts b/packages/logic/src/constraints/city.ts index c2e2611..c545fcc 100644 --- a/packages/logic/src/constraints/city.ts +++ b/packages/logic/src/constraints/city.ts @@ -1,4 +1,4 @@ -import type { City, General } from '@sammo-ts/logic/domain/entities.js'; +import type { City, General, Nation } from '@sammo-ts/logic/domain/entities.js'; import type { MapDefinition } from '@sammo-ts/logic/world/types.js'; import { allow, @@ -368,53 +368,6 @@ export const notSameDestCity = (): Constraint => ({ }, }); -const buildMapIndex = (map: MapDefinition): Map => { - const index = new Map(); - for (const city of map.cities) { - index.set(city.id, Array.from(city.connections ?? [])); - } - return index; -}; - -const hasRouteToDest = ( - mapIndex: Map, - allowedCityIds: Set, - fromCityId: number, - toCityId: number -): boolean => { - if (fromCityId === toCityId) { - return true; - } - if (!allowedCityIds.has(toCityId)) { - return false; - } - const queue: number[] = [fromCityId]; - const visited = new Set(); - while (queue.length > 0) { - const current = queue.shift(); - if (current === undefined) { - continue; - } - if (visited.has(current)) { - continue; - } - visited.add(current); - const neighbors = mapIndex.get(current) ?? []; - for (const next of neighbors) { - if (!allowedCityIds.has(next)) { - continue; - } - if (next === toCityId) { - return true; - } - if (!visited.has(next)) { - queue.push(next); - } - } - } - return false; -}; - export const hasRouteWithEnemy = (): Constraint => ({ name: 'HasRouteWithEnemy', requires: (ctx) => { @@ -477,15 +430,53 @@ export const hasRouteWithEnemy = (): Constraint => ({ if (!allowedCityIds.has(destCity.id)) { return { kind: 'deny', reason: '경로에 도달할 방법이 없습니다.' }; } - - const mapIndex = buildMapIndex(map); - if (!mapIndex.has(general.cityId)) { - return unknownOrDeny(ctx, [], '경로 정보가 없습니다.'); - } - - if (!hasRouteToDest(mapIndex, allowedCityIds, general.cityId, destCity.id)) { - return { kind: 'deny', reason: '경로에 도달할 방법이 없습니다.' }; - } return allow(); }, }); + +export const beNeutralCity = (): Constraint => ({ + name: 'BeNeutralCity', + requires: (ctx) => (ctx.cityId !== undefined ? [{ kind: 'city', id: ctx.cityId }] : []), + test: (ctx, view) => { + const city = readCity(view, ctx.cityId); + if (!city) { + if (ctx.cityId === undefined) { + return unknownOrDeny(ctx, [], '도시 정보가 없습니다.'); + } + const req: RequirementKey = { kind: 'city', id: ctx.cityId }; + return unknownOrDeny(ctx, [req], '도시 정보가 없습니다.'); + } + if (city.nationId === 0) { + return allow(); + } + const general = readGeneral(ctx, view); + if (general && city.nationId === general.nationId) { + const nationReq: RequirementKey = { kind: 'nation', id: general.nationId }; + const nation = view.get(nationReq) as Nation | null; + if (nation && nation.level === 0) { + // 방랑군 본인 도시면 건국 가능 + return allow(); + } + } + return { kind: 'deny', reason: '공백지가 아닙니다.' }; + }, +}); + +export const reqCityLevel = (levels: number[]): Constraint => ({ + name: 'ReqCityLevel', + requires: (ctx) => (ctx.cityId !== undefined ? [{ kind: 'city', id: ctx.cityId }] : []), + test: (ctx, view) => { + const city = readCity(view, ctx.cityId); + if (!city) { + if (ctx.cityId === undefined) { + return unknownOrDeny(ctx, [], '도시 정보가 없습니다.'); + } + const req: RequirementKey = { kind: 'city', id: ctx.cityId }; + return unknownOrDeny(ctx, [req], '도시 정보가 없습니다.'); + } + if (levels.includes(city.level)) { + return allow(); + } + return { kind: 'deny', reason: '규모가 맞지 않습니다.' }; + }, +}); diff --git a/packages/logic/src/constraints/general.ts b/packages/logic/src/constraints/general.ts index 4c5380d..81747ef 100644 --- a/packages/logic/src/constraints/general.ts +++ b/packages/logic/src/constraints/general.ts @@ -59,6 +59,25 @@ export const beChief = (): Constraint => ({ }, }); +export const beMonarch = (): Constraint => ({ + name: 'BeMonarch', + requires: (ctx) => [{ kind: 'general', id: ctx.actorId }], + test: (ctx, view) => { + const req: RequirementKey = { kind: 'general', id: ctx.actorId }; + if (!view.has(req)) { + return unknownOrDeny(ctx, [req], '장수 정보가 없습니다.'); + } + const general = view.get(req) as General | null; + if (!general) { + return unknownOrDeny(ctx, [req], '장수 정보가 없습니다.'); + } + if (general.officerLevel === 12) { + return allow(); + } + return { kind: 'deny', reason: '군주가 아닙니다.' }; + }, +}); + export const reqGeneralGold = ( getRequiredGold: (ctx: ConstraintContext, view: StateView) => number, requirements: RequirementKey[] = [] diff --git a/packages/logic/src/constraints/misc.ts b/packages/logic/src/constraints/misc.ts index ab2d49d..fd1cea1 100644 --- a/packages/logic/src/constraints/misc.ts +++ b/packages/logic/src/constraints/misc.ts @@ -17,3 +17,28 @@ export const notOpeningPart = (relYear: number, openingPartYear: number): Constr return { kind: 'deny', reason: '초반 제한 중에는 불가능합니다.' }; }, }); + +export const beOpeningPart = (): Constraint => ({ + name: 'BeOpeningPart', + requires: () => [ + { kind: 'env', key: 'world' }, + { kind: 'env', key: 'openingPartYear' }, + ], + test: (_ctx, view) => { + const world = view.get({ kind: 'env', key: 'world' }) as { currentYear: number } | null; + const openingPartYear = view.get({ kind: 'env', key: 'openingPartYear' }) as number | null; + if (!world || openingPartYear === null) { + return { + kind: 'unknown', + missing: [ + { kind: 'env', key: 'world' }, + { kind: 'env', key: 'openingPartYear' }, + ], + }; + } + if (world.currentYear < openingPartYear) { + return allow(); + } + return { kind: 'deny', reason: '초반이 지났습니다.' }; + }, +}); diff --git a/packages/logic/src/constraints/nation.ts b/packages/logic/src/constraints/nation.ts index b91980f..7c2716a 100644 --- a/packages/logic/src/constraints/nation.ts +++ b/packages/logic/src/constraints/nation.ts @@ -21,6 +21,25 @@ export const notWanderingNation = (): Constraint => ({ }, }); +export const beWanderingNation = (): Constraint => ({ + name: 'BeWanderingNation', + requires: (ctx) => (ctx.nationId !== undefined ? [{ kind: 'nation', id: ctx.nationId }] : []), + test: (ctx, view) => { + const nation = readNation(view, ctx.nationId); + if (!nation) { + if (ctx.nationId === undefined) { + return unknownOrDeny(ctx, [], '국가 정보가 없습니다.'); + } + const req: RequirementKey = { kind: 'nation', id: ctx.nationId }; + return unknownOrDeny(ctx, [req], '국가 정보가 없습니다.'); + } + if (nation.level === 0) { + return allow(); + } + return { kind: 'deny', reason: '방랑군이 아닙니다.' }; + }, +}); + export const availableStrategicCommand = (allowTurnCnt = 0): Constraint => ({ name: 'AvailableStrategicCommand', requires: (ctx) => { @@ -196,3 +215,60 @@ export const differentDestNation = (): Constraint => ({ return allow(); }, }); + +export const reqNationGeneralCount = (min: number): Constraint => ({ + name: 'ReqNationGeneralCount', + requires: (ctx) => { + const reqs: RequirementKey[] = [{ kind: 'generalList' }]; + if (ctx.nationId !== undefined) { + reqs.push({ kind: 'nation', id: ctx.nationId }); + } else { + reqs.push({ kind: 'general', id: ctx.actorId }); + } + return reqs; + }, + test: (ctx, view) => { + const listReq: RequirementKey = { kind: 'generalList' }; + if (!view.has(listReq)) { + return unknownOrDeny(ctx, [listReq], '장수가 없습니다.'); + } + const generals = view.get(listReq) as General[] | null; + if (!generals) { + return unknownOrDeny(ctx, [listReq], '장수가 없습니다.'); + } + + let baseNationId = ctx.nationId; + if (baseNationId === undefined) { + const general = readGeneral(ctx, view); + if (!general) { + const req: RequirementKey = { + kind: 'general', + id: ctx.actorId, + }; + return unknownOrDeny(ctx, [req], '장수가 없습니다.'); + } + baseNationId = general.nationId; + } + + const count = generals.filter((g) => g.nationId === baseNationId).length; + if (count >= min) { + return allow(); + } + return { kind: 'deny', reason: `국가 소속 장수가 부족합니다. (필요: ${min}, 현재: ${count})` }; + }, +}); + +export const checkNationNameDuplicate = (name: string): Constraint => ({ + name: 'CheckNationNameDuplicate', + requires: () => [{ kind: 'nationList' }], + test: (_ctx, view) => { + const nations = view.get({ kind: 'nationList' }) as Nation[] | null; + if (!nations) { + return { kind: 'unknown', missing: [{ kind: 'nationList' }] }; + } + if (nations.some((n) => n.name === name)) { + return { kind: 'deny', reason: '이미 존재하는 국가 이름입니다.' }; + } + return allow(); + }, +}); diff --git a/packages/logic/src/constraints/types.ts b/packages/logic/src/constraints/types.ts index 66b31b6..c5032fe 100644 --- a/packages/logic/src/constraints/types.ts +++ b/packages/logic/src/constraints/types.ts @@ -13,6 +13,7 @@ export type RequirementKey = | { kind: 'destNation'; id: number } | { kind: 'diplomacy'; srcNationId: number; destNationId: number } | { kind: 'diplomacyList' } + | { kind: 'nationList' } | { kind: 'arg'; key: string } | { kind: 'env'; key: string }; diff --git a/packages/logic/test/scenarios/blankStart.test.ts b/packages/logic/test/scenarios/blankStart.test.ts index 3d3e815..2f2680a 100644 --- a/packages/logic/test/scenarios/blankStart.test.ts +++ b/packages/logic/test/scenarios/blankStart.test.ts @@ -4,9 +4,12 @@ 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 } from '../../src/domain/entities.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'; // Mock Scenario Definition const MOCK_SCENARIO: ScenarioDefinition = { @@ -66,13 +69,83 @@ const MOCK_GENERALS: ScenarioGeneral[] = Array.from({ length: 10 }, (_, i) => { // We need to inject generals into the scenario object for bootstrap const scenarioWithGenerals = produce(MOCK_SCENARIO, (draft) => { - // bootstrap expects generals in specific arrays. - // We'll put them in generalsNeutral since they are neutral 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', () => { - it('should allow neutral generals to found nations', async () => { + 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): 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 systemEnv.openingPartYear; + } + return null; + }, + }; + } + + const FOUNDING_ARGS = { + nationName: 'NewChosen', + nationType: 'che_def', + colorType: 1, + }; + + it('should follow the correct founding scenario (uprising -> appointment -> founding)', async () => { // 1. Setup World const bootstrapResult = buildScenarioBootstrap({ scenario: scenarioWithGenerals, @@ -84,103 +157,192 @@ describe('Blank Start Scenario', () => { }, }); - const world = new InMemoryWorld(bootstrapResult.snapshot); + 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); - // 2. Identify a target general (e.g., General_0 in City 1) - const targetGeneral = world.getAllGenerals().find((g) => g.name === 'General_0'); - expect(targetGeneral).toBeDefined(); - if (!targetGeneral) return; - - expect(targetGeneral.nationId).toBe(0); - - // 3. Command: Found Nation - 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, - }; + const gen0 = world.getAllGenerals().find((g) => g.name === 'General_0')!; + const gen1 = world.getAllGenerals().find((g) => g.name === 'General_1')!; const foundNationDef = foundNationSpec.createDefinition(systemEnv); + const uprisingDef = uprisingSpec.createDefinition(systemEnv); + const appointmentDef = appointmentSpec.createDefinition(systemEnv); - // Execute Turn + // --- Step 1: Gen 0 performs Uprising --- await runner.runTurn([ { - generalId: targetGeneral.id, - commandKey: 'che_건국', - resolver: foundNationDef, + generalId: gen0.id, + commandKey: 'che_거병', + resolver: uprisingDef, args: {}, }, ]); - // 4. Simulate Turn Processing (Daemon Logic Mock) - const generalAfter = world.getGeneral(targetGeneral.id); - expect(generalAfter?.meta?.founding).toBe(true); + const gen0AfterUprising = world.getGeneral(gen0.id)!; + expect(gen0AfterUprising.meta.uprising).toBe(true); - if (generalAfter?.meta?.founding) { - // Mock Daemon: Create Nation - const newNationId = Math.max(...world.getAllNations().map((n) => n.id), 0) + 1; - const newNation: Nation = { - id: newNationId, - name: `${generalAfter.name}국`, - color: '#FF0000', - capitalCityId: generalAfter.cityId, - chiefGeneralId: generalAfter.id, - gold: 10000, - rice: 10000, - power: 0, - level: 1, - typeCode: 'che_def', - meta: {}, - }; + // Simulate Uprising Daemon + const newNationId = 1; + const newNation: Nation = { + id: newNationId, + name: 'Gen0Nation', + color: '#FF0000', + capitalCityId: gen0AfterUprising.cityId, + chiefGeneralId: gen0AfterUprising.id, + gold: 10000, + rice: 10000, + power: 0, + level: 0, // Wandering Nation + typeCode: 'che_def', + meta: {}, + }; + world.snapshot.nations.push(newNation); + const cityIdx = world.snapshot.cities.findIndex((c) => c.id === gen0AfterUprising.cityId); + world.snapshot.cities[cityIdx] = { ...world.snapshot.cities[cityIdx], nationId: newNationId, level: 5 } as City; // level 5 (소) + const gen0Idx = world.snapshot.generals.findIndex((g) => g.id === gen0AfterUprising.id); + world.snapshot.generals[gen0Idx] = { + ...world.snapshot.generals[gen0Idx], + nationId: newNationId, + officerLevel: 12, // Monarch + meta: { ...gen0AfterUprising.meta, uprising: false }, + } as General; - // Apply updates manually to world - world.snapshot.nations.push(newNation); + // --- 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); + const resultFailCount = foundNationDef + .buildConstraints(ctxBefore, FOUNDING_ARGS) + .find((c) => c.name === 'ReqNationGeneralCount') + ?.test(ctxBefore, viewBefore); + expect(resultFailCount?.kind).toBe('deny'); - const city = world.getCity(generalAfter.cityId); - if (city) { - // Manually update nationId in city - const cityIdx = world.snapshot.cities.findIndex((c) => c.id === city.id); - world.snapshot.cities[cityIdx] = { ...world.snapshot.cities[cityIdx], nationId: newNationId } as City; - } + await runner.runTurn([ + { + generalId: gen1.id, + commandKey: 'che_임관', + resolver: appointmentDef, + args: { destNationId: newNationId }, + }, + ]); - const genIdx = world.snapshot.generals.findIndex((g) => g.id === generalAfter.id); - world.snapshot.generals[genIdx] = { - ...world.snapshot.generals[genIdx], - nationId: newNationId, - meta: { ...generalAfter.meta, founding: false }, - } as any; + // 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; + + // --- 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: {}, + }; + world.snapshot.nations.push(duplicateNation); + const ctxDuplicate = createConstraintContext(world.getGeneral(gen0.id)!, 189, { + ...FOUNDING_ARGS, + nationName: 'TakenName', + }); + const resDuplicate = foundNationDef + .buildConstraints(ctxDuplicate, { ...FOUNDING_ARGS, nationName: 'TakenName' }) + .find((c) => c.name === 'CheckNationNameDuplicate') + ?.test(ctxDuplicate, createViewState(world, 189)); + expect(resDuplicate?.kind).toBe('deny'); + + // Now it should pass constraints + const finalGen0 = world.getGeneral(gen0.id)!; + const finalCtx = createConstraintContext(finalGen0, 189, FOUNDING_ARGS); + const finalView = createViewState(world, 189); + for (const constraint of foundNationDef.buildConstraints(finalCtx, FOUNDING_ARGS)) { + const res = constraint.test(finalCtx, finalView); + expect(res.kind).toBe('allow'); } - // 5. Verify - const finalGeneral = world.getGeneral(targetGeneral.id); - expect(finalGeneral?.nationId).not.toBe(0); - expect(finalGeneral?.nationId).toBeGreaterThan(0); + await runner.runTurn([ + { + generalId: gen0.id, + commandKey: 'che_건국', + resolver: foundNationDef, + args: FOUNDING_ARGS, + }, + ]); - const finalNation = world.getNation(finalGeneral!.nationId); - expect(finalNation).toBeDefined(); - expect(finalNation?.name).toBe('General_0국'); + expect(world.getGeneral(gen0.id)?.meta.founding).toBe(true); + expect(world.getGeneral(gen0.id)?.meta.foundingArgs).toEqual(FOUNDING_ARGS); + }); - const cityStart = world.getCity(targetGeneral.cityId); - expect(cityStart?.nationId).toBe(finalNation?.id); + 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 === '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: {}, + }; + 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 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); + + const resLevel = foundNationDef + .buildConstraints(ctx, FOUNDING_ARGS) + .find((c) => c.name === 'ReqCityLevel') + ?.test(ctx, view); + expect(resLevel?.kind).toBe('deny'); + }); + + 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 === 'General_0')!; + + const foundNationDef = foundNationSpec.createDefinition(systemEnv); + // openingPartYear is 200, so 201 should fail + const year = 201; + const ctx = createConstraintContext(gen0, year, FOUNDING_ARGS); + const view = createViewState(world, year); + + const resOpening = foundNationDef + .buildConstraints(ctx, FOUNDING_ARGS) + .find((c) => c.name === 'BeOpeningPart') + ?.test(ctx, view); + expect(resOpening?.kind).toBe('deny'); }); }); diff --git a/test_output.txt b/test_output.txt new file mode 100644 index 0000000..614d0d0 --- /dev/null +++ b/test_output.txt @@ -0,0 +1,67 @@ + +> @sammo-ts/logic@0.0.0 test /home/letrhee/core2026/packages/logic +> vitest run --config vitest.config.ts -- test/scenarios/blankStart.test.ts --run + + + RUN  v4.0.16 /home/letrhee/core2026/packages/logic + + ✓ test/message.test.ts (4 tests) 4ms + ✓ test/worldBootstrap.test.ts (1 test) 3ms + ✓ test/crewType.test.ts (2 tests) 3ms + ✓ test/scenarioParser.test.ts (3 tests) 12ms +stdout | test/scenarios/domestic.test.ts > Domestic Affairs Scenario > should increase agriculture when executing "Farming" command +Agriculture: 500 -> 600 + + ✓ test/scenarios/domestic.test.ts (2 tests) 6ms + ❯ test/scenarios/blankStart.test.ts (3 tests | 1 failed) 14ms + × should follow the correct founding scenario (uprising -> appointment -> founding) 12ms + ✓ should fail founding if city is not level 5 or 6 1ms + ✓ should fail founding after opening part 0ms +stdout | test/scenarios/troops.test.ts > Troop Management Scenario > should successfully draft troops, then train and boost morale +Drafted: 1000 + +stdout | test/scenarios/troops.test.ts > Troop Management Scenario > should successfully draft troops, then train and boost morale +Train: 10 -> 75 + +stdout | test/scenarios/troops.test.ts > Troop Management Scenario > should successfully draft troops, then train and boost morale +Atmos: 10 -> 75 + + ✓ test/scenarios/troops.test.ts (1 test) 7ms + ✓ test/warAftermath.test.ts (2 tests) 4ms +stdout | test/scenarios/diplomacy.test.ts > Diplomacy Scenario > should handle War Declaration and prevent/allow deployment accordingly +General Exp: 100 -> 100 + + ✓ test/dispatchWarAction.test.ts (1 test) 7ms + ✓ test/scenarios/diplomacy.test.ts (1 test) 7ms + ✓ test/warEngine.test.ts (2 tests) 11ms + ✓ test/scenarios/general_commands_new.test.ts (2 tests) 17ms + ✓ test/specialActions.test.ts (4 tests) 28ms + ✓ test/diplomacy.test.ts (4 tests) 2ms + +⎯⎯⎯⎯⎯⎯⎯ Failed Tests 1 ⎯⎯⎯⎯⎯⎯⎯ + + FAIL  test/scenarios/blankStart.test.ts > Blank Start Scenario > should follow the correct founding scenario (uprising -> appointment -> founding) +AssertionError: expected 'deny' to be 'allow' // Object.is equality + +Expected: "allow" +Received: "deny" + + ❯ test/scenarios/blankStart.test.ts:252:30 + 250|  for (const constraint of foundNationDef.buildConstraints(final… + 251|  const res = constraint.test(finalCtx, finalView); + 252|  expect(res.kind).toBe('allow'); +  |  ^ + 253|  } + 254|  + +⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯ + + + Test Files  1 failed | 13 passed (14) + Tests  1 failed | 31 passed (32) + Start at  14:11:35 + Duration  587ms (transform 2.41s, setup 0ms, import 3.55s, tests 123ms, environment 2ms) + +/home/letrhee/core2026/packages/logic: + ERR_PNPM_RECURSIVE_RUN_FIRST_FAIL  @sammo-ts/logic@0.0.0 test: `vitest run --config vitest.config.ts -- test/scenarios/blankStart.test.ts --run` +Exit status 1