merge: 최신 메인을 예약 명령 표시 수정에 통합
This commit is contained in:
@@ -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]),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -55,7 +55,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: {
|
||||
@@ -590,6 +590,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: [
|
||||
|
||||
@@ -327,9 +327,27 @@ const install = async (page: Page, state: FixtureState) => {
|
||||
const rawRequestBody: unknown = route.request().postData() ? route.request().postDataJSON() : {};
|
||||
const requestBody =
|
||||
rawRequestBody && typeof rawRequestBody === 'object' ? (rawRequestBody as Record<string, unknown>) : {};
|
||||
const queryInputText = new URL(route.request().url()).searchParams.get('input');
|
||||
let queryInput: Record<string, unknown> = {};
|
||||
if (queryInputText) {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(queryInputText);
|
||||
if (parsed && typeof parsed === 'object') {
|
||||
queryInput = parsed as Record<string, unknown>;
|
||||
}
|
||||
} catch {
|
||||
queryInput = {};
|
||||
}
|
||||
}
|
||||
const results = operations.map((operation, operationIndex) => {
|
||||
const rawPayload =
|
||||
requestBody[String(operationIndex)] ?? (operations.length === 1 ? requestBody : undefined);
|
||||
requestBody[String(operationIndex)] ??
|
||||
queryInput[String(operationIndex)] ??
|
||||
(operations.length === 1
|
||||
? Object.keys(requestBody).length > 0
|
||||
? requestBody
|
||||
: queryInput
|
||||
: undefined);
|
||||
const payload =
|
||||
rawPayload && typeof rawPayload === 'object' ? (rawPayload as TrpcRequestPayload) : undefined;
|
||||
const jsonInput =
|
||||
@@ -537,10 +555,12 @@ const install = async (page: Page, state: FixtureState) => {
|
||||
return response(battleCenter(state));
|
||||
}
|
||||
if (operation === 'nation.getGeneralLog') {
|
||||
const type = new URL(route.request().url()).searchParams.get('input')?.includes('generalAction')
|
||||
? 'generalAction'
|
||||
: operation;
|
||||
return response({ type, generalId: 7, logs: [{ id: 1, text: '<Y>감찰 기록</>' }] });
|
||||
const type =
|
||||
typeof jsonInput.type === 'string' &&
|
||||
['generalHistory', 'battleDetail', 'battleResult', 'generalAction'].includes(jsonInput.type)
|
||||
? jsonInput.type
|
||||
: 'generalAction';
|
||||
return response({ type, generalId: 7, logs: [{ id: 1, text: `<Y>${type} 감찰 기록</>` }] });
|
||||
}
|
||||
return response({ ok: true });
|
||||
});
|
||||
@@ -1310,7 +1330,9 @@ test('감찰부 keeps the selector interaction and shows the permission error pa
|
||||
await expect(page.locator('.selector-row select').nth(1)).toHaveValue('8');
|
||||
await page.getByRole('button', { name: '다음 ▶' }).click();
|
||||
await expect(page.locator('.selector-row select').nth(1)).toHaveValue('7');
|
||||
await expect(page.locator('.battle-general-name')).toContainText('검증장수 【 간의대부 | 건강 】');
|
||||
await expect(page.locator('.battle-general-name')).toContainText('검증장수');
|
||||
await expect(page.locator('.battle-general-name')).toContainText('간의대부');
|
||||
await expect(page.locator('.battle-general-name')).toContainText('건강');
|
||||
await expect(page.locator('.battle-general-extra')).toContainText('계급29품관');
|
||||
await expect(page.locator('.battle-general-card')).toContainText('병종보병');
|
||||
await expect(page.locator('.battle-general-card')).not.toContainText('che_');
|
||||
@@ -1321,6 +1343,11 @@ test('감찰부 keeps the selector interaction and shows the permission error pa
|
||||
expect(battleImages[1]?.backgroundImage).toContain('/game/crewtype1.png');
|
||||
await expect(page.locator('.battle-general-card [role="progressbar"]')).toHaveCount(14);
|
||||
await expect(page.locator('.battle-general-card [aria-label*="1,275,975 (EX+)"]')).toHaveCount(5);
|
||||
await expect(page.locator('.general-meta')).toHaveCount(0);
|
||||
await expect(page.locator('.battle-general-extra__recent-value')).toHaveText('01-01 00:00');
|
||||
await expect(page.locator('.battle-general-extra__recent-value')).not.toContainText('2026');
|
||||
await expect(page.locator('.log-block')).toHaveCount(4);
|
||||
await expect(page.locator('.log-block[data-log-type="battleResult"]')).toContainText('battleResult 감찰 기록');
|
||||
expect(
|
||||
await page
|
||||
.locator('.battle-general-card [role="progressbar"]')
|
||||
@@ -1330,7 +1357,11 @@ test('감찰부 keeps the selector interaction and shows the permission error pa
|
||||
const geometry = await page.locator('.battle-page').evaluate((element) => {
|
||||
const selector = element.querySelector<HTMLElement>('.selector-row')!;
|
||||
const controls = [...selector.children].map((child) => (child as HTMLElement).getBoundingClientRect());
|
||||
const logBlock = element.querySelector<HTMLElement>('.log-block')!.getBoundingClientRect();
|
||||
const logBlocks = [...element.querySelectorAll<HTMLElement>('.log-block')];
|
||||
const logBlock = logBlocks[0]!.getBoundingClientRect();
|
||||
const recentLabel = element.querySelector<HTMLElement>('.battle-general-extra__recent-label')!;
|
||||
const recentValue = element.querySelector<HTMLElement>('.battle-general-extra__recent-value')!;
|
||||
const previousStat = recentLabel.previousElementSibling as HTMLElement;
|
||||
return {
|
||||
width: element.getBoundingClientRect().width,
|
||||
fontSize: getComputedStyle(element).fontSize,
|
||||
@@ -1341,6 +1372,11 @@ test('감찰부 keeps the selector interaction and shows the permission error pa
|
||||
backgroundImage: getComputedStyle(element).backgroundImage,
|
||||
generalBackgroundImage: getComputedStyle(element.querySelector<HTMLElement>('.battle-general-card')!)
|
||||
.backgroundImage,
|
||||
logBackgroundImages: logBlocks.map((block) => getComputedStyle(block).backgroundImage),
|
||||
recentLabelTop: recentLabel.getBoundingClientRect().top,
|
||||
recentValueTop: recentValue.getBoundingClientRect().top,
|
||||
recentValueWidth: recentValue.getBoundingClientRect().width,
|
||||
previousStatTop: previousStat.getBoundingClientRect().top,
|
||||
};
|
||||
});
|
||||
expect(geometry.width).toBe(1000);
|
||||
@@ -1352,16 +1388,41 @@ test('감찰부 keeps the selector interaction and shows the permission error pa
|
||||
expect(geometry.logBlockWidth).toBeCloseTo(500, 0);
|
||||
expect(geometry.backgroundImage).toContain('back_walnut.jpg');
|
||||
expect(geometry.generalBackgroundImage).toContain('back_blue.jpg');
|
||||
expect(geometry.logBackgroundImages).toHaveLength(4);
|
||||
expect(geometry.logBackgroundImages.every((background) => background.includes('back_walnut.jpg'))).toBe(true);
|
||||
expect(geometry.recentLabelTop).toBeCloseTo(geometry.recentValueTop, 0);
|
||||
expect(geometry.recentLabelTop).toBeGreaterThan(geometry.previousStatTop);
|
||||
expect(geometry.recentValueWidth).toBeGreaterThan(400);
|
||||
await persistParityArtifact(page, 'core-battle-center-desktop', geometry);
|
||||
|
||||
await page.setViewportSize({ width: 500, height: 900 });
|
||||
const mobileGeometry = await page.locator('.selector-row').evaluate((element) => ({
|
||||
columns: getComputedStyle(element).gridTemplateColumns,
|
||||
controlWidths: [...element.children].map((child) => (child as HTMLElement).getBoundingClientRect().width),
|
||||
}));
|
||||
const mobileGeometry = await page.locator('.battle-page').evaluate((element) => {
|
||||
const selector = element.querySelector<HTMLElement>('.selector-row')!;
|
||||
const battleResult = element.querySelector<HTMLElement>('.log-block[data-log-type="battleResult"]')!;
|
||||
const footer = element.querySelector<HTMLElement>('.battle-footer')!;
|
||||
const pageRect = element.getBoundingClientRect();
|
||||
const resultRect = battleResult.getBoundingClientRect();
|
||||
const footerRect = footer.getBoundingClientRect();
|
||||
return {
|
||||
columns: getComputedStyle(selector).gridTemplateColumns,
|
||||
controlWidths: [...selector.children].map((child) => (child as HTMLElement).getBoundingClientRect().width),
|
||||
pageHeight: pageRect.height,
|
||||
pageOverflow: getComputedStyle(element).overflow,
|
||||
resultBottomWithinPage: resultRect.bottom <= pageRect.bottom,
|
||||
footerAfterResult: footerRect.top >= resultRect.bottom,
|
||||
resultBackgroundImage: getComputedStyle(battleResult).backgroundImage,
|
||||
};
|
||||
});
|
||||
expect(mobileGeometry.columns.split(' ')).toHaveLength(4);
|
||||
expect(mobileGeometry.controlWidths[0]).toBeCloseTo(83.33, 0);
|
||||
expect(mobileGeometry.controlWidths[1]).toBeCloseTo(125, 0);
|
||||
expect(mobileGeometry.pageHeight).toBeGreaterThan(0);
|
||||
expect(mobileGeometry.pageOverflow).toBe('visible');
|
||||
expect(mobileGeometry.resultBottomWithinPage).toBe(true);
|
||||
expect(mobileGeometry.footerAfterResult).toBe(true);
|
||||
expect(mobileGeometry.resultBackgroundImage).toContain('back_walnut.jpg');
|
||||
await page.locator('.log-block[data-log-type="battleResult"]').scrollIntoViewIfNeeded();
|
||||
await expect(page.locator('.log-block[data-log-type="battleResult"]')).toBeInViewport();
|
||||
await persistParityArtifact(page, 'core-battle-center-mobile', mobileGeometry);
|
||||
|
||||
await page.unrouteAll({ behavior: 'wait' });
|
||||
|
||||
@@ -748,6 +748,7 @@ const persistArtifact = async (page: Page, name: string) => {
|
||||
return {
|
||||
viewport: { width: innerWidth, height: innerHeight },
|
||||
global: describe('.main-global-menu'),
|
||||
bottomGlobalPopup: describe('[data-menu-position="bottom"] .main-menu-popup__list'),
|
||||
nation: describe('.main-nation-menu'),
|
||||
bottom: describe('.main-mobile-bottom'),
|
||||
globalPopup: describe('#mobile-global-menu'),
|
||||
@@ -809,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);
|
||||
@@ -914,6 +916,20 @@ test('desktop menus preserve ref columns, prefix-safe routes, and controlled dro
|
||||
await gameInfoButton.press('Enter');
|
||||
await expect(gameInfoButton).toHaveAttribute('aria-expanded', 'true');
|
||||
await expect(global.locator('#global-menu-game-info')).toBeVisible();
|
||||
const topMenuGeometry = await gameInfoButton.evaluate((button) => {
|
||||
const popup = button.parentElement?.querySelector<HTMLElement>('.main-menu-popup__list');
|
||||
const caret = button.querySelector<HTMLElement>('.menu-caret');
|
||||
if (!popup || !caret) throw new Error('top global menu popup geometry is incomplete');
|
||||
return {
|
||||
trigger: button.getBoundingClientRect().toJSON(),
|
||||
popup: popup.getBoundingClientRect().toJSON(),
|
||||
caretBorderTopWidth: getComputedStyle(caret).borderTopWidth,
|
||||
caretBorderBottomWidth: getComputedStyle(caret).borderBottomWidth,
|
||||
};
|
||||
});
|
||||
expect(topMenuGeometry.popup.top).toBeGreaterThanOrEqual(topMenuGeometry.trigger.bottom + 1);
|
||||
expect(topMenuGeometry.caretBorderTopWidth).toBe('4px');
|
||||
expect(topMenuGeometry.caretBorderBottomWidth).toBe('0px');
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(gameInfoButton).toHaveAttribute('aria-expanded', 'false');
|
||||
await expect(gameInfoButton).toBeFocused();
|
||||
@@ -921,9 +937,72 @@ test('desktop menus preserve ref columns, prefix-safe routes, and controlled dro
|
||||
await gameInfoButton.click();
|
||||
await page.getByRole('heading', { name: '메인 화면 검증 시나리오' }).click();
|
||||
await expect(gameInfoButton).toHaveAttribute('aria-expanded', 'false');
|
||||
|
||||
const bottomGlobal = page.locator('[data-menu-position="bottom"]');
|
||||
const bottomGameInfoButton = bottomGlobal.locator('[data-menu-id="game-info"]');
|
||||
await bottomGameInfoButton.click();
|
||||
await expect(bottomGameInfoButton).toHaveAttribute('aria-expanded', 'true');
|
||||
await expect(bottomGlobal.locator('#global-menu-game-info')).toBeVisible();
|
||||
const bottomMenuGeometry = await bottomGameInfoButton.evaluate((button) => {
|
||||
const popup = button.parentElement?.querySelector<HTMLElement>('.main-menu-popup__list');
|
||||
const caret = button.querySelector<HTMLElement>('.menu-caret');
|
||||
if (!popup || !caret) throw new Error('bottom global menu popup geometry is incomplete');
|
||||
return {
|
||||
trigger: button.getBoundingClientRect().toJSON(),
|
||||
popup: popup.getBoundingClientRect().toJSON(),
|
||||
caretBorderTopWidth: getComputedStyle(caret).borderTopWidth,
|
||||
caretBorderBottomWidth: getComputedStyle(caret).borderBottomWidth,
|
||||
boxShadow: getComputedStyle(popup).boxShadow,
|
||||
};
|
||||
});
|
||||
expect(bottomMenuGeometry.popup.bottom).toBeLessThanOrEqual(bottomMenuGeometry.trigger.top - 1);
|
||||
expect(bottomMenuGeometry.caretBorderTopWidth).toBe('0px');
|
||||
expect(bottomMenuGeometry.caretBorderBottomWidth).toBe('4px');
|
||||
expect(bottomMenuGeometry.boxShadow).toContain('0px -8px 18px');
|
||||
await persistArtifact(page, `${basePath.slice(1)}-desktop-1200`);
|
||||
});
|
||||
|
||||
test('the repeated bottom global menu opens upward on the mobile document', async ({ page }, testInfo) => {
|
||||
const state: NavigationFixture = {
|
||||
officerLevel: 5,
|
||||
permission: 2,
|
||||
nationLevel: 3,
|
||||
stage: 1,
|
||||
npcMode: 1,
|
||||
scenarioTitle: '하단 메뉴 방향 검증 시나리오',
|
||||
generalMeCalls: 0,
|
||||
operations: [],
|
||||
};
|
||||
await installFixture(page, state);
|
||||
await page.setViewportSize({ width: 500, height: 900 });
|
||||
await waitForMain(page);
|
||||
|
||||
const bottomGlobal = page.locator('[data-menu-position="bottom"]');
|
||||
const gameInfoButton = bottomGlobal.locator('[data-menu-id="game-info"]');
|
||||
await gameInfoButton.click();
|
||||
await expect(gameInfoButton).toHaveAttribute('aria-expanded', 'true');
|
||||
await expect(bottomGlobal.locator('#global-menu-game-info')).toBeVisible();
|
||||
const geometry = await gameInfoButton.evaluate((button) => {
|
||||
const popup = button.parentElement?.querySelector<HTMLElement>('.main-menu-popup__list');
|
||||
const caret = button.querySelector<HTMLElement>('.menu-caret');
|
||||
if (!popup || !caret) throw new Error('mobile bottom global menu popup geometry is incomplete');
|
||||
return {
|
||||
trigger: button.getBoundingClientRect().toJSON(),
|
||||
popup: popup.getBoundingClientRect().toJSON(),
|
||||
caretBorderTopWidth: getComputedStyle(caret).borderTopWidth,
|
||||
caretBorderBottomWidth: getComputedStyle(caret).borderBottomWidth,
|
||||
viewportHeight: window.innerHeight,
|
||||
};
|
||||
});
|
||||
expect(geometry.popup.bottom).toBeLessThanOrEqual(geometry.trigger.top - 1);
|
||||
expect(geometry.popup.top).toBeGreaterThanOrEqual(0);
|
||||
expect(geometry.popup.bottom).toBeLessThanOrEqual(geometry.viewportHeight);
|
||||
expect(geometry.caretBorderTopWidth).toBe('0px');
|
||||
expect(geometry.caretBorderBottomWidth).toBe('4px');
|
||||
await bottomGlobal.screenshot({ path: testInfo.outputPath('mobile-bottom-global-dropup.png') });
|
||||
await persistArtifact(page, `${basePath.slice(1)}-mobile-bottom-dropup`);
|
||||
});
|
||||
|
||||
test('main general card uses local turn time and command clock tracks corrected server time', async ({ page }) => {
|
||||
const state: NavigationFixture = {
|
||||
officerLevel: 0,
|
||||
@@ -2160,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;
|
||||
|
||||
@@ -665,9 +665,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>
|
||||
@@ -790,7 +796,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 {
|
||||
@@ -184,6 +163,17 @@ const isActive = (link: MainNavigationLinkItem) => link.id === 'survey' && props
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.main-global-menu[data-menu-position='bottom'] .main-menu-popup__list {
|
||||
top: auto;
|
||||
bottom: calc(100% + 2px);
|
||||
box-shadow: 0 -8px 18px rgb(0 0 0 / 45%);
|
||||
}
|
||||
|
||||
.main-global-menu[data-menu-position='bottom'] .menu-caret {
|
||||
border-top-width: 0;
|
||||
border-bottom: 4px solid currentColor;
|
||||
}
|
||||
|
||||
.main-menu-split .main-menu-popup__list {
|
||||
right: 0;
|
||||
left: auto;
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -286,28 +286,26 @@ onMounted(() => {
|
||||
><strong>{{ selectedGeneral.battleStats.killCrew.toLocaleString('ko-KR') }}</strong>
|
||||
<span>피살</span
|
||||
><strong>{{ selectedGeneral.battleStats.deathCrew.toLocaleString('ko-KR') }}</strong>
|
||||
<span>최근 전투</span><strong>{{ selectedGeneral.recentWar || '-' }}</strong>
|
||||
<span class="battle-general-extra__recent-label">최근 전투</span>
|
||||
<strong class="battle-general-extra__recent-value">
|
||||
{{
|
||||
formatServerDateTime(selectedGeneral.recentWar, {
|
||||
format: 'monthDayTime',
|
||||
fallback: '-',
|
||||
})
|
||||
}}
|
||||
</strong>
|
||||
</div>
|
||||
<LegacyGeneralProgress :general="selectedGeneral" :show-primary="false" />
|
||||
</template>
|
||||
</GeneralBasicCard>
|
||||
<div v-if="selectedGeneral" class="general-meta">
|
||||
<div>
|
||||
최근 턴:
|
||||
{{
|
||||
formatServerDateTime(selectedGeneral.turnTime, { format: 'hourMinute', fallback: '-' })
|
||||
}}
|
||||
</div>
|
||||
<div>최근 전투: {{ selectedGeneral.recentWar || '-' }}</div>
|
||||
<div>전투 횟수: {{ selectedGeneral.warnum }}</div>
|
||||
</div>
|
||||
</PanelCard>
|
||||
</div>
|
||||
|
||||
<div class="stack">
|
||||
<PanelCard title="장수 기록" subtitle="열전과 전투 기록">
|
||||
<div class="log-grid">
|
||||
<div v-for="type in logTypes" :key="type" class="log-block">
|
||||
<div v-for="type in logTypes" :key="type" class="log-block" :data-log-type="type">
|
||||
<div class="log-title">{{ logLabels[type] }}</div>
|
||||
<SkeletonLines v-if="loading || logLoading" :lines="3" />
|
||||
<template v-else>
|
||||
@@ -356,14 +354,6 @@ onMounted(() => {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.general-meta {
|
||||
margin: 0;
|
||||
padding: 6px 8px;
|
||||
color: #ccc;
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.battle-general-extra {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, 1fr);
|
||||
@@ -390,6 +380,15 @@ onMounted(() => {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.battle-general-extra > .battle-general-extra__recent-label {
|
||||
grid-column: 1;
|
||||
}
|
||||
|
||||
.battle-general-extra > .battle-general-extra__recent-value {
|
||||
grid-column: 2 / -1;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.log-grid {
|
||||
display: contents;
|
||||
}
|
||||
@@ -397,7 +396,8 @@ onMounted(() => {
|
||||
.log-block {
|
||||
border: 1px solid #666;
|
||||
padding: 0;
|
||||
background: #000;
|
||||
background-color: #302016;
|
||||
background-image: var(--sammo-texture-walnut);
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
@@ -492,8 +492,8 @@ onMounted(() => {
|
||||
margin: 0 auto;
|
||||
padding: 0;
|
||||
gap: 0;
|
||||
height: 1268px;
|
||||
overflow: hidden;
|
||||
height: auto;
|
||||
overflow: visible;
|
||||
}
|
||||
.battle-top {
|
||||
height: 32px;
|
||||
@@ -530,7 +530,6 @@ onMounted(() => {
|
||||
@media (max-width: 991px) {
|
||||
.battle-page {
|
||||
width: 500px;
|
||||
height: 1411px;
|
||||
}
|
||||
.battle-top {
|
||||
grid-template-columns: 89px 89px 1fr 0 0;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user