merge: 최신 main을 토너먼트 즉시 조편성 수정에 통합

This commit is contained in:
2026-08-17 10:49:24 +00:00
21 changed files with 455 additions and 118 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: '공격적인 성격' }],
@@ -356,6 +356,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: [
@@ -810,6 +810,7 @@ test('desktop menus preserve ref columns, prefix-safe routes, and controlled dro
await installFixture(page, state);
await page.setViewportSize({ width: 1200, height: 900 });
await waitForMain(page);
if (artifactRoot) await mkdir(resolve(artifactRoot), { recursive: true });
await expect(page.locator('.main-global-menu')).toHaveCount(3);
expect(await gridColumnCount(page, '.main-global-menu')).toBe(8);
@@ -2238,6 +2239,222 @@ test('nation menu presentation follows the server-derived permission matrix', as
);
});
test('all main Lumen button families share the rounded pressed geometry', async ({ page }) => {
const state: NavigationFixture = {
officerLevel: 5,
permission: 2,
nationLevel: 3,
stage: 0,
npcMode: 1,
generalMeCalls: 0,
operations: [],
nationColor: '#663399',
};
await installFixture(page, state);
await page.setViewportSize({ width: 1200, height: 900 });
await waitForMain(page);
const controls: Array<[string, Locator]> = [
[
'천통국 베팅',
page.locator('.main-global-menu[data-menu-position="top"] [data-navigation-id="nation-betting"]'),
],
[
'게임정보',
page.locator('.main-global-menu[data-menu-position="top"]').getByRole('button', {
name: '게임정보',
exact: true,
}),
],
['회 의 실', page.locator('.layout-desktop [data-navigation-id="meeting"]')],
['기 밀 실', page.locator('.layout-desktop [data-navigation-id="secret-board"]')],
[
'당기기',
page.locator('[data-main-target="commands"] .bottom-actions').getByRole('button', { name: '당기기' }),
],
[
'미루기',
page.locator('[data-main-target="commands"] .bottom-actions').getByRole('button', { name: '미루기' }),
],
[
'펼치기',
page.locator('[data-main-target="commands"] .bottom-actions').getByRole('button', { name: '펼치기' }),
],
['실시간 동기화', page.locator('.desktop-action-controls').getByRole('button', { name: / :/u })],
['갱 신', page.locator('.desktop-action-controls').getByRole('button', { name: '갱 신' })],
['로비로', page.locator('.desktop-action-controls').getByRole('button', { name: '로비로' })],
];
const measure = (control: Locator) =>
control.evaluate((element) => {
const rect = element.getBoundingClientRect();
const style = getComputedStyle(element);
return {
top: rect.top,
bottom: rect.bottom,
height: rect.height,
marginTop: style.marginTop,
borderTop: style.borderTopWidth,
borderRight: style.borderRightWidth,
borderBottom: style.borderBottomWidth,
borderLeft: style.borderLeftWidth,
radius: style.borderRadius,
background: style.backgroundColor,
filter: style.filter,
};
});
const evidence: Record<string, Record<string, unknown>> = {};
for (const [index, [label, control]] of controls.entries()) {
await expect(control, `${label} control`).toBeVisible();
await expect(control).toHaveClass(/legacy-button/u);
await control.scrollIntoViewIfNeeded();
await page.mouse.move(1195, 895);
const base = await measure(control);
evidence[label] = { default: base };
if (artifactRoot) {
await control.screenshot({ path: resolve(artifactRoot, `${index + 1}-default.png`) });
}
expect(base, `${label} default geometry`).toMatchObject({
marginTop: '0px',
borderTop: '0px',
borderRight: '1px',
borderBottom: '4px',
borderLeft: '1px',
radius: '5.25px',
filter: 'none',
});
await control.focus();
await expect(control, `${label} keyboard focus`).toBeFocused();
const focused = await measure(control);
evidence[label].focus = focused;
if (artifactRoot) {
await control.screenshot({ path: resolve(artifactRoot, `${index + 1}-focus.png`) });
}
expect(focused.borderBottom, `${label} focus edge`).toBe('4px');
expect(focused.marginTop, `${label} focus position`).toBe('0px');
await control.hover();
const hovered = await measure(control);
evidence[label].hover = hovered;
if (artifactRoot) {
await control.screenshot({ path: resolve(artifactRoot, `${index + 1}-hover.png`) });
}
expect(hovered.borderBottom, `${label} hover edge`).toBe('3px');
expect(hovered.marginTop, `${label} hover position`).toBe('1px');
expect(hovered.top, `${label} hover top`).toBeCloseTo(base.top + 1, 2);
expect(hovered.height, `${label} hover height`).toBeCloseTo(base.height - 1, 2);
expect(hovered.bottom, `${label} hover bottom`).toBeCloseTo(base.bottom, 2);
expect(hovered.background, `${label} hover face`).toBe(base.background);
const box = await control.boundingBox();
if (!box) throw new Error(`${label} control has no bounding box`);
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
await page.mouse.down();
const pressed = await measure(control);
evidence[label].pointerDown = pressed;
if (artifactRoot) {
await control.screenshot({ path: resolve(artifactRoot, `${index + 1}-pointer-down.png`) });
}
expect(pressed.borderBottom, `${label} pressed edge`).toBe('2px');
expect(pressed.marginTop, `${label} pressed position`).toBe('2px');
expect(pressed.top, `${label} pressed top`).toBeCloseTo(base.top + 2, 2);
expect(pressed.height, `${label} pressed height`).toBeCloseTo(base.height - 2, 2);
expect(pressed.bottom, `${label} pressed bottom`).toBeCloseTo(base.bottom, 2);
expect(pressed.background, `${label} pressed face`).toBe(base.background);
await page.mouse.move(1195, 895);
await page.mouse.up();
}
state.permission = 0;
await page.locator('.desktop-action-controls').getByRole('button', { name: '갱 신' }).click();
const disabledSecret = page.locator('.layout-desktop [data-navigation-id="secret-board"]');
await expect(disabledSecret).toHaveAttribute('aria-disabled', 'true');
await disabledSecret.scrollIntoViewIfNeeded();
const disabledBase = await measure(disabledSecret);
await disabledSecret.hover({ force: true });
const disabledHover = await measure(disabledSecret);
evidence['기 밀 실 disabled'] = { default: disabledBase, hover: disabledHover };
expect(disabledHover.borderBottom).toBe('4px');
expect(disabledHover.marginTop).toBe('0px');
expect(disabledHover.top).toBeCloseTo(disabledBase.top, 2);
if (artifactRoot) {
await disabledSecret.screenshot({ path: resolve(artifactRoot, 'disabled-secret-hover.png') });
await writeFile(
resolve(artifactRoot, 'main-lumen-button-states.json'),
`${JSON.stringify(evidence, null, 2)}\n`
);
}
await persistArtifact(page, `${basePath.slice(1)}-main-lumen-button-families`);
});
test('mobile main Lumen button families keep the same state geometry without overflow', async ({ page }) => {
const state: NavigationFixture = {
officerLevel: 5,
permission: 2,
nationLevel: 3,
stage: 0,
npcMode: 1,
generalMeCalls: 0,
operations: [],
nationColor: '#663399',
};
await installFixture(page, state);
await page.setViewportSize({ width: 500, height: 900 });
await waitForMain(page);
const controls = [
page.locator('.main-global-menu[data-menu-position="top"] [data-navigation-id="nation-betting"]'),
page.locator('.main-global-menu[data-menu-position="top"]').getByRole('button', {
name: '게임정보',
exact: true,
}),
page.locator('.layout-mobile [data-navigation-id="meeting"]'),
page.locator('.layout-mobile [data-navigation-id="secret-board"]'),
page.locator('[data-main-target="commands"] .bottom-actions').getByRole('button', { name: '당기기' }),
page.locator('[data-main-target="commands"] .bottom-actions').getByRole('button', { name: '미루기' }),
page.locator('[data-main-target="commands"] .bottom-actions').getByRole('button', { name: '펼치기' }),
page.locator('.desktop-action-controls').getByRole('button', { name: / :/u }),
page.locator('.desktop-action-controls').getByRole('button', { name: '갱 신' }),
page.locator('.desktop-action-controls').getByRole('button', { name: '로비로' }),
];
for (const control of controls) {
await expect(control).toBeVisible();
await expect(control).toHaveClass(/legacy-button/u);
await expect(control).toHaveCSS('border-radius', '5.25px');
await expect(control).toHaveCSS('border-bottom-width', '4px');
}
for (const control of [controls[0], controls[2], controls[4], controls[7]]) {
if (!control) throw new Error('mobile Lumen control is missing');
await control.scrollIntoViewIfNeeded();
await control.focus();
await expect(control).toBeFocused();
await expect(control).toHaveCSS('border-bottom-width', '4px');
await control.hover();
await expect(control).toHaveCSS('border-bottom-width', '3px');
await expect(control).toHaveCSS('margin-top', '1px');
const box = await control.boundingBox();
if (!box) throw new Error('mobile Lumen control is not measurable');
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
await page.mouse.down();
await expect(control).toHaveCSS('border-bottom-width', '2px');
await expect(control).toHaveCSS('margin-top', '2px');
await page.mouse.move(499, 899);
await page.mouse.up();
}
expect(
await page.evaluate(() => ({
document: document.documentElement.scrollWidth - document.documentElement.clientWidth,
body: document.body.scrollWidth - document.body.clientWidth,
}))
).toEqual({ document: 0, body: 0 });
await persistArtifact(page, `${basePath.slice(1)}-mobile-main-lumen-button-families`);
});
test('mobile single document refreshes once and preserves tokens on lobby return', async ({ page }) => {
const state: NavigationFixture = {
officerLevel: 5,
@@ -48,26 +48,6 @@
cursor: pointer;
}
/*
* Ref renders the dashboard reload control with the Lumen navigation family:
* the bottom edge shortens on hover and again while pressed.
*/
.game-shell__action--navigation {
border-color: var(--sammo-button-navigation-border);
border-width: 0 1px 4px;
background: var(--sammo-button-navigation-bg);
}
.game-shell__action--navigation:not(:disabled):hover {
margin-top: 1px;
border-bottom-width: 3px;
}
.game-shell__action--navigation:not(:disabled):active {
margin-top: 2px;
border-bottom-width: 2px;
}
.game-feedback--error {
color: var(--sammo-color-error);
font-size: 0.85rem;
@@ -56,7 +56,6 @@
--legacy-button-bg: var(--sammo-button-primary-bg);
--legacy-button-border: var(--sammo-button-primary-border);
--legacy-button-color: #fff;
min-height: 35.5px;
margin-top: 0;
border-color: var(--legacy-button-border);
border-style: solid;
@@ -65,6 +64,7 @@
padding: 5.25px 10.5px;
background: var(--legacy-button-bg);
color: var(--legacy-button-color);
filter: none;
line-height: 21px;
/* Ref's framework baseline for these controls. */
vertical-align: middle;
@@ -635,9 +635,15 @@ const clickOutsideMenu = (event: Event) => {
</div>
<div v-if="!props.compact" class="bottom-actions">
<button type="button" @click="emit('shift', -1)">당기기</button>
<button type="button" @click="emit('shift', 1)">미루기</button>
<button type="button" @click="expanded = !expanded">{{ expanded ? '접기' : '펼치기' }}</button>
<button class="legacy-button legacy-button--secondary" type="button" @click="emit('shift', -1)">
당기기
</button>
<button class="legacy-button legacy-button--secondary" type="button" @click="emit('shift', 1)">
미루기
</button>
<button class="legacy-button legacy-button--secondary" type="button" @click="expanded = !expanded">
{{ expanded ? '접기' : '펼치기' }}
</button>
</div>
</div>
</div>
@@ -760,7 +766,6 @@ const clickOutsideMenu = (event: Event) => {
.control-pad > button,
.clock,
.legacy-menu > summary,
.bottom-actions button,
.select-command {
box-sizing: border-box;
min-height: 34px;
@@ -26,10 +26,11 @@ const isActive = (link: MainNavigationLinkItem) => link.id === 'survey' && props
:link="entry"
:enabled="isNavigationConfigured(entry)"
:active="isActive(entry)"
lumen-variant="navigation"
/>
<div v-else-if="entry.kind === 'group'" class="main-menu-popup">
<button
class="main-menu-button"
class="main-menu-button legacy-button legacy-button--navigation"
type="button"
:data-menu-id="entry.id"
:aria-expanded="openId === entry.id"
@@ -63,9 +64,10 @@ const isActive = (link: MainNavigationLinkItem) => link.id === 'survey' && props
:link="entry.main"
:enabled="isNavigationConfigured(entry.main)"
:active="isActive(entry.main)"
lumen-variant="navigation"
/>
<button
class="main-menu-button main-menu-split__toggle"
class="main-menu-button main-menu-split__toggle legacy-button legacy-button--navigation"
type="button"
:data-menu-id="entry.id"
:aria-label="`${entry.main.label} 하위 메뉴`"
@@ -115,33 +117,6 @@ const isActive = (link: MainNavigationLinkItem) => link.id === 'survey' && props
min-width: 0;
}
.main-global-menu > :deep(.main-menu-link),
.main-menu-popup > .main-menu-button,
.main-menu-split > :deep(.main-menu-link),
.main-menu-split > .main-menu-split__toggle {
border-color: var(--sammo-button-navigation-border);
background-color: var(--sammo-button-navigation-bg);
background-image: none;
}
.main-global-menu > :deep(.main-menu-link:hover),
.main-global-menu > :deep(.main-menu-link:focus-visible),
.main-global-menu > :deep(.main-menu-link:active),
.main-menu-popup > .main-menu-button:hover,
.main-menu-popup > .main-menu-button:focus-visible,
.main-menu-popup > .main-menu-button:active,
.main-menu-popup > .main-menu-button[aria-expanded='true'],
.main-menu-split > :deep(.main-menu-link:hover),
.main-menu-split > :deep(.main-menu-link:focus-visible),
.main-menu-split > :deep(.main-menu-link:active),
.main-menu-split > .main-menu-split__toggle:hover,
.main-menu-split > .main-menu-split__toggle:focus-visible,
.main-menu-split > .main-menu-split__toggle:active,
.main-menu-split > .main-menu-split__toggle[aria-expanded='true'] {
border-color: var(--sammo-button-navigation-border);
background-color: var(--sammo-button-navigation-bg);
}
.main-menu-popup > .main-menu-button,
.main-menu-split > :deep(.main-menu-link) {
width: 100%;
@@ -157,7 +132,11 @@ const isActive = (link: MainNavigationLinkItem) => link.id === 'survey' && props
width: 28px;
padding: 0;
border-left-width: 0;
border-radius: 0 3px 3px 0;
border-radius: 0 5.25px 5.25px 0;
}
.main-menu-split > :deep(.main-menu-link) {
border-radius: 5.25px 0 0 5.25px;
}
.menu-caret {
@@ -33,15 +33,17 @@ const isActive = (link: MainNavigationLinkItem) => link.highlightStage === props
:link="entry"
:enabled="isNationNavigationEnabled(entry, access)"
:active="isActive(entry)"
lumen-variant="lumen"
/>
<div v-else-if="entry.kind === 'split'" class="nation-menu-split">
<MainNavigationLink
:link="entry.main"
:enabled="isNationNavigationEnabled(entry.main, access)"
:active="isActive(entry.main)"
lumen-variant="lumen"
/>
<button
class="main-menu-button nation-menu-split__toggle"
class="main-menu-button nation-menu-split__toggle legacy-button legacy-button--lumen"
type="button"
:data-menu-id="entry.id"
:aria-label="`${entry.main.label} 하위 메뉴`"
@@ -81,13 +83,14 @@ const isActive = (link: MainNavigationLinkItem) => link.highlightStage === props
.main-nation-menu :deep(.main-menu-link),
.main-nation-menu .main-menu-button {
border-color: color-mix(in srgb, var(--nation-menu-color) 85%, #000);
background-color: var(--nation-menu-color);
--legacy-button-bg: var(--nation-menu-color);
--legacy-button-border: color-mix(in srgb, var(--nation-menu-color) 90%, #000);
--legacy-button-color: #fff;
background-image: none;
}
.main-nation-menu.dark-label :deep(.main-menu-link),
.main-nation-menu.dark-label .main-menu-button {
color: #000;
--legacy-button-color: #000;
}
.nation-menu-split {
@@ -106,7 +109,11 @@ const isActive = (link: MainNavigationLinkItem) => link.highlightStage === props
width: 28px;
padding: 0;
border-left-width: 0;
border-radius: 0 3px 3px 0;
border-radius: 0 5.25px 5.25px 0;
}
.nation-menu-split > :deep(.main-menu-link) {
border-radius: 5.25px 0 0 5.25px;
}
.menu-caret {
@@ -8,11 +8,13 @@ const props = withDefaults(
enabled?: boolean;
compact?: boolean;
active?: boolean;
lumenVariant?: 'navigation' | 'lumen';
}>(),
{
enabled: true,
compact: false,
active: false,
lumenVariant: undefined,
}
);
@@ -22,13 +24,16 @@ const emit = defineEmits<{
const label = computed(() => (props.compact ? (props.link.compactLabel ?? props.link.label) : props.link.label));
const rel = computed(() => (props.link.newTab ? 'noopener noreferrer' : undefined));
const lumenClasses = computed(() =>
props.lumenVariant ? ['legacy-button', `legacy-button--${props.lumenVariant}`] : []
);
</script>
<template>
<RouterLink
v-if="enabled && link.to"
class="main-menu-link"
:class="{ highlight: active }"
:class="[lumenClasses, { highlight: active }]"
:to="link.to"
:target="link.newTab ? '_blank' : undefined"
:rel="rel"
@@ -40,7 +45,7 @@ const rel = computed(() => (props.link.newTab ? 'noopener noreferrer' : undefine
<a
v-else-if="enabled && link.href"
class="main-menu-link"
:class="{ highlight: active }"
:class="[lumenClasses, { highlight: active }]"
:href="link.href"
:target="link.newTab ? '_blank' : undefined"
:rel="rel"
@@ -52,6 +57,7 @@ const rel = computed(() => (props.link.newTab ? 'noopener noreferrer' : undefine
<span
v-else
class="main-menu-link disabled"
:class="lumenClasses"
role="link"
aria-disabled="true"
:title="link.unavailableReason"
@@ -256,6 +256,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;
@@ -269,9 +274,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;
@@ -304,10 +309,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;
@@ -326,7 +331,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 = '';
@@ -740,13 +745,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;
+12 -31
View File
@@ -143,7 +143,7 @@ watch(
</h1>
<div class="game-shell__actions desktop-action-controls">
<button
class="game-shell__action toggle"
class="game-shell__action toggle legacy-button legacy-button--navigation"
:class="{ active: realtimeEnabled }"
type="button"
@click="dashboard.setRealtimeEnabled(!realtimeEnabled)"
@@ -151,7 +151,7 @@ watch(
실시간 동기화: {{ realtimeLabel }}
</button>
<button
class="game-shell__action game-shell__action--navigation"
class="game-shell__action legacy-button legacy-button--navigation"
type="button"
:disabled="refreshing"
:aria-busy="refreshing"
@@ -159,7 +159,13 @@ watch(
>
</button>
<button class="game-shell__action" type="button" @click="moveLobby">로비로</button>
<button
class="game-shell__action legacy-button legacy-button--navigation"
type="button"
@click="moveLobby"
>
로비로
</button>
</div>
</header>
@@ -475,10 +481,6 @@ button {
background-image: var(--sammo-texture-walnut);
}
.toggle.active {
background: rgba(201, 164, 90, 0.2);
}
.game-shell__action.highlight {
border-color: #f39c12;
background: #8a5b13;
@@ -750,29 +752,8 @@ button {
margin-top: 31px;
}
/*
* Ref renders these dashboard controls with the Lumen navigation family: no top
* border, 1px sides and a 4px bottom edge that shortens on hover and press
* while the control moves down.
*/
.desktop-action-controls .game-shell__action {
border-color: #004f28;
border-style: solid;
border-width: 0 1px 4px;
background: #006b36;
color: #fff;
}
.desktop-action-controls .game-shell__action:hover {
margin-top: 1px;
border-bottom-width: 3px;
background: #00582c;
}
.desktop-action-controls .game-shell__action:active {
margin-top: 2px;
border-bottom-width: 2px;
background: #005128;
font-weight: 400;
}
.placeholder {
@@ -793,8 +774,8 @@ button {
}
.desktop-action-controls .game-shell__action {
padding-right: 8px;
padding-left: 8px;
padding-right: 4px;
padding-left: 4px;
}
.main-page {
+16
View File
@@ -56,6 +56,22 @@ control keeps its semantic color and uses the shared opacity/cursor state.
Hover and active use the Ref Lumen bottom-border movement rather than an
unrelated brightness filter.
The shared family is opt-in at each rendered control; defining the primitive
does not connect an existing `.main-menu-link`, `.game-shell__action`, or
feature button automatically. `MainNavigationLink.vue` exposes
`lumenVariant="navigation|lumen"` so top-level global and nation links can opt
in while flat popup menu items stay outside the raised family. The main page's
global menu, nation menu, desktop synchronization/reload/lobby controls, and
reserved-turn pull/push/expand row all use the same primitive. When adding a
new main-page control, inventory every desktop/mobile render site instead of
validating one representative button.
The primitive does not set a fixed `min-height`: Ref's 35.5px default height is
the result of line-height, padding, and the 4px edge, so it naturally becomes
34.5px/33.5px while the 1px/2px top margin keeps the bottom coordinate fixed.
Only a fixed-height owner such as the mobile bottom bar supplies explicit
45px/44px/43px state compensation.
Only layout belongs in the SFC: width, grid column, fixed-height compensation,
margins required by the page, and breakpoint-specific placement. Color base
variables may be supplied by the owner for dynamic nation/scenario colors, but
@@ -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<NationTraitKey, 'che_중립'>;
export const AVAILABLE_NATION_TRAIT_KEYS: readonly AvailableNationTraitKey[] = NATION_TRAIT_KEYS.filter(
(key): key is Exclude<NationTraitKey, 'che_중립'> => 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<TraitModuleExport>;
@@ -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<string>(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()
@@ -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);
});
});