merge: 최신 main을 인게임 폰트 통일에 반영

This commit is contained in:
2026-08-17 10:53:27 +00:00
29 changed files with 1009 additions and 150 deletions
+3 -1
View File
@@ -1,5 +1,7 @@
import { z } from 'zod'; import { z } from 'zod';
import { isAvailableNationTraitKey } from '@sammo-ts/logic';
import type { BattleSimRequestPayload } from './types.js'; import type { BattleSimRequestPayload } from './types.js';
const zBattleSimGeneral = z.object({ const zBattleSimGeneral = z.object({
@@ -71,7 +73,7 @@ const zBattleSimCity = z.object({
}); });
const zBattleSimNation = z.object({ const zBattleSimNation = z.object({
type: z.string().min(1), type: z.string().refine(isAvailableNationTraitKey),
tech: z.number().min(0), tech: z.number().min(0),
level: z.number().int().min(0), level: z.number().int().min(0),
capital: z.number().int().min(0), capital: z.number().int().min(0),
@@ -1,4 +1,5 @@
import { import {
AVAILABLE_NATION_TRAIT_KEYS,
ITEM_KEYS, ITEM_KEYS,
EVENT_DOMESTIC_TRAIT_KEYS, EVENT_DOMESTIC_TRAIT_KEYS,
loadEventDomesticTraitModules, loadEventDomesticTraitModules,
@@ -6,7 +7,6 @@ import {
loadNationTraitModules, loadNationTraitModules,
loadPersonalityTraitModules, loadPersonalityTraitModules,
loadWarTraitModules, loadWarTraitModules,
NATION_TRAIT_KEYS,
PERSONALITY_TRAIT_KEYS, PERSONALITY_TRAIT_KEYS,
WAR_TRAIT_KEYS, WAR_TRAIT_KEYS,
type ItemModule, type ItemModule,
@@ -110,7 +110,7 @@ export const loadBattleSimTraitOptions = async (): Promise<{
}> => { }> => {
if (!cachedTraitOptions) { if (!cachedTraitOptions) {
cachedTraitOptions = Promise.all([ cachedTraitOptions = Promise.all([
loadNationTraitModules([...NATION_TRAIT_KEYS]), loadNationTraitModules([...AVAILABLE_NATION_TRAIT_KEYS]),
loadEventDomesticTraitModules([...EVENT_DOMESTIC_TRAIT_KEYS]), loadEventDomesticTraitModules([...EVENT_DOMESTIC_TRAIT_KEYS]),
loadWarTraitModules([...WAR_TRAIT_KEYS]), loadWarTraitModules([...WAR_TRAIT_KEYS]),
loadPersonalityTraitModules([...PERSONALITY_TRAIT_KEYS]), loadPersonalityTraitModules([...PERSONALITY_TRAIT_KEYS]),
+12 -10
View File
@@ -165,20 +165,22 @@ const loadGeneralTurns = async (db: DatabaseClient, generalId: number): Promise<
return buildTurnListFromRows(rows, MAX_GENERAL_TURNS); return buildTurnListFromRows(rows, MAX_GENERAL_TURNS);
}; };
const loadGeneralAutorunLimit = async (db: DatabaseClient, generalId: number): Promise<number | null> => {
const general = await db.general.findUnique({ where: { id: generalId }, select: { meta: true } });
const rawAutorunLimit = isRecord(general?.meta) ? general.meta.autorun_limit : undefined;
return typeof rawAutorunLimit === 'number' && Number.isFinite(rawAutorunLimit) ? Math.trunc(rawAutorunLimit) : null;
};
export const getGeneralTurnSnapshot = async (db: DatabaseClient, generalId: number): Promise<ReservedTurnSnapshot> => { export const getGeneralTurnSnapshot = async (db: DatabaseClient, generalId: number): Promise<ReservedTurnSnapshot> => {
const [turns, revisionRow, general] = await Promise.all([ const [turns, revisionRow, autorunLimit] = await Promise.all([
loadGeneralTurns(db, generalId), loadGeneralTurns(db, generalId),
db.generalTurnRevision.findUnique({ where: { generalId } }), db.generalTurnRevision.findUnique({ where: { generalId } }),
db.general.findUnique({ where: { id: generalId }, select: { meta: true } }), loadGeneralAutorunLimit(db, generalId),
]); ]);
const rawAutorunLimit = isRecord(general?.meta) ? general.meta.autorun_limit : undefined;
return { return {
revision: revisionRow?.revision ?? 0, revision: revisionRow?.revision ?? 0,
turns: serializeTurnList(turns), turns: serializeTurnList(turns),
autorunLimit: autorunLimit,
typeof rawAutorunLimit === 'number' && Number.isFinite(rawAutorunLimit)
? Math.trunc(rawAutorunLimit)
: null,
}; };
}; };
@@ -331,7 +333,7 @@ export const setGeneralTurns = async (
} }
} }
await persistGeneralTurns(db, generalId, turns); await persistGeneralTurns(db, generalId, turns);
return { revision, turns: serializeTurnList(turns) }; return { revision, turns: serializeTurnList(turns), autorunLimit: await loadGeneralAutorunLimit(db, generalId) };
}; };
export const setGeneralTurn = async ( export const setGeneralTurn = async (
@@ -357,7 +359,7 @@ export const shiftGeneralTurns = async (
const turns = await loadGeneralTurns(db, generalId); const turns = await loadGeneralTurns(db, generalId);
const shifted = applyShift(turns, amount); const shifted = applyShift(turns, amount);
await persistGeneralTurns(db, generalId, shifted); await persistGeneralTurns(db, generalId, shifted);
return { revision, turns: serializeTurnList(shifted) }; return { revision, turns: serializeTurnList(shifted), autorunLimit: await loadGeneralAutorunLimit(db, generalId) };
}; };
export const repeatGeneralTurns = async ( export const repeatGeneralTurns = async (
@@ -372,7 +374,7 @@ export const repeatGeneralTurns = async (
const revision = await claimGeneralRevision(db, generalId, expectedRevision); const revision = await claimGeneralRevision(db, generalId, expectedRevision);
const turns = applyRepeat(await loadGeneralTurns(db, generalId), amount); const turns = applyRepeat(await loadGeneralTurns(db, generalId), amount);
await persistGeneralTurns(db, generalId, turns); await persistGeneralTurns(db, generalId, turns);
return { revision, turns: serializeTurnList(turns) }; return { revision, turns: serializeTurnList(turns), autorunLimit: await loadGeneralAutorunLimit(db, generalId) };
}; };
export const setNationTurns = async ( export const setNationTurns = async (
+23 -2
View File
@@ -117,7 +117,7 @@ const buildBattleRequest = () => ({
conflict: '{}', conflict: '{}',
}, },
attackerNation: { attackerNation: {
type: 'test', type: 'che_도적',
tech: 1000, tech: 1000,
level: 1, level: 1,
capital: 1, capital: 1,
@@ -193,7 +193,7 @@ const buildBattleRequest = () => ({
conflict: '{}', conflict: '{}',
}, },
defenderNation: { defenderNation: {
type: 'test', type: 'che_도적',
tech: 1000, tech: 1000,
level: 1, level: 1,
capital: 2, capital: 2,
@@ -309,6 +309,27 @@ describe('battle router orchestration', () => {
expect(battleSim.simulateCalls).toBe(0); 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 () => { it('returns queued then completed results via transport', async () => {
const battleSim = new QueuedBattleSimTransport(); const battleSim = new QueuedBattleSimTransport();
const state: WorldStateRow = { const state: WorldStateRow = {
+8 -4
View File
@@ -16,7 +16,7 @@ import {
ReservedTurnRevisionConflictError, ReservedTurnRevisionConflictError,
} from '../src/turns/reservedTurns.js'; } from '../src/turns/reservedTurns.js';
const buildDb = () => { const buildDb = (autorunLimit: number | null = null) => {
const generalTurns = new Map<number, GeneralTurnRow[]>(); const generalTurns = new Map<number, GeneralTurnRow[]>();
const nationTurns = new Map<string, NationTurnRow[]>(); const nationTurns = new Map<string, NationTurnRow[]>();
const generalRevisions = new Map<number, number>(); const generalRevisions = new Map<number, number>();
@@ -45,7 +45,7 @@ const buildDb = () => {
findFirst: async () => null, findFirst: async () => null,
}, },
general: { general: {
findUnique: async () => null, findUnique: async () => ({ meta: autorunLimit === null ? {} : { autorun_limit: autorunLimit } }),
}, },
city: { city: {
findUnique: async () => null, findUnique: async () => null,
@@ -200,22 +200,25 @@ const buildDb = () => {
describe('reservedTurns', () => { describe('reservedTurns', () => {
it('sets and shifts general turns', async () => { it('sets and shifts general turns', async () => {
const { db } = buildDb(); const { db } = buildDb(2408);
const initial = await setGeneralTurn(db, 1, 0, 'che_화계', { destCityId: 10 }, 0); const initial = await setGeneralTurn(db, 1, 0, 'che_화계', { destCityId: 10 }, 0);
expect(initial.revision).toBe(1); expect(initial.revision).toBe(1);
expect(initial.turns).toHaveLength(MAX_GENERAL_TURNS); expect(initial.turns).toHaveLength(MAX_GENERAL_TURNS);
expect(initial.turns[0]?.action).toBe('che_화계'); expect(initial.turns[0]?.action).toBe('che_화계');
expect(initial.autorunLimit).toBe(2408);
const pushed = await shiftGeneralTurns(db, 1, 1, initial.revision); const pushed = await shiftGeneralTurns(db, 1, 1, initial.revision);
expect(pushed.revision).toBe(2); expect(pushed.revision).toBe(2);
expect(pushed.turns[0]?.action).toBe('휴식'); expect(pushed.turns[0]?.action).toBe('휴식');
expect(pushed.turns[1]?.action).toBe('che_화계'); expect(pushed.turns[1]?.action).toBe('che_화계');
expect(pushed.autorunLimit).toBe(2408);
const pulled = await shiftGeneralTurns(db, 1, -1, pushed.revision); const pulled = await shiftGeneralTurns(db, 1, -1, pushed.revision);
expect(pulled.turns[0]?.action).toBe('che_화계'); expect(pulled.turns[0]?.action).toBe('che_화계');
expect(pulled.turns[MAX_GENERAL_TURNS - 1]?.action).toBe('휴식'); expect(pulled.turns[MAX_GENERAL_TURNS - 1]?.action).toBe('휴식');
expect(pulled.autorunLimit).toBe(2408);
await expect(setGeneralTurn(db, 1, 2, 'che_훈련', {}, 1)).rejects.toBeInstanceOf( await expect(setGeneralTurn(db, 1, 2, 'che_훈련', {}, 1)).rejects.toBeInstanceOf(
ReservedTurnRevisionConflictError ReservedTurnRevisionConflictError
@@ -285,7 +288,7 @@ describe('reservedTurns', () => {
}); });
it('repeats the leading general pattern at the legacy interval', async () => { it('repeats the leading general pattern at the legacy interval', async () => {
const { db } = buildDb(); const { db } = buildDb(2408);
const seeded = await setGeneralTurns( const seeded = await setGeneralTurns(
db, db,
4, 4,
@@ -309,6 +312,7 @@ describe('reservedTurns', () => {
'che_사기진작', 'che_사기진작',
'che_징병', 'che_징병',
]); ]);
expect(repeated.autorunLimit).toBe(2408);
}); });
it('supports nation bulk/repeat and preserves the legacy amount-12 no-op', async () => { it('supports nation bulk/repeat and preserves the legacy amount-12 no-op', async () => {
+2 -1
View File
@@ -869,7 +869,7 @@ describe('appRouter', () => {
}); });
it('validates and persists general command arguments from the authenticated owner', async () => { it('validates and persists general command arguments from the authenticated owner', async () => {
const general = buildGeneralRow({ id: 13 }); const general = buildGeneralRow({ id: 13, meta: { autorun_limit: 2408 } });
const writes: unknown[] = []; const writes: unknown[] = [];
const changeJournal = new ChangeJournal(); const changeJournal = new ChangeJournal();
const caller = appRouter.createCaller( const caller = appRouter.createCaller(
@@ -885,6 +885,7 @@ describe('appRouter', () => {
}); });
expect(response.turns[0]).toMatchObject({ action: 'che_화계', args: { destCityId: 7 } }); expect(response.turns[0]).toMatchObject({ action: 'che_화계', args: { destCityId: 7 } });
expect(response.autorunLimit).toBe(2408);
expect(writes).toHaveLength(1); expect(writes).toHaveLength(1);
const written = writes[0] as { data: unknown[] }; const written = writes[0] as { data: unknown[] };
expect(written.data).toHaveLength(30); expect(written.data).toHaveLength(30);
@@ -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 { GAME_TICKS_PER_TURN, LiteHashDRBG, RandUtil } from '@sammo-ts/common';
import { simpleSerialize } from '@sammo-ts/logic/war/utils.js'; import { simpleSerialize } from '@sammo-ts/logic/war/utils.js';
import { resolveStartYear, resolveTurnTermMinutes } from '@sammo-ts/logic/actions/turn/actionContextHelpers.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 { GeneralActionPipeline } from '@sammo-ts/logic/actionModules/general.js';
import type { ReservedTurnEntry } from '../../reservedTurnStore.js'; import type { ReservedTurnEntry } from '../../reservedTurnStore.js';
@@ -348,8 +351,10 @@ export class GeneralAI {
chiefStatMin: this.scenarioConfig.stat.chiefMin, chiefStatMin: this.scenarioConfig.stat.chiefMin,
npcMessageFreqByDay: readNumber(constValues.npcMessageFreqByDay, 0), npcMessageFreqByDay: readNumber(constValues.npcMessageFreqByDay, 0),
availableNationTypes: Array.isArray(constValues.availableNationType) availableNationTypes: Array.isArray(constValues.availableNationType)
? constValues.availableNationType.filter((value) => typeof value === 'string') ? constValues.availableNationType.filter(
: NATION_TRAIT_KEYS.filter((value) => value !== 'che_중립'), (value): value is string => typeof value === 'string' && isAvailableNationTraitKey(value)
)
: [...AVAILABLE_NATION_TRAIT_KEYS],
}; };
const generalPolicy = new AutorunGeneralPolicy( const generalPolicy = new AutorunGeneralPolicy(
@@ -3,7 +3,7 @@ import {
LogCategory, LogCategory,
LogFormat, LogFormat,
LogScope, LogScope,
NATION_TRAIT_KEYS, AVAILABLE_NATION_TRAIT_KEYS,
getCityDistance, getCityDistance,
type City, type City,
type MapDefinition, type MapDefinition,
@@ -54,7 +54,6 @@ const NATION_COLORS = [
'#FFFFFF', '#FFFFFF',
'#A9A9A9', '#A9A9A9',
] as const; ] as const;
const AVAILABLE_NATION_TYPES = NATION_TRAIT_KEYS.filter((key) => key !== 'che_중립');
const NPC_TYPE = 6; const NPC_TYPE = 6;
const NPC_PREFIX = 'ⓤ'; const NPC_PREFIX = 'ⓤ';
const STAT_TYPE_WEIGHTS = { : 1, : 1 } as const; const STAT_TYPE_WEIGHTS = { : 1, : 1 } as const;
@@ -301,7 +300,7 @@ export const createRaiseNpcNationHandler = (options: {
const nationId = world.getNextNationId(); const nationId = world.getNextNationId();
const color = rng.choice([...NATION_COLORS]); const color = rng.choice([...NATION_COLORS]);
const typeCode = rng.choice([...AVAILABLE_NATION_TYPES]); const typeCode = rng.choice([...AVAILABLE_NATION_TRAIT_KEYS]);
const nation: Nation = { const nation: Nation = {
id: nationId, id: nationId,
name: `${NPC_PREFIX}${city.name}`, name: `${NPC_PREFIX}${city.name}`,
@@ -58,7 +58,7 @@ const simulatorOptions = {
{ id: 200, name: '궁병', armType: 2 }, { id: 200, name: '궁병', armType: 2 },
], ],
}, },
nationTypes: [{ key: 'che_중립', name: '중립', info: '특별한 효과 없음' }], nationTypes: [{ key: 'che_도적', name: '도적', info: '금 수입 증가, 쌀 수입 감소' }],
eventDomesticTraits: [{ key: 'che_event_신산', name: '신산', info: '계략 강화' }], eventDomesticTraits: [{ key: 'che_event_신산', name: '신산', info: '계략 강화' }],
warTraits: [{ key: 'che_필살', name: '필살', info: '필살 확률 증가' }], warTraits: [{ key: 'che_필살', name: '필살', info: '필살 확률 증가' }],
personalities: [{ 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 page.setViewportSize({ width: 1280, height: 900 });
await gotoSimulator(page); 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 notice = page.getByLabel('시뮬레이터 데이터 안내');
const noticeRect = await notice.boundingBox(); const noticeRect = await notice.boundingBox();
expect(noticeRect?.width).toBeLessThan(100); expect(noticeRect?.width).toBeLessThan(100);
+144 -11
View File
@@ -43,6 +43,7 @@ const inputOptions = {
cities: [ cities: [
{ value: 1, label: '업 (아국)' }, { value: 1, label: '업 (아국)' },
{ value: 2, label: '허창 (적국)', description: '적국 · 예주 · 대도시' }, { value: 2, label: '허창 (적국)', description: '적국 · 예주 · 대도시' },
{ value: 3, label: '단양 (오)' },
], ],
nations: [ nations: [
{ value: 1, label: '아국', color: '#008000' }, { value: 1, label: '아국', color: '#008000' },
@@ -54,7 +55,7 @@ const inputOptions = {
], ],
crewTypes: [{ value: 1100, label: '보병' }], crewTypes: [{ value: 1100, label: '보병' }],
armTypes: [{ value: 1, label: '보병' }], armTypes: [{ value: 1, label: '보병' }],
nationTypes: [{ value: 'che_중립', label: '중립' }], nationTypes: [{ value: 'che_도적', label: '도적', description: '금 수입 증가, 쌀 수입 감소' }],
colors: [{ value: 0, label: '색상 1', color: '#ff0000' }], colors: [{ value: 0, label: '색상 1', color: '#ff0000' }],
items: { horse: [{ value: 'None', label: '판매/해제' }] }, items: { horse: [{ value: 'None', label: '판매/해제' }] },
recruitment: { recruitment: {
@@ -182,6 +183,27 @@ const commandTable = {
}, },
], ],
}, },
{
category: '군사',
values: [
{
key: 'che_출병',
name: '출병',
reqArg: true,
possible: true,
status: 'needsInput',
inputFields: [
{
key: 'destCityId',
label: '대상 도시',
kind: 'select',
required: true,
optionSource: 'cities',
},
],
},
],
},
], ],
nation: [ nation: [
{ {
@@ -312,6 +334,7 @@ const generalContext = {
experience: 0, experience: 0,
dedication: 0, dedication: 0,
items: { horse: 'None', weapon: 'None', book: 'None', item: 'None' }, items: { horse: 'None', weapon: 'None', book: 'None', item: 'None' },
turnTime: '2026-08-17T12:00:00.000Z',
}, },
city: { city: {
id: 1, id: 1,
@@ -483,7 +506,7 @@ const install = async (page: Page, rejectGeneral = false, commandTableResponse:
if (name === 'turns.getCommandTable') return response(commandTableResponse); if (name === 'turns.getCommandTable') return response(commandTableResponse);
if (name === 'nation.getChiefCenter') return response(chiefCenter); if (name === 'nation.getChiefCenter') return response(chiefCenter);
if (name === 'turns.reserved.getGeneral') if (name === 'turns.reserved.getGeneral')
return response({ turns: generalTurns, revision: generalRevision }); return response({ turns: generalTurns, revision: generalRevision, autorunLimit: 2403 });
if (name === 'turns.reserved.getNation') return response({ turns: nationTurns, revision: nationRevision }); if (name === 'turns.reserved.getNation') return response({ turns: nationTurns, revision: nationRevision });
if (name === 'general.getRecentRecords') return response({ global: [], general: [], history: [] }); if (name === 'general.getRecentRecords') return response({ global: [], general: [], history: [] });
if (name === 'general.getFrontStatus') if (name === 'general.getFrontStatus')
@@ -520,7 +543,7 @@ const install = async (page: Page, rejectGeneral = false, commandTableResponse:
generalTurns[index] = { index, action: entry.action, args: entry.args ?? {} }; generalTurns[index] = { index, action: entry.action, args: entry.args ?? {} };
} }
generalRevision += 1; generalRevision += 1;
return response({ ok: true, revision: generalRevision, turns: generalTurns }); return response({ ok: true, revision: generalRevision, turns: generalTurns, autorunLimit: 2403 });
} }
if (name === 'turns.reserved.setNationBulk') { if (name === 'turns.reserved.setNationBulk') {
requests.push(body); requests.push(body);
@@ -561,12 +584,67 @@ test('renders and accepts every Ref strategy command at mobile width', async ({
await button.click(); await button.click();
const form = picker.getByTestId('command-argument-form'); const form = picker.getByTestId('command-argument-form');
await expect(form.getByTestId('command-argument-guidance')).toContainText(strategy.guidance); await expect(form.getByTestId('command-argument-guidance')).toContainText(strategy.guidance);
await expect(form.locator('select option')).toHaveCount(2); await expect(form.locator('select option')).toHaveCount(3);
await picker.getByRole('button', { name: '명령 다시 선택', exact: true }).click(); await picker.getByRole('button', { name: '명령 다시 선택', exact: true }).click();
} }
await picker.screenshot({ path: test.info().outputPath('all-strategy-commands-mobile.png') }); 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 }) => { test('reserves force move, retirement, and resignation from the user command picker', async ({ page }) => {
const specialCommandTable = { const specialCommandTable = {
general: [ general: [
@@ -654,7 +732,7 @@ test('reserves force move, retirement, and resignation from the user command pic
await expect(forceMoveForm.getByTestId('command-argument-guidance')).toContainText('선택한 도시로 강행합니다.'); await expect(forceMoveForm.getByTestId('command-argument-guidance')).toContainText('선택한 도시로 강행합니다.');
await forceMoveForm.locator('select').selectOption('2'); await forceMoveForm.locator('select').selectOption('2');
await picker.getByRole('button', { name: '입력', exact: true }).click(); await picker.getByRole('button', { name: '입력', exact: true }).click();
await expect(editor.locator('.action-column > div').nth(2)).toHaveText('강행'); await expect(editor.locator('.action-column > div').nth(2)).toHaveText('【허창】으로 강행');
const serialized = JSON.stringify(requests); const serialized = JSON.stringify(requests);
expect(serialized).toContain('"action":"che_은퇴","args":{}'); expect(serialized).toContain('"action":"che_은퇴","args":{}');
@@ -801,7 +879,9 @@ test('enters general and nation command arguments and sends exact values', async
return { width: rect.width, height: rect.height }; return { width: rect.width, height: rect.height };
}); });
await page.getByTestId('command-picker').getByRole('button', { name: '입력', exact: true }).click(); await page.getByTestId('command-picker').getByRole('button', { name: '입력', exact: true }).click();
await expect(page.locator('[data-command-scope="general"] .action-column > div').first()).toHaveText('화계'); await expect(page.locator('[data-command-scope="general"] .action-column > div').first()).toHaveText(
'【허창】에 화계실행'
);
await page.goto('/che/chief-center'); await page.goto('/che/chief-center');
await page.getByRole('button', { name: '1턴 명령 입력', exact: true }).click(); await page.getByRole('button', { name: '1턴 명령 입력', exact: true }).click();
@@ -824,7 +904,9 @@ test('enters general and nation command arguments and sends exact values', async
}; };
}); });
await chiefPicker.getByRole('button', { name: '입력', exact: true }).click(); await chiefPicker.getByRole('button', { name: '입력', exact: true }).click();
await expect(page.locator('[data-command-scope="nation"] .action-column > div').first()).toHaveText('포상'); await expect(page.locator('[data-command-scope="nation"] .action-column > div').first()).toHaveText(
'【관우】 쌀 300 포상'
);
expect(JSON.stringify(requests)).toContain('"destCityId":2'); expect(JSON.stringify(requests)).toContain('"destCityId":2');
expect(JSON.stringify(requests)).toContain('"isGold":false'); expect(JSON.stringify(requests)).toContain('"isGold":false');
@@ -906,7 +988,9 @@ test('uses a Ref-style full recruitment page without horizontal overflow on desk
await infantry.getByRole('button', { name: '절반', exact: true }).click(); await infantry.getByRole('button', { name: '절반', exact: true }).click();
await page.screenshot({ path: testInfo.outputPath('recruitment-desktop.png') }); await page.screenshot({ path: testInfo.outputPath('recruitment-desktop.png') });
await picker.getByRole('button', { name: '입력', exact: true }).click(); await picker.getByRole('button', { name: '입력', exact: true }).click();
await expect(page.locator('[data-command-scope="general"] .action-column > div').first()).toHaveText('징병'); await expect(page.locator('[data-command-scope="general"] .action-column > div').first()).toHaveText(
'【보병】 3500명 징병'
);
expect(JSON.stringify(requests)).toContain('"crewType":1100'); expect(JSON.stringify(requests)).toContain('"crewType":1100');
expect(JSON.stringify(requests)).toContain('"amount":3500'); expect(JSON.stringify(requests)).toContain('"amount":3500');
@@ -1112,6 +1196,53 @@ test('keeps the entered command visible and reports a server validation error',
await expect(page.getByTestId('command-argument-form').locator('select')).toHaveValue('2'); await expect(page.getByTestId('command-argument-form').locator('select')).toHaveValue('2');
}); });
test('keeps Ref command briefs and autonomous-action state after a turn mutation', async ({ page }) => {
await install(page);
await page.setViewportSize({ width: 1200, height: 900 });
await page.goto('/');
const editor = page.locator('[data-command-scope="general"]');
const status = editor.locator('[data-command-autorun-status]');
const firstRow = editor.locator('.action-column > div').first();
await expect(status).toHaveText(/ : 200 3 · \d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}/u);
await expect(firstRow).toContainText('휴식(자율 행동)');
expect(await firstRow.evaluate((element) => getComputedStyle(element).color)).toBe('rgb(170, 255, 255)');
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();
await picker.getByTestId('command-argument-form').locator('select').selectOption('3');
await picker.getByRole('button', { name: '입력', exact: true }).click();
await expect(firstRow).toHaveText('【단양】으로 출병');
await expect(firstRow).toHaveAttribute('title', / /u);
expect(await firstRow.evaluate((element) => getComputedStyle(element).color)).toBe('rgb(170, 255, 255)');
await expect(status).toHaveText(/ : 200 3 · .*/u);
const desktopGeometry = await editor.evaluate((element) => {
const statusElement = element.querySelector<HTMLElement>('[data-command-autorun-status]');
const row = element.querySelector<HTMLElement>('.action-column > div');
if (!statusElement || !row) throw new Error('autonomous command geometry is missing');
return {
horizontalOverflow: element.scrollWidth - element.clientWidth,
statusWidth: statusElement.getBoundingClientRect().width,
editorWidth: element.getBoundingClientRect().width,
rowHeight: row.getBoundingClientRect().height,
};
});
expect(desktopGeometry.horizontalOverflow).toBeLessThanOrEqual(0);
expect(desktopGeometry.statusWidth).toBeLessThanOrEqual(desktopGeometry.editorWidth);
expect(desktopGeometry.rowHeight).toBeGreaterThanOrEqual(20);
await page.screenshot({ path: test.info().outputPath('command-brief-autorun-desktop-1200.png'), fullPage: true });
await page.setViewportSize({ width: 500, height: 900 });
await expect(firstRow).toHaveText('【단양】으로 출병');
await expect(status).toBeVisible();
expect(await editor.evaluate((element) => element.scrollWidth - element.clientWidth)).toBeLessThanOrEqual(0);
await page.screenshot({ path: test.info().outputPath('command-brief-autorun-mobile-500.png'), fullPage: true });
});
test('uses drag selection, clipboard paste, and a stored template in advanced mode', async ({ page }) => { test('uses drag selection, clipboard paste, and a stored template in advanced mode', async ({ page }) => {
const requests = await install(page); const requests = await install(page);
await page.goto('/'); await page.goto('/');
@@ -1139,7 +1270,7 @@ test('uses drag selection, clipboard paste, and a stored template in advanced mo
await blockedFire.click(); await blockedFire.click();
await picker.getByTestId('command-argument-form').locator('select').selectOption('2'); await picker.getByTestId('command-argument-form').locator('select').selectOption('2');
await picker.getByRole('button', { name: '입력', exact: true }).click(); await picker.getByRole('button', { name: '입력', exact: true }).click();
await expect(editor.locator('.action-column > div').nth(2)).toHaveText('화계'); await expect(editor.locator('.action-column > div').nth(2)).toHaveText('【허창】에 화계실행');
await drag(0, 2); await drag(0, 2);
await editor.locator('details.selected-menu > summary').click(); await editor.locator('details.selected-menu > summary').click();
@@ -1150,7 +1281,7 @@ test('uses drag selection, clipboard paste, and a stored template in advanced mo
await expect(editor.locator('.index-column > button.selected')).toHaveCount(15); await expect(editor.locator('.index-column > button.selected')).toHaveCount(15);
await editor.locator('details.selected-menu > summary').click(); await editor.locator('details.selected-menu > summary').click();
await editor.getByRole('button', { name: '붙여넣기', exact: true }).click(); await editor.getByRole('button', { name: '붙여넣기', exact: true }).click();
await expect(editor.locator('.action-column > div').nth(5)).toHaveText('화계'); await expect(editor.locator('.action-column > div').nth(5)).toHaveText('【허창】에 화계실행');
await drag(0, 2); await drag(0, 2);
page.once('dialog', (dialog) => dialog.accept('화계 세트')); page.once('dialog', (dialog) => dialog.accept('화계 세트'));
@@ -1229,7 +1360,9 @@ test('keeps the shared main and chief shell geometry and interaction states', as
await chiefArgumentForm.locator('input[type=number]').fill('300'); await chiefArgumentForm.locator('input[type=number]').fill('300');
await chiefArgumentForm.locator('select').selectOption('2'); await chiefArgumentForm.locator('select').selectOption('2');
await page.getByTestId('command-picker').getByRole('button', { name: '입력', exact: true }).click(); await page.getByTestId('command-picker').getByRole('button', { name: '입력', exact: true }).click();
await expect(page.locator('[data-command-scope="nation"] .action-column > div').first()).toHaveText('포상'); await expect(page.locator('[data-command-scope="nation"] .action-column > div').first()).toHaveText(
'【관우】 쌀 300 포상'
);
expect(JSON.stringify(requests)).toContain('"action":"che_포상"'); expect(JSON.stringify(requests)).toContain('"action":"che_포상"');
expect(JSON.stringify(requests)).toContain('"destGeneralId":2'); expect(JSON.stringify(requests)).toContain('"destGeneralId":2');
const chiefDesktop = await page.locator('.chief-page').evaluate((element) => ({ const chiefDesktop = await page.locator('.chief-page').evaluate((element) => ({
@@ -810,6 +810,7 @@ test('desktop menus preserve ref columns, prefix-safe routes, and controlled dro
await installFixture(page, state); await installFixture(page, state);
await page.setViewportSize({ width: 1200, height: 900 }); await page.setViewportSize({ width: 1200, height: 900 });
await waitForMain(page); await waitForMain(page);
if (artifactRoot) await mkdir(resolve(artifactRoot), { recursive: true });
await expect(page.locator('.main-global-menu')).toHaveCount(3); await expect(page.locator('.main-global-menu')).toHaveCount(3);
expect(await gridColumnCount(page, '.main-global-menu')).toBe(8); 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 }) => { test('mobile single document refreshes once and preserves tokens on lobby return', async ({ page }) => {
const state: NavigationFixture = { const state: NavigationFixture = {
officerLevel: 5, officerLevel: 5,
@@ -48,26 +48,6 @@
cursor: pointer; 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 { .game-feedback--error {
color: var(--sammo-color-error); color: var(--sammo-color-error);
font-size: 0.85rem; font-size: 0.85rem;
@@ -56,7 +56,6 @@
--legacy-button-bg: var(--sammo-button-primary-bg); --legacy-button-bg: var(--sammo-button-primary-bg);
--legacy-button-border: var(--sammo-button-primary-border); --legacy-button-border: var(--sammo-button-primary-border);
--legacy-button-color: #fff; --legacy-button-color: #fff;
min-height: 35.5px;
margin-top: 0; margin-top: 0;
border-color: var(--legacy-button-border); border-color: var(--legacy-button-border);
border-style: solid; border-style: solid;
@@ -65,6 +64,7 @@
padding: 5.25px 10.5px; padding: 5.25px 10.5px;
background: var(--legacy-button-bg); background: var(--legacy-button-bg);
color: var(--legacy-button-color); color: var(--legacy-button-color);
filter: none;
line-height: 21px; line-height: 21px;
/* Ref's framework baseline for these controls. */ /* Ref's framework baseline for these controls. */
vertical-align: middle; vertical-align: middle;
@@ -21,6 +21,7 @@ import type {
CommandTable, CommandTable,
ReservedCommandRow, ReservedCommandRow,
} from './types'; } from './types';
import { formatReservedCommandBrief } from './reservedCommandBrief';
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
@@ -37,6 +38,7 @@ const props = withDefaults(
currentTime?: string; currentTime?: string;
mapData?: CommandMapData | null; mapData?: CommandMapData | null;
mapLayout?: CommandMapLayout | null; mapLayout?: CommandMapLayout | null;
autonomousUntil?: string | null;
}>(), }>(),
{ {
maxPushTurn: 6, maxPushTurn: 6,
@@ -47,6 +49,7 @@ const props = withDefaults(
currentTime: '--:--:--', currentTime: '--:--:--',
mapData: null, mapData: null,
mapLayout: null, mapLayout: null,
autonomousUntil: null,
} }
); );
@@ -70,6 +73,7 @@ const commandArgsValid = ref(false);
const expanded = ref(false); const expanded = ref(false);
const menuRevision = ref(0); const menuRevision = ref(0);
const pendingReservation = ref<CommandPatternEntry | null>(null); const pendingReservation = ref<CommandPatternEntry | null>(null);
const editorElement = ref<HTMLElement | null>(null);
const pickerElement = ref<HTMLElement | null>(null); const pickerElement = ref<HTMLElement | null>(null);
const collapsedRowCount = 15; const collapsedRowCount = 15;
@@ -133,12 +137,16 @@ const labelMap = computed(() => {
const displayRows = computed(() => const displayRows = computed(() =>
props.rows.slice(0, expanded.value || props.compact ? props.rows.length : collapsedRowCount) props.rows.slice(0, expanded.value || props.compact ? props.rows.length : collapsedRowCount)
); );
const quickPickerTop = computed(() => `${70 + (quickTarget.value ?? 0) * 34.4}px`); const quickPickerTop = ref('38px');
const isRecruitmentCommand = computed( const isRecruitmentCommand = computed(
() => selectedCommand.value?.key === 'che_징병' || selectedCommand.value?.key === 'che_모병' () => selectedCommand.value?.key === 'che_징병' || selectedCommand.value?.key === 'che_모병'
); );
const isRecruitmentOverlayOpen = computed(() => pickerOpen.value && isRecruitmentCommand.value); const isRecruitmentOverlayOpen = computed(() => pickerOpen.value && isRecruitmentCommand.value);
const rowLabel = (row: ReservedCommandRow): string => row.label ?? labelMap.value.get(row.action) ?? row.action; const rowLabel = (row: ReservedCommandRow): string =>
formatReservedCommandBrief(props.scope, row.action, row.args, props.commandTable) ||
row.label ||
labelMap.value.get(row.action) ||
row.action;
const selectedIndices = () => normalizedSelection(selected.value, previousSelected.value, props.rows.length); const selectedIndices = () => normalizedSelection(selected.value, previousSelected.value, props.rows.length);
const pattern = () => extractPattern(props.rows, selectedIndices()); const pattern = () => extractPattern(props.rows, selectedIndices());
const touchMenus = () => (menuRevision.value += 1); const touchMenus = () => (menuRevision.value += 1);
@@ -163,6 +171,19 @@ const finishDrag = (next: Set<number>) => {
}; };
const openPicker = (turnIndex?: number) => { const openPicker = (turnIndex?: number) => {
if (!props.compact && editorElement.value) {
const editorRect = editorElement.value.getBoundingClientRect();
const anchor =
turnIndex === undefined
? editorElement.value.querySelector<HTMLElement>('.control-pad')
: editorElement.value.querySelector<HTMLElement>(`[data-turn-index="${turnIndex}"]`);
if (anchor) {
const anchorRect = anchor.getBoundingClientRect();
quickPickerTop.value = `${
turnIndex === undefined ? anchorRect.bottom - editorRect.top : anchorRect.top - editorRect.top + 30
}px`;
}
}
quickTarget.value = turnIndex ?? null; quickTarget.value = turnIndex ?? null;
pickerOpen.value = true; pickerOpen.value = true;
selectedCommand.value = null; selectedCommand.value = null;
@@ -281,7 +302,7 @@ const rearrange = (direction: 'pull' | 'push') => {
const textCopy = async () => { const textCopy = async () => {
const lines = selectedIndices().map((index) => { const lines = selectedIndices().map((index) => {
const row = props.rows[index]; const row = props.rows[index];
return `${index + 1}${row?.label ?? labelMap.value.get(row?.action ?? '') ?? row?.action ?? ''}`; return `${index + 1}${row ? rowLabel(row) : ''}`;
}); });
await navigator.clipboard.writeText(lines.join('\n')); await navigator.clipboard.writeText(lines.join('\n'));
releaseSelection(); releaseSelection();
@@ -310,6 +331,7 @@ const clickOutsideMenu = (event: Event) => {
<template> <template>
<article <article
ref="editorElement"
class="reserved-command-editor" class="reserved-command-editor"
:class="{ :class="{
compact: props.compact, compact: props.compact,
@@ -326,6 +348,10 @@ const clickOutsideMenu = (event: Event) => {
<span>{{ props.title }} :</span><strong>{{ props.name ?? '-' }}</strong> <span>{{ props.title }} :</span><strong>{{ props.name ?? '-' }}</strong>
</header> </header>
<div v-if="props.autonomousUntil" class="autorun-status" data-command-autorun-status role="status">
자율 행동: {{ props.autonomousUntil }}
</div>
<div class="editor-layout"> <div class="editor-layout">
<aside class="control-pad"> <aside class="control-pad">
<div v-if="props.mobile && props.compact" class="mobile-identity legacy-bg1"> <div v-if="props.mobile && props.compact" class="mobile-identity legacy-bg1">
@@ -614,7 +640,11 @@ const clickOutsideMenu = (event: Event) => {
<div <div
v-for="row in displayRows" v-for="row in displayRows"
:key="row.index" :key="row.index"
:title="row.autonomous ? `${rowLabel(row)} · 자율 행동` : rowLabel(row)" :title="
row.autonomous
? `${rowLabel(row)} · 자율 행동${props.autonomousUntil ? ` (${props.autonomousUntil})` : ''}`
: rowLabel(row)
"
:class="{ autonomous: row.autonomous }" :class="{ autonomous: row.autonomous }"
> >
<span>{{ rowLabel(row) }}</span> <span>{{ rowLabel(row) }}</span>
@@ -635,9 +665,15 @@ const clickOutsideMenu = (event: Event) => {
</div> </div>
<div v-if="!props.compact" class="bottom-actions"> <div v-if="!props.compact" class="bottom-actions">
<button type="button" @click="emit('shift', -1)">당기기</button> <button class="legacy-button legacy-button--secondary" type="button" @click="emit('shift', -1)">
<button type="button" @click="emit('shift', 1)">미루기</button> 당기기
<button type="button" @click="expanded = !expanded">{{ expanded ? '접기' : '펼치기' }}</button> </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> </div>
</div> </div>
@@ -649,7 +685,7 @@ const clickOutsideMenu = (event: Event) => {
class="command-picker" class="command-picker"
:class="{ 'recruitment-picker': isRecruitmentCommand }" :class="{ 'recruitment-picker': isRecruitmentCommand }"
data-testid="command-picker" data-testid="command-picker"
:style="isRecruitmentCommand || quickTarget === null || props.compact ? undefined : { top: quickPickerTop }" :style="isRecruitmentCommand || props.compact ? undefined : { top: quickPickerTop }"
:role="isRecruitmentCommand ? 'dialog' : undefined" :role="isRecruitmentCommand ? 'dialog' : undefined"
:aria-modal="isRecruitmentCommand ? 'true' : undefined" :aria-modal="isRecruitmentCommand ? 'true' : undefined"
:aria-label=" :aria-label="
@@ -760,7 +796,6 @@ const clickOutsideMenu = (event: Event) => {
.control-pad > button, .control-pad > button,
.clock, .clock,
.legacy-menu > summary, .legacy-menu > summary,
.bottom-actions button,
.select-command { .select-command {
box-sizing: border-box; box-sizing: border-box;
min-height: 34px; min-height: 34px;
@@ -850,6 +885,15 @@ const clickOutsideMenu = (event: Event) => {
display: grid; display: grid;
grid-template-columns: 75px 40px minmax(0, 1fr) 38px; grid-template-columns: 75px 40px minmax(0, 1fr) 38px;
} }
.autorun-status {
padding: 3px 6px;
border-bottom: 1px solid #2d6574;
background: #102c35;
color: #aaffff;
font-size: 0.78rem;
line-height: 1.35;
text-align: center;
}
.queue-grid.advanced { .queue-grid.advanced {
grid-template-columns: 34px 75px 40px minmax(0, 1fr); grid-template-columns: 34px 75px 40px minmax(0, 1fr);
} }
@@ -1149,5 +1193,4 @@ const clickOutsideMenu = (event: Event) => {
bottom: 0; bottom: 0;
left: 109px; left: 109px;
} }
</style> </style>
@@ -0,0 +1,185 @@
import { JosaUtil } from '@sammo-ts/common';
import type { CommandOption, CommandTable } from './types';
type CommandScope = 'general' | 'nation';
type CommandArgs = Record<string, unknown>;
const CITY_TO_COMMANDS = new Set(['che_이동', 'che_강행', 'che_출병', 'che_천도']);
const CITY_AT_COMMANDS = new Set([
'che_첩보',
'che_화계',
'che_선동',
'che_탈취',
'che_파괴',
'che_백성동원',
'che_허보',
]);
const CITY_OBJECT_COMMANDS = new Set(['che_수몰', 'che_초토화']);
const NATION_AT_COMMANDS = new Set(['che_선전포고', 'che_급습', 'che_이호경식']);
const NATION_PROPOSAL_COMMANDS = new Set(['che_불가침파기제의', 'che_종전제의']);
const ITEM_TYPE_NAMES: Record<string, string> = {
horse: '명마',
weapon: '무기',
book: '서적',
item: '도구',
};
const asArgs = (value: unknown): CommandArgs =>
value && typeof value === 'object' && !Array.isArray(value) ? (value as CommandArgs) : {};
const firstValue = (args: CommandArgs, keys: readonly string[]): unknown => {
for (const key of keys) {
if (args[key] !== undefined) return args[key];
}
return undefined;
};
const optionLabel = (options: readonly CommandOption[], value: unknown): string | null => {
const option = options.find((entry) => entry.value === value || String(entry.value) === String(value));
if (!option) return null;
// 도시·장수 option에는 선택 보조용 소속 정보가 괄호로 붙지만 Ref brief에는 이름만 들어간다.
return option.label.replace(/\s+\([^)]*\)$/u, '');
};
const commandNameMap = (table: CommandTable | null, scope: CommandScope): Map<string, string> => {
const result = new Map<string, string>([['휴식', '휴식']]);
for (const group of table?.[scope] ?? []) {
for (const command of group.values) result.set(command.key, command.name);
}
return result;
};
const defaultCommandName = (action: string): string => action.replace(/^(?:che_|cr_|event_)/u, '');
const numberText = (value: unknown, grouped = false): string => {
if (typeof value !== 'number' || !Number.isFinite(value)) return String(value ?? '');
return grouped ? value.toLocaleString('en-US') : String(value);
};
const wrap = (value: string): string => `${value}`;
const withParticle = (value: string, particle: '을' | '으로'): string => `${value}${JosaUtil.pick(value, particle)}`;
const wrappedWithParticle = (value: string, particle: '을' | '으로'): string =>
`${wrap(value)}${JosaUtil.pick(value, particle)}`;
export const formatReservedCommandBrief = (
scope: CommandScope,
action: string,
rawArgs: unknown,
table: CommandTable | null
): string => {
const args = asArgs(rawArgs);
const input = table?.inputOptions;
const commandNames = commandNameMap(table, scope);
const commandName = commandNames.get(action) ?? defaultCommandName(action);
const cityName = optionLabel(input?.cities ?? [], firstValue(args, ['destCityId', 'destCityID']));
const nationName = optionLabel(input?.nations ?? [], firstValue(args, ['destNationId', 'destNationID']));
const generalName = optionLabel(input?.generals ?? [], firstValue(args, ['destGeneralId', 'destGeneralID']));
if (CITY_TO_COMMANDS.has(action) && cityName) {
return `${wrappedWithParticle(cityName, '으로')} ${commandName}`;
}
if (action === 'cr_인구이동' && cityName) {
return `${wrappedWithParticle(cityName, '으로')} ${numberText(args.amount, true)}${commandName}`;
}
if (CITY_AT_COMMANDS.has(action) && cityName) {
const suffix =
scope === 'nation' ? commandName : action === 'che_첩보' ? `${commandName} 실행` : `${commandName}실행`;
return `${wrap(cityName)}${suffix}`;
}
if (CITY_OBJECT_COMMANDS.has(action) && cityName) {
return `${wrappedWithParticle(cityName, '을')} ${commandName}`;
}
if (action === 'che_임관' && nationName) {
return `${wrappedWithParticle(nationName, '으로')} ${commandName}`;
}
if (NATION_AT_COMMANDS.has(action) && nationName) {
return `${wrap(nationName)}${commandName}`;
}
if (NATION_PROPOSAL_COMMANDS.has(action) && nationName) {
return `${wrap(nationName)}에게 ${commandName}`;
}
if (action === 'che_불가침제의' && nationName) {
return `${wrap(nationName)}에게 ${numberText(args.year)}${numberText(args.month)}월까지 ${commandName}`;
}
if (action === 'che_등용' && generalName) {
return `${wrappedWithParticle(generalName, '을')} ${commandName}`;
}
if (action === 'che_장수대상임관' && generalName) {
return `${wrappedWithParticle(generalName, '을')} 따라 임관`;
}
if (action === 'che_선양' && generalName) {
return `${wrap(generalName)}에게 ${commandName}`;
}
if (action === 'che_랜덤임관') {
return '무작위 국가로 임관';
}
if ((action === 'che_건국' || action === 'cr_건국') && typeof args.nationName === 'string') {
return `${wrappedWithParticle(args.nationName, '을')} 건국`;
}
if (action === 'che_무작위건국' && typeof args.nationName === 'string') {
return `${wrappedWithParticle(args.nationName, '을')} 무작위 도시에 건국`;
}
if (action === 'che_군량매매') {
return `군량 ${numberText(args.amount)}${args.buyRice ? '구입' : '판매'}`;
}
if (action === 'che_헌납') {
return `${args.isGold ? '금' : '쌀'} ${numberText(args.amount)}${commandName}`;
}
if (action === 'che_증여' && generalName) {
return `${wrap(generalName)}에게 ${args.isGold ? '금' : '쌀'} ${numberText(args.amount)}${commandName}`;
}
if (action === 'che_징병' || action === 'che_모병') {
const crewType = optionLabel(input?.crewTypes ?? [], args.crewType);
if (crewType) return `${wrap(crewType)} ${numberText(args.amount)}${commandName}`;
}
if (action === 'che_숙련전환') {
const srcArmType = optionLabel(input?.armTypes ?? [], args.srcArmType);
const destArmType = optionLabel(input?.armTypes ?? [], args.destArmType);
if (srcArmType && destArmType) return `${wrap(srcArmType)}숙련을 ${wrap(destArmType)}숙련으로 전환`;
}
if (action === 'che_장비매매') {
const itemType = typeof args.itemType === 'string' ? args.itemType : '';
if (args.itemCode === 'None') {
const itemTypeName = ITEM_TYPE_NAMES[itemType];
if (itemTypeName) return `${withParticle(itemTypeName, '을')} 판매`;
}
const itemName = optionLabel(input?.items[itemType] ?? [], args.itemCode);
if (itemName) {
const itemRawName = itemName.replace(/\([^)]*\)$/u, '');
return `${wrap(itemName)}${JosaUtil.pick(itemRawName, '을')} 구입`;
}
}
if (action === 'che_발령' && generalName && cityName) {
return `${wrap(generalName)}${wrappedWithParticle(cityName, '으로')} ${commandName}`;
}
if (action === 'che_부대탈퇴지시' && generalName) {
return `${wrap(generalName)}${commandName}`;
}
if ((action === 'che_포상' || action === 'che_몰수') && generalName) {
return `${wrap(generalName)} ${args.isGold ? '금' : '쌀'} ${numberText(args.amount, true)} ${commandName}`;
}
if (action === 'che_물자원조' && nationName && Array.isArray(args.amountList)) {
return `${wrap(nationName)}에게 국고 ${numberText(args.amountList[0], true)} 병량 ${numberText(
args.amountList[1],
true
)} ${commandName}`;
}
if (action === 'che_증축' || action === 'che_감축') {
return `수도를 ${commandName}`;
}
if (action === 'che_국기변경') {
return '【국기】를 변경';
}
if (action === 'che_국호변경' && typeof args.nationName === 'string') {
return `국호를 ${wrappedWithParticle(args.nationName, '으로')} 변경`;
}
if (action === 'che_피장파장' && nationName) {
const targetAction = typeof args.commandType === 'string' ? args.commandType : '';
const targetName = commandNames.get(targetAction) ?? defaultCommandName(targetAction);
return `${wrap(nationName)}${wrap(targetName)} ${commandName}`;
}
return commandName;
};
@@ -2,7 +2,7 @@
import { computed, onUnmounted, ref, watch } from 'vue'; import { computed, onUnmounted, ref, watch } from 'vue';
import { addMinutes } from 'date-fns'; import { addMinutes } from 'date-fns';
import ReservedCommandEditor from '../command/ReservedCommandEditor.vue'; import ReservedCommandEditor from '../command/ReservedCommandEditor.vue';
import { formatLocalTimeSeconds } from '../../utils/legacyDateTime'; import { formatLocalDateTime, formatLocalTimeSeconds } from '../../utils/legacyDateTime';
import type { import type {
CommandMapData, CommandMapData,
CommandMapLayout, CommandMapLayout,
@@ -65,6 +65,26 @@ const rows = computed<ReservedCommandRow[]>(() => {
}); });
}); });
const autonomousUntil = computed(() => {
if (props.autorunLimit == null) return null;
const baseYear = props.currentYear ?? 0;
const baseMonth = props.currentMonth ?? 1;
const currentAbsoluteMonth = baseYear * 12 + baseMonth - 1;
const lastAutonomousMonth = props.autorunLimit - 1;
if (lastAutonomousMonth < currentAbsoluteMonth) return null;
const untilYear = Math.floor(lastAutonomousMonth / 12);
const untilMonth = (lastAutonomousMonth % 12) + 1;
const base = props.general?.turnTime ? new Date(props.general.turnTime) : null;
const term = props.turnTermMinutes ?? 0;
const expiresAt =
base && Number.isFinite(base.getTime())
? addMinutes(base, (lastAutonomousMonth - currentAbsoluteMonth) * term)
: null;
const currentTimeLabel = expiresAt ? formatLocalDateTime(expiresAt) : '현재시각 확인 불가';
return `${untilYear}${untilMonth}月 · ${currentTimeLabel}까지`;
});
const currentServerTime = ref('--:--:--'); const currentServerTime = ref('--:--:--');
let sampledServerTimeMs: number | null = null; let sampledServerTimeMs: number | null = null;
let sampledClientTimeMs = 0; let sampledClientTimeMs = 0;
@@ -112,6 +132,7 @@ onUnmounted(() => {
:current-time="currentServerTime" :current-time="currentServerTime"
:map-data="props.mapData" :map-data="props.mapData"
:map-layout="props.mapLayout" :map-layout="props.mapLayout"
:autonomous-until="autonomousUntil"
@reserve-bulk="emit('set-general-turns', $event)" @reserve-bulk="emit('set-general-turns', $event)"
@shift="emit('shift-general-turns', $event)" @shift="emit('shift-general-turns', $event)"
@repeat="emit('repeat-general-turns', $event)" @repeat="emit('repeat-general-turns', $event)"
@@ -26,10 +26,11 @@ const isActive = (link: MainNavigationLinkItem) => link.id === 'survey' && props
:link="entry" :link="entry"
:enabled="isNavigationConfigured(entry)" :enabled="isNavigationConfigured(entry)"
:active="isActive(entry)" :active="isActive(entry)"
lumen-variant="navigation"
/> />
<div v-else-if="entry.kind === 'group'" class="main-menu-popup"> <div v-else-if="entry.kind === 'group'" class="main-menu-popup">
<button <button
class="main-menu-button" class="main-menu-button legacy-button legacy-button--navigation"
type="button" type="button"
:data-menu-id="entry.id" :data-menu-id="entry.id"
:aria-expanded="openId === entry.id" :aria-expanded="openId === entry.id"
@@ -63,9 +64,10 @@ const isActive = (link: MainNavigationLinkItem) => link.id === 'survey' && props
:link="entry.main" :link="entry.main"
:enabled="isNavigationConfigured(entry.main)" :enabled="isNavigationConfigured(entry.main)"
:active="isActive(entry.main)" :active="isActive(entry.main)"
lumen-variant="navigation"
/> />
<button <button
class="main-menu-button main-menu-split__toggle" class="main-menu-button main-menu-split__toggle legacy-button legacy-button--navigation"
type="button" type="button"
:data-menu-id="entry.id" :data-menu-id="entry.id"
:aria-label="`${entry.main.label} 하위 메뉴`" :aria-label="`${entry.main.label} 하위 메뉴`"
@@ -115,33 +117,6 @@ const isActive = (link: MainNavigationLinkItem) => link.id === 'survey' && props
min-width: 0; 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-popup > .main-menu-button,
.main-menu-split > :deep(.main-menu-link) { .main-menu-split > :deep(.main-menu-link) {
width: 100%; width: 100%;
@@ -157,7 +132,11 @@ const isActive = (link: MainNavigationLinkItem) => link.id === 'survey' && props
width: 28px; width: 28px;
padding: 0; padding: 0;
border-left-width: 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 { .menu-caret {
@@ -33,15 +33,17 @@ const isActive = (link: MainNavigationLinkItem) => link.highlightStage === props
:link="entry" :link="entry"
:enabled="isNationNavigationEnabled(entry, access)" :enabled="isNationNavigationEnabled(entry, access)"
:active="isActive(entry)" :active="isActive(entry)"
lumen-variant="lumen"
/> />
<div v-else-if="entry.kind === 'split'" class="nation-menu-split"> <div v-else-if="entry.kind === 'split'" class="nation-menu-split">
<MainNavigationLink <MainNavigationLink
:link="entry.main" :link="entry.main"
:enabled="isNationNavigationEnabled(entry.main, access)" :enabled="isNationNavigationEnabled(entry.main, access)"
:active="isActive(entry.main)" :active="isActive(entry.main)"
lumen-variant="lumen"
/> />
<button <button
class="main-menu-button nation-menu-split__toggle" class="main-menu-button nation-menu-split__toggle legacy-button legacy-button--lumen"
type="button" type="button"
:data-menu-id="entry.id" :data-menu-id="entry.id"
:aria-label="`${entry.main.label} 하위 메뉴`" :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 :deep(.main-menu-link),
.main-nation-menu .main-menu-button { .main-nation-menu .main-menu-button {
border-color: color-mix(in srgb, var(--nation-menu-color) 85%, #000); --legacy-button-bg: var(--nation-menu-color);
background-color: var(--nation-menu-color); --legacy-button-border: color-mix(in srgb, var(--nation-menu-color) 90%, #000);
--legacy-button-color: #fff;
background-image: none; background-image: none;
} }
.main-nation-menu.dark-label :deep(.main-menu-link), .main-nation-menu.dark-label :deep(.main-menu-link),
.main-nation-menu.dark-label .main-menu-button { .main-nation-menu.dark-label .main-menu-button {
color: #000; --legacy-button-color: #000;
} }
.nation-menu-split { .nation-menu-split {
@@ -106,7 +109,11 @@ const isActive = (link: MainNavigationLinkItem) => link.highlightStage === props
width: 28px; width: 28px;
padding: 0; padding: 0;
border-left-width: 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 { .menu-caret {
@@ -8,11 +8,13 @@ const props = withDefaults(
enabled?: boolean; enabled?: boolean;
compact?: boolean; compact?: boolean;
active?: boolean; active?: boolean;
lumenVariant?: 'navigation' | 'lumen';
}>(), }>(),
{ {
enabled: true, enabled: true,
compact: false, compact: false,
active: 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 label = computed(() => (props.compact ? (props.link.compactLabel ?? props.link.label) : props.link.label));
const rel = computed(() => (props.link.newTab ? 'noopener noreferrer' : undefined)); const rel = computed(() => (props.link.newTab ? 'noopener noreferrer' : undefined));
const lumenClasses = computed(() =>
props.lumenVariant ? ['legacy-button', `legacy-button--${props.lumenVariant}`] : []
);
</script> </script>
<template> <template>
<RouterLink <RouterLink
v-if="enabled && link.to" v-if="enabled && link.to"
class="main-menu-link" class="main-menu-link"
:class="{ highlight: active }" :class="[lumenClasses, { highlight: active }]"
:to="link.to" :to="link.to"
:target="link.newTab ? '_blank' : undefined" :target="link.newTab ? '_blank' : undefined"
:rel="rel" :rel="rel"
@@ -40,7 +45,7 @@ const rel = computed(() => (props.link.newTab ? 'noopener noreferrer' : undefine
<a <a
v-else-if="enabled && link.href" v-else-if="enabled && link.href"
class="main-menu-link" class="main-menu-link"
:class="{ highlight: active }" :class="[lumenClasses, { highlight: active }]"
:href="link.href" :href="link.href"
:target="link.newTab ? '_blank' : undefined" :target="link.newTab ? '_blank' : undefined"
:rel="rel" :rel="rel"
@@ -52,6 +57,7 @@ const rel = computed(() => (props.link.newTab ? 'noopener noreferrer' : undefine
<span <span
v-else v-else
class="main-menu-link disabled" class="main-menu-link disabled"
:class="lumenClasses"
role="link" role="link"
aria-disabled="true" aria-disabled="true"
:title="link.unavailableReason" :title="link.unavailableReason"
@@ -15,3 +15,12 @@ export const formatLocalTimeSeconds = (value: string | Date): string => {
.map((part) => String(part).padStart(2, '0')) .map((part) => String(part).padStart(2, '0'))
.join(':'); .join(':');
}; };
export const formatLocalDateTime = (value: string | Date): string => {
const parsed = value instanceof Date ? value : new Date(value);
if (!Number.isFinite(parsed.getTime())) return '-';
const date = [parsed.getFullYear(), parsed.getMonth() + 1, parsed.getDate()]
.map((part, index) => String(part).padStart(index === 0 ? 4 : 2, '0'))
.join('-');
return `${date} ${formatLocalTimeSeconds(parsed)}`;
};
@@ -256,6 +256,11 @@ const toExportedGeneral = (general: GeneralDraft): GeneralExport => ({
inheritBuff: { ...general.inheritBuff }, 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 () => { const initializeDefaults = async () => {
loading.value = true; loading.value = true;
error.value = null; error.value = null;
@@ -269,9 +274,9 @@ const initializeDefaults = async () => {
repeatCnt.value = 1; repeatCnt.value = 1;
seed.value = ''; seed.value = '';
const nationTypeDefault = context.nationTypes[0]?.key ?? 'che_중립'; const nationTypeDefault = resolveAvailableNationType(me?.nation?.typeCode);
attackerNation.type = me?.nation?.typeCode ?? nationTypeDefault; attackerNation.type = nationTypeDefault;
defenderNation.type = me?.nation?.typeCode ?? nationTypeDefault; defenderNation.type = nationTypeDefault;
attackerNation.level = me?.nation?.level ?? 0; attackerNation.level = me?.nation?.level ?? 0;
defenderNation.level = me?.nation?.level ?? 0; defenderNation.level = me?.nation?.level ?? 0;
@@ -304,10 +309,10 @@ const applyGameEnvironment = () => {
return; return;
} }
const me = gameDefaults.value; const me = gameDefaults.value;
const nationTypeDefault = options.value.nationTypes[0]?.key ?? 'che_중립'; const nationTypeDefault = resolveAvailableNationType(me?.nation?.typeCode);
year.value = options.value.world.currentYear; year.value = options.value.world.currentYear;
month.value = options.value.world.currentMonth; month.value = options.value.world.currentMonth;
attackerNation.type = me?.nation?.typeCode ?? nationTypeDefault; attackerNation.type = nationTypeDefault;
defenderNation.type = attackerNation.type; defenderNation.type = attackerNation.type;
attackerNation.level = me?.nation?.level ?? 0; attackerNation.level = me?.nation?.level ?? 0;
defenderNation.level = attackerNation.level; defenderNation.level = attackerNation.level;
@@ -326,7 +331,7 @@ const applyIndependentEnvironment = () => {
if (!options.value) { if (!options.value) {
return; return;
} }
const nationTypeDefault = options.value.nationTypes[0]?.key ?? 'che_중립'; const nationTypeDefault = resolveAvailableNationType();
year.value = options.value.world.startYear; year.value = options.value.world.startYear;
month.value = 1; month.value = 1;
seed.value = ''; seed.value = '';
@@ -740,13 +745,13 @@ const importBattle = (data: BattleExport) => {
month.value = data.month; month.value = data.month;
repeatCnt.value = data.repeatCnt; repeatCnt.value = data.repeatCnt;
attackerNation.type = data.attackerNation.type; attackerNation.type = resolveAvailableNationType(data.attackerNation.type);
attackerNation.level = data.attackerNation.level; attackerNation.level = data.attackerNation.level;
attackerNation.tech = Math.floor(data.attackerNation.tech / 1000); attackerNation.tech = Math.floor(data.attackerNation.tech / 1000);
attackerNation.isCapital = data.attackerNation.capital === 1; attackerNation.isCapital = data.attackerNation.capital === 1;
attackerCity.level = data.attackerCity.level; attackerCity.level = data.attackerCity.level;
defenderNation.type = data.defenderNation.type; defenderNation.type = resolveAvailableNationType(data.defenderNation.type);
defenderNation.level = data.defenderNation.level; defenderNation.level = data.defenderNation.level;
defenderNation.tech = Math.floor(data.defenderNation.tech / 1000); defenderNation.tech = Math.floor(data.defenderNation.tech / 1000);
defenderNation.isCapital = data.defenderNation.capital === 3; defenderNation.isCapital = data.defenderNation.capital === 3;
+12 -31
View File
@@ -143,7 +143,7 @@ watch(
</h1> </h1>
<div class="game-shell__actions desktop-action-controls"> <div class="game-shell__actions desktop-action-controls">
<button <button
class="game-shell__action toggle" class="game-shell__action toggle legacy-button legacy-button--navigation"
:class="{ active: realtimeEnabled }" :class="{ active: realtimeEnabled }"
type="button" type="button"
@click="dashboard.setRealtimeEnabled(!realtimeEnabled)" @click="dashboard.setRealtimeEnabled(!realtimeEnabled)"
@@ -151,7 +151,7 @@ watch(
실시간 동기화: {{ realtimeLabel }} 실시간 동기화: {{ realtimeLabel }}
</button> </button>
<button <button
class="game-shell__action game-shell__action--navigation" class="game-shell__action legacy-button legacy-button--navigation"
type="button" type="button"
:disabled="refreshing" :disabled="refreshing"
:aria-busy="refreshing" :aria-busy="refreshing"
@@ -159,7 +159,13 @@ watch(
> >
</button> </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> </div>
</header> </header>
@@ -475,10 +481,6 @@ button {
background-image: var(--sammo-texture-walnut); background-image: var(--sammo-texture-walnut);
} }
.toggle.active {
background: rgba(201, 164, 90, 0.2);
}
.game-shell__action.highlight { .game-shell__action.highlight {
border-color: #f39c12; border-color: #f39c12;
background: #8a5b13; background: #8a5b13;
@@ -750,29 +752,8 @@ button {
margin-top: 31px; 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 { .desktop-action-controls .game-shell__action {
border-color: #004f28; font-weight: 400;
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;
} }
.placeholder { .placeholder {
@@ -793,8 +774,8 @@ button {
} }
.desktop-action-controls .game-shell__action { .desktop-action-controls .game-shell__action {
padding-right: 8px; padding-right: 4px;
padding-left: 8px; padding-left: 4px;
} }
.main-page { .main-page {
@@ -2,6 +2,7 @@ import assert from 'node:assert/strict';
import test from 'node:test'; import test from 'node:test';
import { import {
formatLocalDateTime,
formatLocalTimeSeconds, formatLocalTimeSeconds,
formatSeoulDateTime, formatSeoulDateTime,
formatSeoulHourMinute, formatSeoulHourMinute,
@@ -29,3 +30,12 @@ void test('formats an ISO instant with the client local clock', () => {
assert.equal(formatLocalTimeSeconds(instant.toISOString()), expected); assert.equal(formatLocalTimeSeconds(instant.toISOString()), expected);
assert.equal(formatLocalTimeSeconds('invalid'), '-'); assert.equal(formatLocalTimeSeconds('invalid'), '-');
}); });
void test('formats an autorun expiry instant with the client local date and seconds', () => {
const instant = new Date('2026-08-13T00:07:06.713Z');
const expectedDate = [instant.getFullYear(), instant.getMonth() + 1, instant.getDate()]
.map((part, index) => String(part).padStart(index === 0 ? 4 : 2, '0'))
.join('-');
assert.equal(formatLocalDateTime(instant), `${expectedDate} ${formatLocalTimeSeconds(instant)}`);
assert.equal(formatLocalDateTime('invalid'), '-');
});
@@ -0,0 +1,174 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { formatReservedCommandBrief } from '../src/components/command/reservedCommandBrief.ts';
import type { CommandAvailability, CommandTable } from '../src/components/command/types.ts';
const command = (key: string, name: string): CommandAvailability => ({
key,
name,
reqArg: false,
status: 'available',
possible: true,
inputFields: [],
});
const generalNames: Array<[string, string]> = [
['휴식', '휴식'],
['che_이동', '이동'],
['che_강행', '강행'],
['che_출병', '출병'],
['che_첩보', '첩보'],
['che_화계', '화계'],
['che_선동', '선동'],
['che_탈취', '탈취'],
['che_파괴', '파괴'],
['che_임관', '임관'],
['che_등용', '등용'],
['che_장수대상임관', '장수 대상 임관'],
['che_선양', '선양'],
['che_랜덤임관', '랜덤 임관'],
['che_건국', '건국'],
['che_무작위건국', '무작위 건국'],
['che_군량매매', '군량 매매'],
['che_헌납', '헌납'],
['che_증여', '증여'],
['che_징병', '징병'],
['che_모병', '모병'],
['che_숙련전환', '숙련 전환'],
['che_장비매매', '장비 매매'],
];
const nationNames: Array<[string, string]> = [
['휴식', '휴식'],
['che_발령', '발령'],
['che_부대탈퇴지시', '부대 탈퇴 지시'],
['che_포상', '포상'],
['che_몰수', '몰수'],
['che_물자원조', '물자 원조'],
['che_불가침제의', '불가침 제의'],
['che_불가침파기제의', '불가침 파기 제의'],
['che_종전제의', '종전 제의'],
['che_선전포고', '선전포고'],
['che_급습', '급습'],
['che_이호경식', '이호경식'],
['che_천도', '천도'],
['che_백성동원', '백성 동원'],
['che_허보', '허보'],
['che_수몰', '수몰'],
['che_초토화', '초토화'],
['che_증축', '증축'],
['che_감축', '감축'],
['che_국기변경', '국기 변경'],
['che_국호변경', '국호 변경'],
['che_피장파장', '피장파장'],
['cr_인구이동', '인구이동'],
];
const table: CommandTable = {
general: [{ category: '전체', values: generalNames.map(([key, name]) => command(key, name)) }],
nation: [{ category: '전체', values: nationNames.map(([key, name]) => command(key, name)) }],
inputOptions: {
cities: [
{ value: 2, label: '단양 (오 · 회계)' },
{ value: 3, label: '업 (위)' },
],
nations: [
{ value: 2, label: '오' },
{ value: 3, label: '위' },
],
generals: [
{ value: 8, label: '손권 (오 · 단양)' },
{ value: 9, label: '조조 (위 · 업)' },
],
crewTypes: [{ value: 1100, label: '보병' }],
armTypes: [
{ value: 0, label: '보병' },
{ value: 1, label: '궁병' },
],
nationTypes: [],
colors: [],
items: {
horse: [
{ value: 'None', label: '판매/해제' },
{ value: 'che_명마_01_노기', label: '노기(+1)' },
],
},
recruitment: null,
},
};
test('Ref getBrief를 상속하는 출병·계략·모병까지 실제 인자 요약으로 표시한다', () => {
assert.equal(formatReservedCommandBrief('general', 'che_출병', { destCityId: 2 }, table), '【단양】으로 출병');
assert.equal(formatReservedCommandBrief('general', 'che_화계', { destCityId: 3 }, table), '【업】에 화계실행');
assert.equal(formatReservedCommandBrief('general', 'che_선동', { destCityId: 2 }, table), '【단양】에 선동실행');
assert.equal(
formatReservedCommandBrief('general', 'che_모병', { crewType: 1100, amount: 2400 }, table),
'【보병】 2400명 모병'
);
});
test('개인·인사·국가 명령의 Ref brief 변형을 보존한다', () => {
const cases: Array<[string, Record<string, unknown>, string]> = [
['che_이동', { destCityId: 3 }, '【업】으로 이동'],
['che_강행', { destCityId: 2 }, '【단양】으로 강행'],
['che_첩보', { destCityId: 2 }, '【단양】에 첩보 실행'],
['che_임관', { destNationId: 2 }, '【오】로 임관'],
['che_등용', { destGeneralId: 9 }, '【조조】를 등용'],
['che_장수대상임관', { destGeneralId: 8 }, '【손권】을 따라 임관'],
['che_선양', { destGeneralId: 8 }, '【손권】에게 선양'],
['che_랜덤임관', {}, '무작위 국가로 임관'],
['che_건국', { nationName: '촉한' }, '【촉한】을 건국'],
['che_무작위건국', { nationName: '오' }, '【오】를 무작위 도시에 건국'],
['che_군량매매', { amount: 500, buyRice: true }, '군량 500을 구입'],
['che_헌납', { amount: 300, isGold: false }, '쌀 300을 헌납'],
['che_증여', { destGeneralId: 8, amount: 200, isGold: true }, '【손권】에게 금 200을 증여'],
['che_징병', { crewType: 1100, amount: 3200 }, '【보병】 3200명 징병'],
['che_숙련전환', { srcArmType: 0, destArmType: 1 }, '【보병】숙련을 【궁병】숙련으로 전환'],
['che_장비매매', { itemType: 'horse', itemCode: 'None' }, '명마를 판매'],
['che_장비매매', { itemType: 'horse', itemCode: 'che_명마_01_노기' }, '【노기(+1)】를 구입'],
];
for (const [action, args, expected] of cases) {
assert.equal(formatReservedCommandBrief('general', action, args, table), expected, action);
}
});
test('국가 명령의 도시·국가·장수·자원 인자를 Ref brief로 표시한다', () => {
const cases: Array<[string, Record<string, unknown>, string]> = [
['che_발령', { destGeneralId: 8, destCityId: 3 }, '【손권】【업】으로 발령'],
['che_부대탈퇴지시', { destGeneralId: 8 }, '【손권】부대 탈퇴 지시'],
['che_포상', { destGeneralId: 8, amount: 12000, isGold: true }, '【손권】 금 12,000 포상'],
['che_몰수', { destGeneralId: 9, amount: 3400, isGold: false }, '【조조】 쌀 3,400 몰수'],
[
'che_물자원조',
{ destNationId: 2, amountList: [12000, 34000] },
'【오】에게 국고 12,000 병량 34,000 물자 원조',
],
['che_불가침제의', { destNationId: 2, year: 190, month: 8 }, '【오】에게 190년 8월까지 불가침 제의'],
['che_불가침파기제의', { destNationId: 2 }, '【오】에게 불가침 파기 제의'],
['che_종전제의', { destNationId: 3 }, '【위】에게 종전 제의'],
['che_선전포고', { destNationId: 2 }, '【오】에 선전포고'],
['che_급습', { destNationId: 3 }, '【위】에 급습'],
['che_이호경식', { destNationId: 2 }, '【오】에 이호경식'],
['che_천도', { destCityId: 2 }, '【단양】으로 천도'],
['che_백성동원', { destCityId: 3 }, '【업】에 백성 동원'],
['che_허보', { destCityId: 2 }, '【단양】에 허보'],
['che_수몰', { destCityId: 2 }, '【단양】을 수몰'],
['che_초토화', { destCityId: 3 }, '【업】을 초토화'],
['che_증축', {}, '수도를 증축'],
['che_감축', {}, '수도를 감축'],
['che_국기변경', { colorType: 2 }, '【국기】를 변경'],
['che_국호변경', { nationName: '진' }, '국호를 【진】으로 변경'],
['che_피장파장', { destNationId: 2, commandType: 'che_선전포고' }, '【오】에 【선전포고】 피장파장'],
['cr_인구이동', { destCityId: 2, amount: 12000 }, '【단양】으로 12,000명 인구이동'],
];
for (const [action, args, expected] of cases) {
assert.equal(formatReservedCommandBrief('nation', action, args, table), expected, action);
}
});
test('Ref가 getBrief를 재정의하지 않은 명령은 실제 표시명을 유지한다', () => {
assert.equal(formatReservedCommandBrief('general', '휴식', {}, table), '휴식');
assert.equal(formatReservedCommandBrief('general', 'che_훈련', {}, table), '훈련');
assert.equal(formatReservedCommandBrief('nation', 'che_필사즉생', {}, table), '필사즉생');
});
+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 Hover and active use the Ref Lumen bottom-border movement rather than an
unrelated brightness filter. 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, Only layout belongs in the SFC: width, grid column, fixed-height compensation,
margins required by the page, and breakpoint-specific placement. Color base margins required by the page, and breakpoint-specific placement. Color base
variables may be supplied by the owner for dynamic nation/scenario colors, but 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]; 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 NationTraitModule = TraitModule;
export type NationTraitImporter = () => Promise<TraitModuleExport>; 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 { getLegacyStringWidth } from '@sammo-ts/logic/troop/management.js';
import { z } from 'zod'; import { z } from 'zod';
@@ -38,14 +38,12 @@ export const NATION_COLORS = [
'#A9A9A9', '#A9A9A9',
] as const; ] as const;
const SELECTABLE_NATION_TYPES = new Set<string>(NATION_TRAIT_KEYS.filter((key) => key !== 'che_중립'));
export const FOUNDING_ARGS_SCHEMA = z.object({ export const FOUNDING_ARGS_SCHEMA = z.object({
nationName: z nationName: z
.string() .string()
.min(1) .min(1)
.refine((value) => getLegacyStringWidth(value) <= 18), .refine((value) => getLegacyStringWidth(value) <= 18),
nationType: z.string().refine((value) => SELECTABLE_NATION_TYPES.has(value)), nationType: z.string().refine(isAvailableNationTraitKey),
colorType: z colorType: z
.number() .number()
.int() .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);
});
});