fix: 선택 불가 국가 성향을 입력 목록에서 제외

Ref의 availableNationType 13개를 공용 목록으로 정의하고 건국, NPC/AI 건국, 전투 시뮬레이터 입력과 검증에 적용한다.
This commit is contained in:
2026-08-17 10:39:36 +00:00
parent bc356c0c84
commit 0b8add7f13
12 changed files with 171 additions and 25 deletions
+3 -1
View File
@@ -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),
@@ -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]),
+23 -2
View File
@@ -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 = {
@@ -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_중립' })])
);
});
});
@@ -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(
@@ -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}`,
@@ -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);
+56 -1
View File
@@ -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: [
@@ -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;