diff --git a/app/game-api/src/battleSim/schema.ts b/app/game-api/src/battleSim/schema.ts index 09fb8d9b..71b092dc 100644 --- a/app/game-api/src/battleSim/schema.ts +++ b/app/game-api/src/battleSim/schema.ts @@ -1,5 +1,7 @@ import { z } from 'zod'; +import { isAvailableNationTraitKey } from '@sammo-ts/logic'; + import type { BattleSimRequestPayload } from './types.js'; const zBattleSimGeneral = z.object({ @@ -71,7 +73,7 @@ const zBattleSimCity = z.object({ }); const zBattleSimNation = z.object({ - type: z.string().min(1), + type: z.string().refine(isAvailableNationTraitKey), tech: z.number().min(0), level: z.number().int().min(0), capital: z.number().int().min(0), diff --git a/app/game-api/src/battleSim/simulatorOptions.ts b/app/game-api/src/battleSim/simulatorOptions.ts index 51b80ddf..0d084147 100644 --- a/app/game-api/src/battleSim/simulatorOptions.ts +++ b/app/game-api/src/battleSim/simulatorOptions.ts @@ -1,4 +1,5 @@ import { + AVAILABLE_NATION_TRAIT_KEYS, ITEM_KEYS, EVENT_DOMESTIC_TRAIT_KEYS, loadEventDomesticTraitModules, @@ -6,7 +7,6 @@ import { loadNationTraitModules, loadPersonalityTraitModules, loadWarTraitModules, - NATION_TRAIT_KEYS, PERSONALITY_TRAIT_KEYS, WAR_TRAIT_KEYS, type ItemModule, @@ -110,7 +110,7 @@ export const loadBattleSimTraitOptions = async (): Promise<{ }> => { if (!cachedTraitOptions) { cachedTraitOptions = Promise.all([ - loadNationTraitModules([...NATION_TRAIT_KEYS]), + loadNationTraitModules([...AVAILABLE_NATION_TRAIT_KEYS]), loadEventDomesticTraitModules([...EVENT_DOMESTIC_TRAIT_KEYS]), loadWarTraitModules([...WAR_TRAIT_KEYS]), loadPersonalityTraitModules([...PERSONALITY_TRAIT_KEYS]), diff --git a/app/game-api/test/battleSimRouter.test.ts b/app/game-api/test/battleSimRouter.test.ts index 3e765fe0..7d60b37c 100644 --- a/app/game-api/test/battleSimRouter.test.ts +++ b/app/game-api/test/battleSimRouter.test.ts @@ -117,7 +117,7 @@ const buildBattleRequest = () => ({ conflict: '{}', }, attackerNation: { - type: 'test', + type: 'che_도적', tech: 1000, level: 1, capital: 1, @@ -193,7 +193,7 @@ const buildBattleRequest = () => ({ conflict: '{}', }, defenderNation: { - type: 'test', + type: 'che_도적', tech: 1000, level: 1, capital: 2, @@ -309,6 +309,27 @@ describe('battle router orchestration', () => { expect(battleSim.simulateCalls).toBe(0); }); + it('rejects the neutral storage nation type before preparing or queuing a simulation', async () => { + const battleSim = new QueuedBattleSimTransport(); + const state: WorldStateRow = { + id: 1, + scenarioCode: 'default', + currentYear: 200, + currentMonth: 1, + tickSeconds: 600, + config: {}, + meta: {}, + updatedAt: new Date('2026-01-01T00:00:00Z'), + }; + const caller = appRouter.createCaller(buildContext({ state, battleSim })); + const request = buildBattleRequest(); + request.attackerNation.type = 'che_중립'; + + await expect(caller.battle.prepareSimulation(request)).rejects.toMatchObject({ code: 'BAD_REQUEST' }); + await expect(caller.battle.simulate(request)).rejects.toMatchObject({ code: 'BAD_REQUEST' }); + expect(battleSim.simulateCalls).toBe(0); + }); + it('returns queued then completed results via transport', async () => { const battleSim = new QueuedBattleSimTransport(); const state: WorldStateRow = { diff --git a/app/game-api/test/simulatorOptions.test.ts b/app/game-api/test/simulatorOptions.test.ts new file mode 100644 index 00000000..f2a256fd --- /dev/null +++ b/app/game-api/test/simulatorOptions.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from 'vitest'; + +import { AVAILABLE_NATION_TRAIT_KEYS } from '@sammo-ts/logic'; + +import { loadBattleSimTraitOptions } from '../src/battleSim/simulatorOptions.js'; + +describe('selectable trait options', () => { + it('uses the Ref available nation-type list for founding and battle simulation inputs', async () => { + const options = await loadBattleSimTraitOptions(); + + expect(options.nationTypes.map((entry) => entry.key)).toEqual(AVAILABLE_NATION_TRAIT_KEYS); + expect(options.nationTypes).not.toEqual( + expect.arrayContaining([expect.objectContaining({ key: 'che_중립' })]) + ); + }); +}); diff --git a/app/game-engine/src/turn/ai/generalAi/core.ts b/app/game-engine/src/turn/ai/generalAi/core.ts index 17740a19..3b130819 100644 --- a/app/game-engine/src/turn/ai/generalAi/core.ts +++ b/app/game-engine/src/turn/ai/generalAi/core.ts @@ -13,7 +13,10 @@ import type { ConstraintContext } from '@sammo-ts/logic'; import { GAME_TICKS_PER_TURN, LiteHashDRBG, RandUtil } from '@sammo-ts/common'; import { simpleSerialize } from '@sammo-ts/logic/war/utils.js'; import { resolveStartYear, resolveTurnTermMinutes } from '@sammo-ts/logic/actions/turn/actionContextHelpers.js'; -import { NATION_TRAIT_KEYS } from '@sammo-ts/logic/actionModules/traits/nation/index.js'; +import { + AVAILABLE_NATION_TRAIT_KEYS, + isAvailableNationTraitKey, +} from '@sammo-ts/logic/actionModules/traits/nation/index.js'; import { GeneralActionPipeline } from '@sammo-ts/logic/actionModules/general.js'; import type { ReservedTurnEntry } from '../../reservedTurnStore.js'; @@ -348,8 +351,10 @@ export class GeneralAI { chiefStatMin: this.scenarioConfig.stat.chiefMin, npcMessageFreqByDay: readNumber(constValues.npcMessageFreqByDay, 0), availableNationTypes: Array.isArray(constValues.availableNationType) - ? constValues.availableNationType.filter((value) => typeof value === 'string') - : NATION_TRAIT_KEYS.filter((value) => value !== 'che_중립'), + ? constValues.availableNationType.filter( + (value): value is string => typeof value === 'string' && isAvailableNationTraitKey(value) + ) + : [...AVAILABLE_NATION_TRAIT_KEYS], }; const generalPolicy = new AutorunGeneralPolicy( diff --git a/app/game-engine/src/turn/monthlyRaiseNpcNationAction.ts b/app/game-engine/src/turn/monthlyRaiseNpcNationAction.ts index 5e24209e..2f5beb1e 100644 --- a/app/game-engine/src/turn/monthlyRaiseNpcNationAction.ts +++ b/app/game-engine/src/turn/monthlyRaiseNpcNationAction.ts @@ -3,7 +3,7 @@ import { LogCategory, LogFormat, LogScope, - NATION_TRAIT_KEYS, + AVAILABLE_NATION_TRAIT_KEYS, getCityDistance, type City, type MapDefinition, @@ -54,7 +54,6 @@ const NATION_COLORS = [ '#FFFFFF', '#A9A9A9', ] as const; -const AVAILABLE_NATION_TYPES = NATION_TRAIT_KEYS.filter((key) => key !== 'che_중립'); const NPC_TYPE = 6; const NPC_PREFIX = 'ⓤ'; const STAT_TYPE_WEIGHTS = { 무: 1, 지: 1 } as const; @@ -301,7 +300,7 @@ export const createRaiseNpcNationHandler = (options: { const nationId = world.getNextNationId(); const color = rng.choice([...NATION_COLORS]); - const typeCode = rng.choice([...AVAILABLE_NATION_TYPES]); + const typeCode = rng.choice([...AVAILABLE_NATION_TRAIT_KEYS]); const nation: Nation = { id: nationId, name: `${NPC_PREFIX}${city.name}`, diff --git a/app/game-frontend/e2e/battleSimulator.spec.ts b/app/game-frontend/e2e/battleSimulator.spec.ts index ebd67c11..52f23616 100644 --- a/app/game-frontend/e2e/battleSimulator.spec.ts +++ b/app/game-frontend/e2e/battleSimulator.spec.ts @@ -58,7 +58,7 @@ const simulatorOptions = { { id: 200, name: '궁병', armType: 2 }, ], }, - nationTypes: [{ key: 'che_중립', name: '중립', info: '특별한 효과 없음' }], + nationTypes: [{ key: 'che_도적', name: '도적', info: '금 수입 증가, 쌀 수입 감소' }], eventDomesticTraits: [{ key: 'che_event_신산', name: '신산', info: '계략 강화' }], warTraits: [{ key: 'che_필살', name: '필살', info: '필살 확률 증가' }], personalities: [{ key: 'che_대담', name: '대담', info: '공격적인 성격' }], @@ -352,6 +352,10 @@ test('operates independent/game presets, imports my general, and renders battle await page.setViewportSize({ width: 1280, height: 900 }); await gotoSimulator(page); + const nationTypeSelects = page.locator('[data-parity-id="attacker-nation"] select').first(); + await expect(nationTypeSelects).toHaveValue('che_도적'); + await expect(nationTypeSelects.locator('option[value="che_중립"]')).toHaveCount(0); + const notice = page.getByLabel('시뮬레이터 데이터 안내'); const noticeRect = await notice.boundingBox(); expect(noticeRect?.width).toBeLessThan(100); diff --git a/app/game-frontend/e2e/commandArguments.spec.ts b/app/game-frontend/e2e/commandArguments.spec.ts index dde3ebb1..c2dc3319 100644 --- a/app/game-frontend/e2e/commandArguments.spec.ts +++ b/app/game-frontend/e2e/commandArguments.spec.ts @@ -54,7 +54,7 @@ const inputOptions = { ], crewTypes: [{ value: 1100, label: '보병' }], armTypes: [{ value: 1, label: '보병' }], - nationTypes: [{ value: 'che_중립', label: '중립' }], + nationTypes: [{ value: 'che_도적', label: '도적', description: '금 수입 증가, 쌀 수입 감소' }], colors: [{ value: 0, label: '색상 1', color: '#ff0000' }], items: { horse: [{ value: 'None', label: '판매/해제' }] }, recruitment: { @@ -567,6 +567,61 @@ test('renders and accepts every Ref strategy command at mobile width', async ({ await picker.screenshot({ path: test.info().outputPath('all-strategy-commands-mobile.png') }); }); +test('defaults founding to a Ref-selectable nation trait without exposing the neutral storage trait', async ({ + page, +}) => { + const foundingCommandTable = { + general: [ + { + category: '국가', + values: [ + { + key: 'che_건국', + name: '건국', + reqArg: true, + possible: true, + status: 'needsInput', + inputFields: [ + { key: 'nationName', label: '국가명', kind: 'text', required: true, min: 1, max: 18 }, + { + key: 'nationType', + label: '국가 성향', + kind: 'select', + required: true, + optionSource: 'nationTypes', + }, + { + key: 'colorType', + label: '국기 색상', + kind: 'select', + required: true, + optionSource: 'colors', + }, + ], + }, + ], + }, + ], + nation: [], + inputOptions, + }; + await install(page, false, foundingCommandTable); + await page.setViewportSize({ width: 1200, height: 900 }); + await page.goto('/'); + await page.getByRole('button', { name: '1턴 명령 입력', exact: true }).click(); + + const picker = page.getByTestId('command-picker'); + await picker.getByRole('button', { name: '국가', exact: true }).click(); + await picker.getByRole('button', { name: '건국', exact: true }).click(); + const nationType = picker.getByLabel('국가 성향'); + await expect(nationType).toHaveValue('che_도적'); + await expect(nationType.locator('option[value="che_중립"]')).toHaveCount(0); + await expect(nationType.locator('option')).toHaveText(['도적']); + await nationType.focus(); + await expect(nationType).toBeFocused(); + await picker.screenshot({ path: test.info().outputPath('founding-selectable-nation-trait-desktop-1200.png') }); +}); + test('reserves force move, retirement, and resignation from the user command picker', async ({ page }) => { const specialCommandTable = { general: [ diff --git a/app/game-frontend/src/views/BattleSimulatorView.vue b/app/game-frontend/src/views/BattleSimulatorView.vue index 3f49c0cf..2b0fc8f1 100644 --- a/app/game-frontend/src/views/BattleSimulatorView.vue +++ b/app/game-frontend/src/views/BattleSimulatorView.vue @@ -254,6 +254,11 @@ const toExportedGeneral = (general: GeneralDraft): GeneralExport => ({ inheritBuff: { ...general.inheritBuff }, }); +const resolveAvailableNationType = (candidate?: string | null): string => { + const available = options.value?.nationTypes ?? []; + return available.some((entry) => entry.key === candidate) ? (candidate as string) : (available[0]?.key ?? ''); +}; + const initializeDefaults = async () => { loading.value = true; error.value = null; @@ -267,9 +272,9 @@ const initializeDefaults = async () => { repeatCnt.value = 1; seed.value = ''; - const nationTypeDefault = context.nationTypes[0]?.key ?? 'che_중립'; - attackerNation.type = me?.nation?.typeCode ?? nationTypeDefault; - defenderNation.type = me?.nation?.typeCode ?? nationTypeDefault; + const nationTypeDefault = resolveAvailableNationType(me?.nation?.typeCode); + attackerNation.type = nationTypeDefault; + defenderNation.type = nationTypeDefault; attackerNation.level = me?.nation?.level ?? 0; defenderNation.level = me?.nation?.level ?? 0; @@ -302,10 +307,10 @@ const applyGameEnvironment = () => { return; } const me = gameDefaults.value; - const nationTypeDefault = options.value.nationTypes[0]?.key ?? 'che_중립'; + const nationTypeDefault = resolveAvailableNationType(me?.nation?.typeCode); year.value = options.value.world.currentYear; month.value = options.value.world.currentMonth; - attackerNation.type = me?.nation?.typeCode ?? nationTypeDefault; + attackerNation.type = nationTypeDefault; defenderNation.type = attackerNation.type; attackerNation.level = me?.nation?.level ?? 0; defenderNation.level = attackerNation.level; @@ -324,7 +329,7 @@ const applyIndependentEnvironment = () => { if (!options.value) { return; } - const nationTypeDefault = options.value.nationTypes[0]?.key ?? 'che_중립'; + const nationTypeDefault = resolveAvailableNationType(); year.value = options.value.world.startYear; month.value = 1; seed.value = ''; @@ -742,13 +747,13 @@ const importBattle = (data: BattleExport) => { month.value = data.month; repeatCnt.value = data.repeatCnt; - attackerNation.type = data.attackerNation.type; + attackerNation.type = resolveAvailableNationType(data.attackerNation.type); attackerNation.level = data.attackerNation.level; attackerNation.tech = Math.floor(data.attackerNation.tech / 1000); attackerNation.isCapital = data.attackerNation.capital === 1; attackerCity.level = data.attackerCity.level; - defenderNation.type = data.defenderNation.type; + defenderNation.type = resolveAvailableNationType(data.defenderNation.type); defenderNation.level = data.defenderNation.level; defenderNation.tech = Math.floor(data.defenderNation.tech / 1000); defenderNation.isCapital = data.defenderNation.capital === 3; diff --git a/packages/logic/src/actionModules/traits/nation/index.ts b/packages/logic/src/actionModules/traits/nation/index.ts index acd856df..e4719c81 100644 --- a/packages/logic/src/actionModules/traits/nation/index.ts +++ b/packages/logic/src/actionModules/traits/nation/index.ts @@ -19,6 +19,17 @@ export const NATION_TRAIT_KEYS = [ export type NationTraitKey = (typeof NATION_TRAIT_KEYS)[number]; +// Ref GameConst::$availableNationType excludes the neutral storage/default trait. +// Founding, NPC founding, and the battle simulator must only expose this list. +export type AvailableNationTraitKey = Exclude; + +export const AVAILABLE_NATION_TRAIT_KEYS: readonly AvailableNationTraitKey[] = NATION_TRAIT_KEYS.filter( + (key): key is Exclude => key !== 'che_중립' +); + +export const isAvailableNationTraitKey = (value: string): value is AvailableNationTraitKey => + AVAILABLE_NATION_TRAIT_KEYS.includes(value as AvailableNationTraitKey); + export type NationTraitModule = TraitModule; export type NationTraitImporter = () => Promise; diff --git a/packages/logic/src/actions/turn/general/foundingShared.ts b/packages/logic/src/actions/turn/general/foundingShared.ts index abab0304..e18a8952 100644 --- a/packages/logic/src/actions/turn/general/foundingShared.ts +++ b/packages/logic/src/actions/turn/general/foundingShared.ts @@ -1,4 +1,4 @@ -import { NATION_TRAIT_KEYS } from '@sammo-ts/logic/actionModules/traits/nation/index.js'; +import { isAvailableNationTraitKey } from '@sammo-ts/logic/actionModules/traits/nation/index.js'; import { getLegacyStringWidth } from '@sammo-ts/logic/troop/management.js'; import { z } from 'zod'; @@ -38,14 +38,12 @@ export const NATION_COLORS = [ '#A9A9A9', ] as const; -const SELECTABLE_NATION_TYPES = new Set(NATION_TRAIT_KEYS.filter((key) => key !== 'che_중립')); - export const FOUNDING_ARGS_SCHEMA = z.object({ nationName: z .string() .min(1) .refine((value) => getLegacyStringWidth(value) <= 18), - nationType: z.string().refine((value) => SELECTABLE_NATION_TYPES.has(value)), + nationType: z.string().refine(isAvailableNationTraitKey), colorType: z .number() .int() diff --git a/packages/logic/test/nationTraitAvailability.test.ts b/packages/logic/test/nationTraitAvailability.test.ts new file mode 100644 index 00000000..182c9699 --- /dev/null +++ b/packages/logic/test/nationTraitAvailability.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest'; + +import { + AVAILABLE_NATION_TRAIT_KEYS, + isAvailableNationTraitKey, + NATION_TRAIT_KEYS, +} from '../src/actionModules/traits/nation/index.js'; + +describe('Ref-selectable nation traits', () => { + it('keeps the neutral storage trait valid internally but unavailable to user selection', () => { + expect(NATION_TRAIT_KEYS).toContain('che_중립'); + expect(AVAILABLE_NATION_TRAIT_KEYS).toEqual([ + 'che_도적', + 'che_명가', + 'che_음양가', + 'che_종횡가', + 'che_불가', + 'che_오두미도', + 'che_태평도', + 'che_도가', + 'che_묵가', + 'che_덕가', + 'che_병가', + 'che_유가', + 'che_법가', + ]); + expect(isAvailableNationTraitKey('che_중립')).toBe(false); + expect(isAvailableNationTraitKey('che_도적')).toBe(true); + }); +});