merge: 최종 최신 main을 지도 계절 전환에 반영한다

This commit is contained in:
2026-08-21 16:44:45 +00:00
37 changed files with 930 additions and 405 deletions
+3 -2
View File
@@ -175,13 +175,14 @@ export const worldRouter = router({
const turns = generalIds.length const turns = generalIds.length
? await ctx.db.generalTurn.findMany({ ? await ctx.db.generalTurn.findMany({
where: { generalId: { in: generalIds }, turnIdx: { lt: 5 } }, where: { generalId: { in: generalIds }, turnIdx: { lt: 5 } },
select: { generalId: true, turnIdx: true, actionCode: true, arg: true },
orderBy: [{ generalId: 'asc' }, { turnIdx: 'asc' }], orderBy: [{ generalId: 'asc' }, { turnIdx: 'asc' }],
}) })
: []; : [];
const turnMap = new Map<number, string[]>(); const turnMap = new Map<number, Array<{ action: string; args: unknown }>>();
for (const turn of turns) { for (const turn of turns) {
const list = turnMap.get(turn.generalId) ?? []; const list = turnMap.get(turn.generalId) ?? [];
list[turn.turnIdx] = turn.actionCode; list[turn.turnIdx] = { action: turn.actionCode, args: turn.arg };
turnMap.set(turn.generalId, list); turnMap.set(turn.generalId, list);
} }
const nationMap = new Map(nations.map((item) => [item.id, item])); const nationMap = new Map(nations.map((item) => [item.id, item]));
@@ -0,0 +1,175 @@
import { describe, expect, it, vi } from 'vitest';
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
import type { RedisConnector } from '@sammo-ts/infra';
import { RedisAccessTokenStore } from '../src/auth/accessTokenStore.js';
import { InMemoryFlushStore } from '../src/auth/flushStore.js';
import type { DatabaseClient, GameApiContext, GeneralRow } from '../src/context.js';
import { appRouter } from '../src/router.js';
vi.mock('../src/maps/mapLayout.js', () => ({
loadMapLayout: vi.fn(async () => ({
mapName: 'che',
cityList: [{ id: 1, name: '업', level: 8, region: 1, x: 0, y: 0, path: [] }],
regionMap: { 1: '하북' },
levelMap: { 8: '특' },
})),
}));
vi.mock('@sammo-ts/game-engine/scenario/unitSetLoader.js', () => ({
loadUnitSetDefinitionByName: vi.fn(async () => ({ crewTypes: [{ id: 1, name: '보병' }] })),
}));
const now = new Date('2026-01-01T01:02:00Z');
const general = (overrides: Partial<GeneralRow> = {}): GeneralRow => ({
id: 1,
userId: 'u1',
name: '장수',
nationId: 1,
cityId: 1,
troopId: 0,
npcState: 0,
affinity: null,
bornYear: 180,
deadYear: 300,
picture: null,
imageServer: 0,
leadership: 70,
strength: 60,
intel: 50,
injury: 0,
experience: 900,
dedication: 100,
officerLevel: 1,
gold: 1000,
rice: 2000,
crew: 300,
crewTypeId: 1,
train: 90,
atmos: 90,
weaponCode: 'None',
bookCode: 'None',
horseCode: 'None',
itemCode: 'None',
turnTime: now,
recentWarTime: null,
age: 20,
startAge: 20,
personalCode: 'None',
specialCode: 'None',
special2Code: 'None',
lastTurn: {},
meta: { defence_train: 80 },
penalty: {},
createdAt: now,
updatedAt: now,
...overrides,
});
const token = (): GameSessionTokenPayload => ({
version: 1,
profile: 'che:default',
issuedAt: now.toISOString(),
expiresAt: new Date(now.getTime() + 86_400_000).toISOString(),
sessionId: 'session-1',
user: { id: 'u1', username: 'u1', displayName: '장수', roles: [] },
sanctions: {},
});
const fixture = (authenticated = true) => {
const actor = general();
const npc = general({ id: 2, userId: null, name: 'NPC', npcState: 2 });
const city = {
id: 1,
name: '업',
nationId: 1,
level: 8,
region: 1,
population: 150_000,
populationMax: 620_500,
agriculture: 1_000,
agricultureMax: 12_500,
commerce: 1_000,
commerceMax: 11_300,
security: 1_000,
securityMax: 10_000,
trust: 80,
trade: 100,
defence: 5_000,
defenceMax: 11_700,
wall: 5_000,
wallMax: 12_200,
};
const db = {
general: {
findFirst: vi.fn(async () => actor),
findMany: vi.fn(async ({ where }: { where: Record<string, unknown> }) => {
if ('cityId' in where) return [actor, npc];
if ('officerLevel' in where) return [];
if ('nationId' in where) return [actor, npc];
return [];
}),
},
nation: {
findUnique: vi.fn(async () => ({ id: 1, name: '위', color: '#008000', level: 1, meta: {} })),
findMany: vi.fn(async () => [{ id: 1, name: '위', color: '#008000', level: 1, meta: {} }]),
},
city: { findMany: vi.fn(async () => [city]) },
worldState: {
findFirst: vi.fn(async () => ({ config: {}, meta: { turntime: '2026-01-01 10:02:00' } })),
},
generalTurn: {
findMany: vi.fn(async () => [
{
generalId: 1,
turnIdx: 0,
actionCode: 'che_징병',
arg: { crewType: 1, amount: 300 },
},
]),
},
};
const redis = { get: vi.fn(async () => null), set: vi.fn(async () => null) } as unknown as RedisConnector['client'];
const context: GameApiContext = {
db: db as unknown as DatabaseClient,
redis,
turnDaemon: {} as GameApiContext['turnDaemon'],
battleSim: {} as GameApiContext['battleSim'],
profile: { id: 'che', scenario: 'default', name: 'che:default' },
auth: authenticated ? token() : null,
uploadDir: 'uploads',
uploadPath: '/uploads',
uploadPublicUrl: null,
accessTokenStore: new RedisAccessTokenStore(redis, 'che:default'),
flushStore: new InMemoryFlushStore(),
gameTokenSecret: 'secret',
};
return { caller: appRouter.createCaller(context), db };
};
describe('world current-city command projection', () => {
it('returns the first five own-user turns as canonical action and args while redacting NPC turns', async () => {
const { caller, db } = fixture();
const result = await caller.world.getCurrentCity();
expect(result.generals.find((entry) => entry.id === 1)?.turns).toEqual([
{ action: 'che_징병', args: { crewType: 1, amount: 300 } },
]);
expect(result.generals.find((entry) => entry.id === 2)?.turns).toEqual([]);
expect(db.generalTurn.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { generalId: { in: [1] }, turnIdx: { lt: 5 } },
select: { generalId: true, turnIdx: true, actionCode: true, arg: true },
})
);
});
it('keeps authentication and input validation in front of the city read model', async () => {
await expect(fixture(false).caller.world.getCurrentCity()).rejects.toMatchObject({ code: 'UNAUTHORIZED' });
await expect(fixture().caller.world.getCurrentCity({ cityId: 0 })).rejects.toMatchObject({
code: 'BAD_REQUEST',
});
});
});
+3 -3
View File
@@ -349,7 +349,7 @@ describeDb('scenario database seed', () => {
databaseUrl, databaseUrl,
now: new Date('2030-01-01T00:00:00Z'), now: new Date('2030-01-01T00:00:00Z'),
installOptions: { installOptions: {
turnTermMinutes: 60, turnTermMinutes: 3,
sync: false, sync: false,
fiction: 1, fiction: 1,
extend: false, extend: false,
@@ -383,7 +383,7 @@ describeDb('scenario database seed', () => {
if (!worldState) { if (!worldState) {
return; return;
} }
expect(worldState.tickSeconds).toBe(3600); expect(worldState.tickSeconds).toBe(180);
expect(worldState.currentMonth).toBe(1); expect(worldState.currentMonth).toBe(1);
const config = (worldState.config ?? {}) as Record<string, unknown>; const config = (worldState.config ?? {}) as Record<string, unknown>;
@@ -395,7 +395,7 @@ describeDb('scenario database seed', () => {
expect(meta.develcost).toBe( expect(meta.develcost).toBe(
(worldState.currentYear - (scenario.startYear ?? worldState.currentYear) + 10) * 2 (worldState.currentYear - (scenario.startYear ?? worldState.currentYear) + 10) * 2
); );
expect(meta.killturn).toBe(80); expect(meta.killturn).toBe(1600);
const autorun = (meta.autorun_user ?? {}) as Record<string, unknown>; const autorun = (meta.autorun_user ?? {}) as Record<string, unknown>;
const autorunOptions = (autorun.options ?? {}) as Record<string, unknown>; const autorunOptions = (autorun.options ?? {}) as Record<string, unknown>;
expect(autorunOptions.develop).toBe(true); expect(autorunOptions.develop).toBe(true);
+48 -4
View File
@@ -210,7 +210,44 @@ const install = async (
} }
if (operation === 'general.me') return response(generalContext); if (operation === 'general.me') return response(generalContext);
if (operation === 'world.getMap') return response(mapFixture); if (operation === 'world.getMap') return response(mapFixture);
if (operation === 'turns.getCommandTable') return response({ general: [], nation: [] }); if (operation === 'turns.getCommandTable')
return response({
general: [
{
category: '군사',
values: [
{
key: 'che_징병',
name: '징병',
reqArg: true,
status: 'needsInput',
possible: true,
inputFields: [],
},
{
key: 'che_화계',
name: '화계',
reqArg: true,
status: 'needsInput',
possible: true,
inputFields: [],
},
],
},
],
nation: [],
inputOptions: {
cities: [{ value: 1, label: '업 (아국)' }],
nations: [],
generals: [],
crewTypes: [{ value: 1, label: '보병' }],
armTypes: [],
nationTypes: [],
colors: [],
items: {},
recruitment: null,
},
});
if (operation === 'turns.reserved.getGeneral' || operation === 'turns.reserved.getNation') { if (operation === 'turns.reserved.getGeneral' || operation === 'turns.reserved.getNation') {
return response({ turns: [], revision: 0 }); return response({ turns: [], revision: 0 });
} }
@@ -373,7 +410,12 @@ const install = async (
crew: 500, crew: 500,
train: 90, train: 90,
atmos: 90, atmos: 90,
turns: denseCurrentCity ? ['징병', '훈련'] : ['징병'], turns: denseCurrentCity
? [
{ action: 'che_징병', args: { crewType: 1, amount: 300 } },
{ action: 'che_화계', args: { destCityId: 1 } },
]
: [{ action: 'che_징병', args: { crewType: 1, amount: 300 } }],
}, },
...(denseCurrentCity ...(denseCurrentCity
? Array.from({ length: 12 }, (_, index) => ({ ? Array.from({ length: 12 }, (_, index) => ({
@@ -1082,8 +1124,10 @@ test('current-city wraps dense general names and only shrinks reserved turns', a
const rows = page.locator('.generals tbody tr'); const rows = page.locator('.generals tbody tr');
const reservedTurns = rows.nth(0).locator('.turns'); const reservedTurns = rows.nth(0).locator('.turns');
const npcTurns = rows.nth(1).locator('.turns'); const npcTurns = rows.nth(1).locator('.turns');
await expect(reservedTurns).toContainText('1 : 징병'); await expect(reservedTurns).toContainText('1 : 【보병】 300명 징병');
await expect(reservedTurns).toContainText('2 : 훈련'); await expect(reservedTurns).toContainText('2 : 【업】에 화계실행');
await expect(reservedTurns.locator('.turn-line').nth(0)).toHaveAttribute('title', '【보병】 300명 징병');
await expect(reservedTurns.locator('.turn-line').nth(1)).toHaveAttribute('title', '【업】에 화계실행');
await expect(reservedTurns).toHaveClass(/turns--reserved/); await expect(reservedTurns).toHaveClass(/turns--reserved/);
await expect(npcTurns).toHaveText('NPC 장수'); await expect(npcTurns).toHaveText('NPC 장수');
await expect(npcTurns).not.toHaveClass(/turns--reserved/); await expect(npcTurns).not.toHaveClass(/turns--reserved/);
+37 -1
View File
@@ -2218,7 +2218,43 @@ test('main cards and command input stay inside their Ref-sized grid slots', asyn
await selectedMenu.evaluate((element) => ((element as HTMLDetailsElement).open = false)); await selectedMenu.evaluate((element) => ((element as HTMLDetailsElement).open = false));
await expect(selectedMenu).not.toHaveAttribute('open', ''); await expect(selectedMenu).not.toHaveAttribute('open', '');
await page.locator('[data-main-target="commands"] .select-command').click(); const selectCommand = page.locator('[data-main-target="commands"] .select-command');
await expect(selectCommand).toHaveClass(/legacy-button--info/u);
await page.mouse.move(1, 1);
const measureSelectCommand = () =>
selectCommand.evaluate((element) => {
const rect = element.getBoundingClientRect();
const style = getComputedStyle(element);
return {
top: rect.top,
bottom: rect.bottom,
height: rect.height,
marginTop: style.marginTop,
borderBottomWidth: style.borderBottomWidth,
borderRadius: style.borderRadius,
backgroundColor: style.backgroundColor,
};
});
const selectDefault = await measureSelectCommand();
expect(selectDefault).toMatchObject({
height: 34,
marginTop: '0px',
borderBottomWidth: '4px',
borderRadius: '5.25px',
backgroundColor: 'rgb(52, 152, 219)',
});
await selectCommand.hover();
const selectHover = await measureSelectCommand();
expect(selectHover).toMatchObject({ height: 33, marginTop: '1px', borderBottomWidth: '3px' });
expect(selectHover.bottom).toBeCloseTo(selectDefault.bottom, 2);
const selectBox = await selectCommand.boundingBox();
if (!selectBox) throw new Error('select command control is not measurable');
await page.mouse.move(selectBox.x + selectBox.width / 2, selectBox.y + selectBox.height / 2);
await page.mouse.down();
const selectActive = await measureSelectCommand();
expect(selectActive).toMatchObject({ height: 32, marginTop: '2px', borderBottomWidth: '2px' });
expect(selectActive.bottom).toBeCloseTo(selectDefault.bottom, 2);
await page.mouse.up();
const picker = page.getByTestId('command-picker'); const picker = page.getByTestId('command-picker');
await expect(picker).toBeVisible(); await expect(picker).toBeVisible();
// The trigger can end up directly above a newly opened category button. // The trigger can end up directly above a newly opened category button.
@@ -205,6 +205,86 @@ test('nation generals keeps the 1000px legacy grid and redacted member columns',
await expect(page.locator('#nation-general-list')).toContainText('?'); await expect(page.locator('#nation-general-list')).toContainText('?');
}); });
test('nation generals top controls share fixed Lumen state geometry on desktop and mobile', async ({
page,
}, testInfo) => {
await install(page);
const evidence: Record<string, unknown> = {};
for (const viewport of [
{ width: 1200, height: 900 },
{ width: 500, height: 900 },
]) {
await page.setViewportSize(viewport);
await page.goto('nation/generals');
await expect(page.locator('#nation-general-list')).toBeVisible();
const controls = [
page.getByRole('button', { name: '돌아가기' }),
page.getByRole('button', { name: '갱신' }),
page.getByRole('button', { name: '보기 모드⌄' }),
page.getByRole('button', { name: '열 선택⌄' }),
];
const viewportEvidence: Record<string, unknown> = {};
for (const control of controls) {
const label = (await control.textContent())?.trim() ?? 'unknown';
await expect(control).toHaveClass(/legacy-button--fixed-height/u);
const measure = () =>
control.evaluate((element) => {
const rect = element.getBoundingClientRect();
const style = getComputedStyle(element);
return {
top: rect.top,
bottom: rect.bottom,
height: rect.height,
marginTop: style.marginTop,
borderBottomWidth: style.borderBottomWidth,
borderRadius: style.borderRadius,
backgroundColor: style.backgroundColor,
fontFamily: style.fontFamily,
fontSize: style.fontSize,
};
});
await page.mouse.move(viewport.width - 1, viewport.height - 1);
const base = await measure();
expect(base).toMatchObject({
height: 32,
marginTop: '0px',
borderBottomWidth: '4px',
borderRadius: '5.25px',
fontSize: '14px',
});
expect(base.fontFamily).toContain('Pretendard');
await control.hover();
const hover = await measure();
expect(hover).toMatchObject({ height: 31, marginTop: '1px', borderBottomWidth: '3px' });
expect(hover.bottom).toBeCloseTo(base.bottom, 2);
const box = await control.boundingBox();
if (!box) throw new Error(`${label} control is not measurable`);
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
await page.mouse.down();
const active = await measure();
expect(active).toMatchObject({ height: 30, marginTop: '2px', borderBottomWidth: '2px' });
expect(active.bottom).toBeCloseTo(base.bottom, 2);
await page.mouse.move(viewport.width - 1, viewport.height - 1);
await page.mouse.up();
viewportEvidence[label] = { default: base, hover, active };
}
evidence[`${viewport.width}x${viewport.height}`] = viewportEvidence;
await page.screenshot({
path: testInfo.outputPath(`nation-general-buttons-${viewport.width}.png`),
fullPage: true,
});
}
await testInfo.attach('nation-general-button-geometry', {
body: JSON.stringify(evidence, null, 2),
contentType: 'application/json',
});
});
test('nation generals restores Ref group, saved view, sort, and Korean search behavior', async ({ page }, testInfo) => { test('nation generals restores Ref group, saved view, sort, and Korean search behavior', async ({ page }, testInfo) => {
await install(page); await install(page);
await page.setViewportSize({ width: 1200, height: 900 }); await page.setViewportSize({ width: 1200, height: 900 });
@@ -405,7 +485,10 @@ test('secret office renders five Ref-style command briefs and the forbidden erro
'5 : 휴식', '5 : 휴식',
]); ]);
await expect(commandRows.nth(2)).toHaveAttribute('title', '【다른장수】에게 쌀 200을 증여'); await expect(commandRows.nth(2)).toHaveAttribute('title', '【다른장수】에게 쌀 200을 증여');
const geometry = await page.locator('#secret-general-list .turns').first().evaluate((element) => { const geometry = await page
.locator('#secret-general-list .turns')
.first()
.evaluate((element) => {
const rect = element.getBoundingClientRect(); const rect = element.getBoundingClientRect();
const style = getComputedStyle(element); const style = getComputedStyle(element);
return { return {
@@ -395,7 +395,10 @@ test('join refresh shows the assigned preliminary group immediately with accessi
await refresh.focus(); await refresh.focus();
await expect(refresh).toBeFocused(); await expect(refresh).toBeFocused();
await refresh.hover(); await refresh.hover();
await expect(refresh).toHaveCSS('filter', 'brightness(1.25)'); await expect(refresh).toHaveCSS('filter', 'none');
await expect(refresh).toHaveCSS('height', '43px');
await expect(refresh).toHaveCSS('margin-top', '1px');
await expect(refresh).toHaveCSS('border-bottom-width', '3px');
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(390); expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(390);
}); });
@@ -602,7 +605,11 @@ test('mobile betting rankings use tabs and keep dedicated icons beside general n
await expect(dialog.getByText('예상 환수금 280')).toBeVisible(); await expect(dialog.getByText('예상 환수금 280')).toBeVisible();
await dialog.getByLabel('베팅 금액').selectOption('50'); await dialog.getByLabel('베팅 금액').selectOption('50');
await expect(dialog.getByText('예상 환수금 1,400')).toBeVisible(); await expect(dialog.getByText('예상 환수금 1,400')).toBeVisible();
await persistScreenshot(page, 'tournament-betting-dialog-mobile', testInfo.outputPath('betting-dialog-mobile.webp')); await persistScreenshot(
page,
'tournament-betting-dialog-mobile',
testInfo.outputPath('betting-dialog-mobile.webp')
);
await dialog.getByRole('button', { name: '베팅 등록' }).click(); await dialog.getByRole('button', { name: '베팅 등록' }).click();
await expect(dialog).not.toBeVisible(); await expect(dialog).not.toBeVisible();
await expect(page.getByRole('status')).toHaveText('베팅이 등록되었습니다.'); await expect(page.getByRole('status')).toHaveText('베팅이 등록되었습니다.');
@@ -144,7 +144,7 @@
/* /*
* Ref Bootstrap 5.2 + Lumen button family. This class owns the common raised * Ref Bootstrap 5.2 + Lumen button family. This class owns the common raised
* edge and pressed movement. Semantic modifiers below only select face, edge, * edge and pressed movement. Semantic modifiers below only select face, edge,
* and text colors; width and fixed-height compensation stay with the owner. * and text colors; width and the optional fixed-height value stay with the owner.
* Existing semantic modifiers also opt in for backward compatibility. * Existing semantic modifiers also opt in for backward compatibility.
*/ */
.legacy-button:is( .legacy-button:is(
@@ -173,6 +173,15 @@
vertical-align: middle; vertical-align: middle;
} }
/*
* Top bars and other fixed rows keep their owner-provided height while using
* the same Lumen edge movement. Shrinking the box with the edge keeps its
* bottom coordinate fixed instead of moving the whole control down.
*/
.legacy-button.legacy-button--fixed-height {
height: var(--legacy-button-height);
}
.legacy-button.legacy-button--secondary { .legacy-button.legacy-button--secondary {
--legacy-button-bg: var(--sammo-button-secondary-bg); --legacy-button-bg: var(--sammo-button-secondary-bg);
--legacy-button-border: var(--sammo-button-secondary-border); --legacy-button-border: var(--sammo-button-secondary-border);
@@ -213,6 +222,10 @@
background: var(--legacy-button-bg); background: var(--legacy-button-bg);
} }
.legacy-button.legacy-button--fixed-height:not(:disabled, [aria-disabled='true']):is(:hover, [aria-expanded='true']) {
height: calc(var(--legacy-button-height) - 1px);
}
.legacy-button:is( .legacy-button:is(
.legacy-button--lumen, .legacy-button--lumen,
.legacy-button--primary, .legacy-button--primary,
@@ -244,6 +257,10 @@
box-shadow: none; box-shadow: none;
} }
.legacy-button.legacy-button--fixed-height:not(:disabled, [aria-disabled='true']):active {
height: calc(var(--legacy-button-height) - 2px);
}
.legacy-button:is( .legacy-button:is(
.legacy-button--lumen, .legacy-button--lumen,
.legacy-button--primary, .legacy-button--primary,
@@ -588,7 +588,13 @@ const clickOutsideMenu = (event: Event) => {
</button> </button>
</div> </div>
</details> </details>
<button type="button" class="select-command" @click="togglePicker()">명령 선택 </button> <button
type="button"
class="legacy-button legacy-button--info legacy-button--fixed-height select-command"
@click="togglePicker()"
>
명령 선택
</button>
</div> </div>
<div class="queue-area"> <div class="queue-area">
@@ -806,8 +812,7 @@ const clickOutsideMenu = (event: Event) => {
} }
.control-pad > button, .control-pad > button,
.clock, .clock,
.legacy-menu > summary, .legacy-menu > summary {
.select-command {
box-sizing: border-box; box-sizing: border-box;
min-height: 34px; min-height: 34px;
border: 0; border: 0;
@@ -822,6 +827,12 @@ const clickOutsideMenu = (event: Event) => {
cursor: pointer; cursor: pointer;
list-style: none; list-style: none;
} }
.select-command {
--legacy-button-height: 34px;
display: grid;
place-items: center;
padding: 4px;
}
.clock { .clock {
background: #345c85; background: #345c85;
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
@@ -1024,9 +1035,6 @@ const clickOutsideMenu = (event: Event) => {
grid-template-columns: 5fr 7fr; grid-template-columns: 5fr 7fr;
order: 1; order: 1;
} }
.advanced-actions > * {
border-radius: 0 !important;
}
.bottom-actions { .bottom-actions {
display: grid; display: grid;
grid-template-columns: repeat(3, 1fr); grid-template-columns: repeat(3, 1fr);
@@ -61,7 +61,7 @@ const onAction = (action: NonNullable<MainNavigationLinkItem['action']>) => {
> >
<div class="bottom-item"> <div class="bottom-item">
<button <button
class="bottom-trigger legacy-button legacy-button--navigation" class="bottom-trigger legacy-button legacy-button--navigation legacy-button--fixed-height"
type="button" type="button"
data-bottom-menu="global" data-bottom-menu="global"
:aria-expanded="openId === 'global'" :aria-expanded="openId === 'global'"
@@ -132,7 +132,7 @@ const onAction = (action: NonNullable<MainNavigationLinkItem['action']>) => {
<div class="bottom-item nation-bottom-item"> <div class="bottom-item nation-bottom-item">
<button <button
class="bottom-trigger nation-trigger legacy-button legacy-button--lumen" class="bottom-trigger nation-trigger legacy-button legacy-button--lumen legacy-button--fixed-height"
:style="{ :style="{
'--legacy-button-bg': nationMenuColor, '--legacy-button-bg': nationMenuColor,
'--legacy-button-border': 'color-mix(in srgb, var(--nation-menu-color) 90%, #000)', '--legacy-button-border': 'color-mix(in srgb, var(--nation-menu-color) 90%, #000)',
@@ -178,7 +178,7 @@ const onAction = (action: NonNullable<MainNavigationLinkItem['action']>) => {
<div class="bottom-item"> <div class="bottom-item">
<button <button
class="bottom-trigger quick-trigger legacy-button legacy-button--dark" class="bottom-trigger quick-trigger legacy-button legacy-button--dark legacy-button--fixed-height"
type="button" type="button"
data-bottom-menu="quick" data-bottom-menu="quick"
:aria-expanded="openId === 'quick'" :aria-expanded="openId === 'quick'"
@@ -221,7 +221,7 @@ const onAction = (action: NonNullable<MainNavigationLinkItem['action']>) => {
<div class="bottom-refresh-controls"> <div class="bottom-refresh-controls">
<button <button
class="bottom-trigger auto-refresh-trigger legacy-button legacy-button--navigation" class="bottom-trigger auto-refresh-trigger legacy-button legacy-button--navigation legacy-button--fixed-height"
:class="{ active: realtimeEnabled }" :class="{ active: realtimeEnabled }"
type="button" type="button"
data-bottom-menu="auto-refresh" data-bottom-menu="auto-refresh"
@@ -232,7 +232,7 @@ const onAction = (action: NonNullable<MainNavigationLinkItem['action']>) => {
<strong>{{ realtimeEnabled ? 'ON' : 'OFF' }}</strong> <strong>{{ realtimeEnabled ? 'ON' : 'OFF' }}</strong>
</button> </button>
<button <button
class="bottom-trigger manual-refresh-trigger legacy-button legacy-button--dark" class="bottom-trigger manual-refresh-trigger legacy-button legacy-button--dark legacy-button--fixed-height"
type="button" type="button"
data-bottom-menu="manual-refresh" data-bottom-menu="manual-refresh"
aria-label="직접 갱신" aria-label="직접 갱신"
@@ -279,9 +279,9 @@ const onAction = (action: NonNullable<MainNavigationLinkItem['action']>) => {
} }
.bottom-trigger { .bottom-trigger {
--legacy-button-height: 45px;
box-sizing: border-box; box-sizing: border-box;
width: 125px; width: 125px;
height: 45px;
padding: 6px 4px; padding: 6px 4px;
font-family: inherit; font-family: inherit;
font-size: 16px; font-size: 16px;
@@ -316,15 +316,6 @@ const onAction = (action: NonNullable<MainNavigationLinkItem['action']>) => {
line-height: 1; line-height: 1;
} }
.bottom-trigger.legacy-button:not(:disabled, [aria-disabled='true']):hover,
.bottom-trigger.legacy-button:not(:disabled, [aria-disabled='true'])[aria-expanded='true'] {
height: 44px;
}
.bottom-trigger.legacy-button:not(:disabled, [aria-disabled='true']):active {
height: 43px;
}
.dropup-caret { .dropup-caret {
display: inline-block; display: inline-block;
margin-left: 4px; margin-left: 4px;
@@ -14,6 +14,7 @@ defineProps<{
<button <button
type="button" type="button"
role="tab" role="tab"
class="legacy-button legacy-button--dark legacy-button--fixed-height"
:aria-selected="activePage === 'tournament'" :aria-selected="activePage === 'tournament'"
:class="{ active: activePage === 'tournament' }" :class="{ active: activePage === 'tournament' }"
@click="navigate" @click="navigate"
@@ -25,6 +26,7 @@ defineProps<{
<button <button
type="button" type="button"
role="tab" role="tab"
class="legacy-button legacy-button--dark legacy-button--fixed-height"
:aria-selected="activePage === 'betting'" :aria-selected="activePage === 'betting'"
:class="{ active: activePage === 'betting' }" :class="{ active: activePage === 'betting' }"
@click="navigate" @click="navigate"
@@ -34,7 +36,13 @@ defineProps<{
</RouterLink> </RouterLink>
</nav> </nav>
<RouterLink v-slot="{ navigate }" custom to="/"> <RouterLink v-slot="{ navigate }" custom to="/">
<button class="close-button" type="button" @click="navigate"> 닫기</button> <button
class="legacy-button legacy-button--navigation legacy-button--fixed-height close-button"
type="button"
@click="navigate"
>
닫기
</button>
</RouterLink> </RouterLink>
</div> </div>
</header> </header>
@@ -64,33 +72,21 @@ defineProps<{
gap: 4px; gap: 4px;
} }
button { button {
height: 44px; --legacy-button-height: 44px;
margin: 0; margin: 0;
border: 1px solid #666;
border-radius: 5.25px;
background: #444;
color: #fff;
font-size: 14px; font-size: 14px;
line-height: 18px;
cursor: pointer;
} }
.tournament-page-tabs button { .tournament-page-tabs button {
min-width: 72px; min-width: 72px;
padding: 10px 12px; padding: 10px 12px;
} }
.tournament-page-tabs button.active { .tournament-page-tabs button.active {
border-color: #f39c12; --legacy-button-bg: #8a5b13;
background: #8a5b13; --legacy-button-border: #704a0f;
} }
.close-button { .close-button {
width: 88px; width: 88px;
padding: 10px 16px; padding: 10px 16px;
border-color: #375a7f;
background: #375a7f;
}
button:hover,
button:focus {
filter: brightness(1.25);
} }
button:focus-visible { button:focus-visible {
outline: 2px solid #f39c12; outline: 2px solid #f39c12;
+31 -62
View File
@@ -197,20 +197,31 @@ onMounted(() => {
:class="activeTab === 'resource' ? 'resource-page' : 'unique-page'" :class="activeTab === 'resource' ? 'resource-page' : 'unique-page'"
> >
<header class="top-back-bar bg0"> <header class="top-back-bar bg0">
<button class="legacy-button close-button" type="button" @click="closeWindow"> 닫기</button> <button
<button class="legacy-button reload-button" type="button" :disabled="loading" @click="loadOverview"> class="legacy-button legacy-button--navigation legacy-button--fixed-height close-button"
type="button"
@click="closeWindow"
>
닫기
</button>
<button
class="legacy-button legacy-button--navigation legacy-button--fixed-height reload-button"
type="button"
:disabled="loading"
@click="loadOverview"
>
갱신 갱신
</button> </button>
<h1>{{ activeTab === 'resource' ? '경매장' : '유니크 경매장' }}</h1> <h1>{{ activeTab === 'resource' ? '경매장' : '유니크 경매장' }}</h1>
<button <button
class="legacy-button tab-button" class="legacy-button legacy-button--dark tab-button"
:aria-pressed="activeTab === 'resource'" :aria-pressed="activeTab === 'resource'"
@click="activeTab = 'resource'" @click="activeTab = 'resource'"
> >
/ /
</button> </button>
<button <button
class="legacy-button tab-button" class="legacy-button legacy-button--dark tab-button"
:aria-pressed="activeTab === 'unique'" :aria-pressed="activeTab === 'unique'"
@click="activeTab = 'unique'" @click="activeTab = 'unique'"
> >
@@ -318,7 +329,12 @@ onMounted(() => {
step="10" step="10"
required required
/> />
<button class="legacy-button" :disabled="actionBusy || selectedResource.isCallerHost">입찰</button> <button
class="legacy-button legacy-button--dark"
:disabled="actionBusy || selectedResource.isCallerHost"
>
입찰
</button>
</form> </form>
<h3 class="subsection-title">경매 등록</h3> <h3 class="subsection-title">경매 등록</h3>
@@ -331,7 +347,7 @@ onMounted(() => {
<legend>매물</legend> <legend>매물</legend>
<div class="item-toggle"> <div class="item-toggle">
<button <button
class="legacy-button" class="legacy-button legacy-button--dark"
type="button" type="button"
:aria-pressed="openForm.type === 'BUY_RICE'" :aria-pressed="openForm.type === 'BUY_RICE'"
@click="openForm.type = 'BUY_RICE'" @click="openForm.type = 'BUY_RICE'"
@@ -339,7 +355,7 @@ onMounted(() => {
</button> </button>
<button <button
class="legacy-button" class="legacy-button legacy-button--dark"
type="button" type="button"
:aria-pressed="openForm.type === 'SELL_RICE'" :aria-pressed="openForm.type === 'SELL_RICE'"
@click="openForm.type = 'SELL_RICE'" @click="openForm.type = 'SELL_RICE'"
@@ -363,7 +379,7 @@ onMounted(() => {
<span>마감가 ({{ openForm.type === 'BUY_RICE' ? '금' : '쌀' }})</span> <span>마감가 ({{ openForm.type === 'BUY_RICE' ? '금' : '쌀' }})</span>
<input v-model.number="openForm.finishBidAmount" type="number" min="100" max="10000" step="10" /> <input v-model.number="openForm.finishBidAmount" type="number" min="100" max="10000" step="10" />
</label> </label>
<button class="legacy-button register-button" :disabled="actionBusy">등록</button> <button class="legacy-button legacy-button--dark register-button" :disabled="actionBusy">등록</button>
</form> </form>
<h3 class="subsection-title">이전 경매(최근 20)</h3> <h3 class="subsection-title">이전 경매(최근 20)</h3>
@@ -408,7 +424,7 @@ onMounted(() => {
유산포인트 (잔여: {{ formatNumber(uniqueDetail.remainPoint) }}포인트) 유산포인트 (잔여: {{ formatNumber(uniqueDetail.remainPoint) }}포인트)
</label> </label>
<input id="unique-bid" v-model.number="bidAmount" type="number" min="1" required /> <input id="unique-bid" v-model.number="bidAmount" type="number" min="1" required />
<button class="legacy-button" :disabled="actionBusy">입찰</button> <button class="legacy-button legacy-button--dark" :disabled="actionBusy">입찰</button>
</form> </form>
</template> </template>
</section> </section>
@@ -480,7 +496,9 @@ onMounted(() => {
</section> </section>
<footer class="bottom-bar bg0"> <footer class="bottom-bar bg0">
<button class="legacy-button close-button" type="button" @click="closeWindow"> 닫기</button> <button class="legacy-button legacy-button--navigation close-button" type="button" @click="closeWindow">
닫기
</button>
</footer> </footer>
</main> </main>
</template> </template>
@@ -530,66 +548,17 @@ onMounted(() => {
line-height: 32px; line-height: 32px;
text-align: center; text-align: center;
} }
.legacy-button {
box-sizing: border-box;
border: solid #3d3d3d;
border-width: 0 1px 4px;
border-radius: 5.25px;
padding: 5.25px 10.5px;
color: #fff;
background: #444;
font: inherit;
font-weight: 700;
line-height: 21px;
cursor: pointer;
}
.legacy-button:hover,
.legacy-button:focus {
border-color: #353535;
background: #393939;
}
.legacy-button:focus-visible {
outline: 2px solid #8ab4f8;
outline-offset: -2px;
}
.legacy-button:active,
.legacy-button[aria-pressed='true'] {
border-color: #303030;
background: #333;
}
.legacy-button:disabled {
cursor: default;
opacity: 0.65;
}
.close-button, .close-button,
.reload-button { .reload-button {
margin-right: 2px; margin-right: 2px;
border-color: #004f28;
background: #00582c;
}
.close-button:hover,
.close-button:focus,
.reload-button:hover,
.reload-button:focus {
border-color: #004523;
background: #004a25;
} }
.top-back-bar .close-button, .top-back-bar .close-button,
.top-back-bar .reload-button { .top-back-bar .reload-button {
height: 32px; --legacy-button-height: 32px;
}
.tab-button {
border-color: #3d3d3d;
background: #444;
} }
.tab-button[aria-pressed='true'] { .tab-button[aria-pressed='true'] {
border-color: #3d3d3d; --legacy-button-bg: #333;
background: #444; --legacy-button-border: #303030;
}
.tab-button:hover,
.tab-button:focus {
border-color: #3d3d3d;
background: #444;
} }
.section-title, .section-title,
.subsection-title, .subsection-title,
@@ -236,9 +236,20 @@ onMounted(() => {
<main class="ref-shell battle-page"> <main class="ref-shell battle-page">
<header class="battle-top legacy-bg0"> <header class="battle-top legacy-bg0">
<RouterLink v-slot="{ navigate }" custom to="/"> <RouterLink v-slot="{ navigate }" custom to="/">
<button class="battle-nav" type="button" @click="navigate"> 닫기</button> <button
class="legacy-button legacy-button--navigation legacy-button--fixed-height battle-nav"
type="button"
@click="navigate"
>
닫기
</button>
</RouterLink> </RouterLink>
<button class="battle-nav" @click="loadBattleCenter">갱신</button> <button
class="legacy-button legacy-button--navigation legacy-button--fixed-height battle-nav"
@click="loadBattleCenter"
>
갱신
</button>
<h1>감찰부</h1> <h1>감찰부</h1>
<div></div> <div></div>
<div></div> <div></div>
@@ -305,7 +316,9 @@ onMounted(() => {
</section> </section>
<footer class="battle-footer legacy-bg0"> <footer class="battle-footer legacy-bg0">
<RouterLink v-slot="{ navigate }" custom to="/"> <RouterLink v-slot="{ navigate }" custom to="/">
<button class="battle-nav" type="button" @click="navigate"> 닫기</button> <button class="legacy-button legacy-button--navigation battle-nav" type="button" @click="navigate">
닫기
</button>
</RouterLink> </RouterLink>
</footer> </footer>
</main> </main>
@@ -418,17 +431,11 @@ onMounted(() => {
text-align: center; text-align: center;
} }
.battle-nav { .battle-nav {
--legacy-button-height: 32px;
box-sizing: border-box; box-sizing: border-box;
height: 32px;
margin-right: 2px; margin-right: 2px;
border: 0;
border-radius: 3px;
display: grid; display: grid;
place-items: center; place-items: center;
background: #00582c;
color: #fff;
font: inherit;
font-weight: 700;
text-decoration: none; text-decoration: none;
} }
.battle-footer { .battle-footer {
+23 -16
View File
@@ -130,7 +130,13 @@ const placeBet = async () => {
<main id="tournament-betting-container" class="betting-page"> <main id="tournament-betting-container" class="betting-page">
<TournamentPageHeader class="bg0" active-page="betting" title="베 팅 장" /> <TournamentPageHeader class="bg0" active-page="betting" title="베 팅 장" />
<section class="toolbar bg0"> <section class="toolbar bg0">
<button type="button" @click="load">갱신</button> <button
class="legacy-button legacy-button--secondary legacy-button--fixed-height"
type="button"
@click="load"
>
갱신
</button>
<span v-if="loading">불러오는 중...</span> <span v-if="loading">불러오는 중...</span>
<span v-if="message" role="status">{{ message }}</span> <span v-if="message" role="status">{{ message }}</span>
</section> </section>
@@ -159,12 +165,7 @@ const placeBet = async () => {
@request-bet="openBetDialog" @request-bet="openBetDialog"
/> />
<dialog <dialog ref="betDialog" class="bet-dialog" aria-labelledby="bet-dialog-title" @close="selectedTarget = null">
ref="betDialog"
class="bet-dialog"
aria-labelledby="bet-dialog-title"
@close="selectedTarget = null"
>
<form v-if="selectedTarget" class="bet-dialog-content" @submit.prevent="placeBet"> <form v-if="selectedTarget" class="bet-dialog-content" @submit.prevent="placeBet">
<header> <header>
<h2 id="bet-dialog-title">베팅하기</h2> <h2 id="bet-dialog-title">베팅하기</h2>
@@ -194,9 +195,13 @@ const placeBet = async () => {
<span aria-hidden="true">×</span> <span aria-hidden="true">×</span>
<span class="gold-color">{{ selectedAmount }}</span> <span class="gold-color">{{ selectedAmount }}</span>
<span aria-hidden="true">=</span> <span aria-hidden="true">=</span>
<strong class="return-color">예상 환수금 {{ selectedExpectedReturn.toLocaleString('ko-KR') }}</strong> <strong class="return-color"
>예상 환수금 {{ selectedExpectedReturn.toLocaleString('ko-KR') }}</strong
>
</output> </output>
<p class="bet-preview-note">현재 배당 기준 예상값이며, 베팅 상황에 따라 최종 배당은 달라질 있습니다.</p> <p class="bet-preview-note">
현재 배당 기준 예상값이며, 베팅 상황에 따라 최종 배당은 달라질 있습니다.
</p>
<p v-if="betError" class="bet-dialog-error" role="alert">{{ betError }}</p> <p v-if="betError" class="bet-dialog-error" role="alert">{{ betError }}</p>
<footer> <footer>
<button type="button" :disabled="placingBet" @click="closeBetDialog">취소</button> <button type="button" :disabled="placingBet" @click="closeBetDialog">취소</button>
@@ -289,7 +294,9 @@ const placeBet = async () => {
</section> </section>
<footer class="betting-footer bg0"> <footer class="betting-footer bg0">
<RouterLink v-slot="{ navigate }" custom to="/"> <RouterLink v-slot="{ navigate }" custom to="/">
<button class="close-button" type="button" @click="navigate"> 닫기</button> <button class="legacy-button legacy-button--navigation close-button" type="button" @click="navigate">
닫기
</button>
</RouterLink> </RouterLink>
<small> <small>
삼국지 모의전투 HiDCHe / KOEI의 이미지를 사용, 응용하였습니다 / 제작 : HideD(hided62@gmail.com) / Credit 삼국지 모의전투 HiDCHe / KOEI의 이미지를 사용, 응용하였습니다 / 제작 : HideD(hided62@gmail.com) / Credit
@@ -334,8 +341,8 @@ const placeBet = async () => {
text-align: left; text-align: left;
} }
.toolbar button { .toolbar button {
--legacy-button-height: 44px;
min-width: 72px; min-width: 72px;
height: 44px;
padding: 10px 16px; padding: 10px 16px;
font-size: 14px; font-size: 14px;
} }
@@ -380,7 +387,7 @@ select {
background: #000; background: #000;
color: #fff; color: #fff;
} }
button { button:not(.legacy-button) {
height: 35.5px; height: 35.5px;
color: #fff; color: #fff;
background: #444; background: #444;
@@ -388,16 +395,16 @@ button {
border-radius: 5.25px; border-radius: 5.25px;
cursor: pointer; cursor: pointer;
} }
button:hover, button:not(.legacy-button):hover,
button:focus { button:not(.legacy-button):focus {
filter: brightness(1.25); filter: brightness(1.25);
} }
button:focus-visible, button:not(.legacy-button):focus-visible,
select:focus-visible { select:focus-visible {
outline: 2px solid #f39c12; outline: 2px solid #f39c12;
outline-offset: 1px; outline-offset: 1px;
} }
button:disabled, button:not(.legacy-button):disabled,
select:disabled { select:disabled {
cursor: not-allowed; cursor: not-allowed;
opacity: 0.5; opacity: 0.5;
+20 -53
View File
@@ -126,7 +126,13 @@ onMounted(() => {
<div v-if="accessChecked && !canAccess" class="legacy-raw-access-error" role="alert">{{ errorMessage }}</div> <div v-if="accessChecked && !canAccess" class="legacy-raw-access-error" role="alert">{{ errorMessage }}</div>
<main v-else id="container" class="legacy-board-page"> <main v-else id="container" class="legacy-board-page">
<header class="top-back-bar bg0"> <header class="top-back-bar bg0">
<button class="legacy-button back-button" type="button" @click="closeBoard">돌아가기</button> <button
class="legacy-button legacy-button--navigation legacy-button--fixed-height back-button"
type="button"
@click="closeBoard"
>
돌아가기
</button>
<div></div> <div></div>
<h1>{{ title }}</h1> <h1>{{ title }}</h1>
<div></div> <div></div>
@@ -163,7 +169,7 @@ onMounted(() => {
<div></div> <div></div>
<button <button
id="submitArticle" id="submitArticle"
class="legacy-button legacy-button--secondary" class="legacy-button legacy-button--secondary legacy-button--fixed-height"
type="button" type="button"
@click="submitArticle" @click="submitArticle"
> >
@@ -222,7 +228,7 @@ onMounted(() => {
@keyup.enter="submitComment(article.id)" @keyup.enter="submitComment(article.id)"
/> />
<button <button
class="legacy-button submit-comment" class="legacy-button legacy-button--dark legacy-button--fixed-height submit-comment"
type="button" type="button"
@click="submitComment(article.id)" @click="submitComment(article.id)"
> >
@@ -235,7 +241,13 @@ onMounted(() => {
</section> </section>
<footer class="bottom-bar bg0"> <footer class="bottom-bar bg0">
<button class="legacy-button back-button" type="button" @click="closeBoard">돌아가기</button> <button
class="legacy-button legacy-button--navigation legacy-button--fixed-height back-button"
type="button"
@click="closeBoard"
>
돌아가기
</button>
</footer> </footer>
</template> </template>
</main> </main>
@@ -296,47 +308,9 @@ onMounted(() => {
text-align: center; text-align: center;
} }
.legacy-button {
min-height: 31px;
box-sizing: border-box;
border: 1px solid #3d3d3d;
border-radius: 4px;
padding: 4px 12px;
color: #fff;
background: #444;
font: inherit;
font-weight: 600;
line-height: 1.5;
text-align: center;
text-decoration: none;
cursor: pointer;
}
.legacy-button:hover {
border-color: #3d3d3d;
background: #444;
}
.legacy-button:focus-visible {
outline: none;
}
.legacy-button:active {
border-color: #3d3d3d;
background: #444;
}
.back-button { .back-button {
height: 32px; --legacy-button-height: 32px;
margin-right: 2px; margin-right: 2px;
border-color: #004f28;
background: #00582c;
}
.back-button:hover,
.back-button:focus {
border-color: #004523;
background: #004a25;
} }
.board-state { .board-state {
@@ -414,17 +388,10 @@ onMounted(() => {
} }
.article-submit-row .legacy-button { .article-submit-row .legacy-button {
--legacy-button-height: 35.5px;
width: auto; width: auto;
min-height: 35.5px;
margin-right: 10.5px; margin-right: 10.5px;
margin-left: 10.5px; margin-left: 10.5px;
transition: none;
}
.article-submit-row .legacy-button:hover,
.article-submit-row .legacy-button:focus,
.article-submit-row .legacy-button:active {
border-color: transparent;
} }
.article-frame { .article-frame {
@@ -513,8 +480,8 @@ onMounted(() => {
} }
.submit-comment { .submit-comment {
--legacy-button-height: 29.375px;
width: 83.333px; width: 83.333px;
min-height: 29.375px;
padding-top: 2px; padding-top: 2px;
padding-bottom: 2px; padding-bottom: 2px;
flex: 0 0 auto; flex: 0 0 auto;
@@ -529,9 +496,9 @@ onMounted(() => {
} }
.bottom-bar .back-button { .bottom-bar .back-button {
--legacy-button-height: 35.5px;
display: inline-block; display: inline-block;
width: 71px; width: 71px;
height: 35.5px;
margin: 0; margin: 0;
padding-right: 6px; padding-right: 6px;
padding-left: 6px; padding-left: 6px;
+17 -11
View File
@@ -318,8 +318,19 @@ const repeatTurns = async (amount: number) => {
<template> <template>
<main class="chief-page"> <main class="chief-page">
<header class="chief-top legacy-bg0"> <header class="chief-top legacy-bg0">
<button class="chief-nav" type="button" @click="router.push('/')">돌아가기</button> <button
<button class="chief-nav" @click="loadChiefCenter">갱신</button> class="legacy-button legacy-button--navigation legacy-button--fixed-height chief-nav"
type="button"
@click="router.push('/')"
>
돌아가기
</button>
<button
class="legacy-button legacy-button--navigation legacy-button--fixed-height chief-nav"
@click="loadChiefCenter"
>
갱신
</button>
<h1>사령부</h1> <h1>사령부</h1>
<div></div> <div></div>
<div></div> <div></div>
@@ -442,7 +453,9 @@ const repeatTurns = async (amount: number) => {
</template> </template>
</div> </div>
<footer class="chief-footer legacy-bg0"> <footer class="chief-footer legacy-bg0">
<button class="chief-nav" type="button" @click="router.push('/')">돌아가기</button> <button class="legacy-button legacy-button--navigation chief-nav" type="button" @click="router.push('/')">
돌아가기
</button>
</footer> </footer>
</main> </main>
</template> </template>
@@ -643,19 +656,12 @@ const repeatTurns = async (amount: number) => {
text-align: center; text-align: center;
} }
.chief-nav { .chief-nav {
--legacy-button-height: 32px;
box-sizing: border-box; box-sizing: border-box;
height: 32px;
margin-right: 2px; margin-right: 2px;
border: 0;
border-radius: 3px;
display: grid; display: grid;
place-items: center; place-items: center;
background: #00582c;
color: #fff;
font: inherit;
font-weight: 700;
text-decoration: none; text-decoration: none;
cursor: pointer;
} }
.layout-desktop { .layout-desktop {
display: block; display: block;
@@ -1,6 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref, watch } from 'vue'; import { computed, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router'; import { useRoute, useRouter } from 'vue-router';
import { formatReservedCommandBrief } from '../components/command/reservedCommandBrief';
import type { CommandTable } from '../components/command/types';
import { cityLevelMap, formatOfficerLevelText, regionMap } from '../utils/nationFormat'; import { cityLevelMap, formatOfficerLevelText, regionMap } from '../utils/nationFormat';
import { getNpcColor } from '../utils/npcColor'; import { getNpcColor } from '../utils/npcColor';
import { resolveGeneralIconUrl, useDefaultGeneralIcon } from '../utils/generalIcon'; import { resolveGeneralIconUrl, useDefaultGeneralIcon } from '../utils/generalIcon';
@@ -9,10 +11,12 @@ import { trpc } from '../utils/trpc';
type Result = Awaited<ReturnType<typeof trpc.world.getCurrentCity.query>>; type Result = Awaited<ReturnType<typeof trpc.world.getCurrentCity.query>>;
type General = Result['generals'][number]; type General = Result['generals'][number];
type ReservedCommand = General['turns'][number];
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
const data = ref<Result | null>(null); const data = ref<Result | null>(null);
const commandTable = ref<CommandTable | null>(null);
const error = ref(''); const error = ref('');
const selected = ref<number>(); const selected = ref<number>();
let loadSequence = 0; let loadSequence = 0;
@@ -29,8 +33,10 @@ const load = async (cityId?: number) => {
const sequence = ++loadSequence; const sequence = ++loadSequence;
try { try {
const result = await trpc.world.getCurrentCity.query(cityId ? { cityId } : undefined); const result = await trpc.world.getCurrentCity.query(cityId ? { cityId } : undefined);
const table = await trpc.turns.getCommandTable.query({ generalId: result.me.id });
if (sequence !== loadSequence) return; if (sequence !== loadSequence) return;
data.value = result; data.value = result;
commandTable.value = table;
selected.value = result.city.id; selected.value = result.city.id;
error.value = ''; error.value = '';
} catch (cause) { } catch (cause) {
@@ -76,6 +82,8 @@ const defenceTrainText = (value: number | null) => {
return '△'; return '△';
}; };
const generalImage = (general: General): string => resolveGeneralIconUrl(general); const generalImage = (general: General): string => resolveGeneralIconUrl(general);
const commandBrief = (command: ReservedCommand): string =>
formatReservedCommandBrief('general', command.action, command.args, commandTable.value);
</script> </script>
<template> <template>
@@ -282,8 +290,12 @@ const generalImage = (general: General): string => resolveGeneralIconUrl(general
<td>{{ general.atmos ?? '?' }}</td> <td>{{ general.atmos ?? '?' }}</td>
<td class="turns" :class="{ 'turns--reserved': general.turns.length > 0 }"> <td class="turns" :class="{ 'turns--reserved': general.turns.length > 0 }">
<template v-if="general.turns.length"> <template v-if="general.turns.length">
<span v-for="(turn, index) in general.turns" :key="index" class="turn-line" <span
>{{ index + 1 }} : {{ turn }}</span v-for="(turn, index) in general.turns"
:key="index"
class="turn-line"
:title="commandBrief(turn)"
>{{ index + 1 }} : {{ commandBrief(turn) }}</span
> >
</template> </template>
<template v-else-if="general.npcState > 1">NPC 장수</template> <template v-else-if="general.npcState > 1">NPC 장수</template>
+17 -8
View File
@@ -53,7 +53,13 @@ onMounted(async () => {
<template> <template>
<main class="global-page legacy-bg0"> <main class="global-page legacy-bg0">
<header class="legacy-title"> <header class="legacy-title">
<button type="button" @click="goBack">돌아가기</button><strong>중원 정보</strong> <button
class="legacy-button legacy-button--navigation legacy-button--fixed-height"
type="button"
@click="goBack"
>
돌아가기</button
><strong>중원 정보</strong>
</header> </header>
<p v-if="error" class="error">{{ error }}</p> <p v-if="error" class="error">{{ error }}</p>
<section v-if="data" class="section"> <section v-if="data" class="section">
@@ -147,7 +153,15 @@ onMounted(async () => {
</div> </div>
</div> </div>
</section> </section>
<footer class="legacy-title footer"><button type="button" @click="goBack">돌아가기</button></footer> <footer class="legacy-title footer">
<button
class="legacy-button legacy-button--navigation legacy-button--fixed-height"
type="button"
@click="goBack"
>
돌아가기
</button>
</footer>
<button class="legacy-compat-button" type="button" tabindex="-1" aria-hidden="true" /> <button class="legacy-compat-button" type="button" tabindex="-1" aria-hidden="true" />
</main> </main>
</template> </template>
@@ -178,15 +192,10 @@ onMounted(async () => {
line-height: 32px; line-height: 32px;
} }
.legacy-title button { .legacy-title button {
--legacy-button-height: 32px;
position: absolute; position: absolute;
inset: 0 auto 0 0; inset: 0 auto 0 0;
width: 88px; width: 88px;
border: 1px solid #0a9960;
border-radius: 0 0 4px;
background: #087f45;
color: #fff;
font-weight: 700;
cursor: pointer;
} }
.legacy-compat-button { .legacy-compat-button {
display: none; display: none;
+5 -4
View File
@@ -449,10 +449,12 @@ onMounted(() => {
<template> <template>
<header class="top-back-bar legacy-bg0"> <header class="top-back-bar legacy-bg0">
<RouterLink class="top-button legacy-button legacy-button--navigation" to="/">돌아가기</RouterLink> <RouterLink class="top-button legacy-button legacy-button--navigation legacy-button--fixed-height" to="/"
>돌아가기</RouterLink
>
<strong>유산 관리</strong> <strong>유산 관리</strong>
<button <button
class="top-button legacy-button legacy-button--navigation" class="top-button legacy-button legacy-button--navigation legacy-button--fixed-height"
type="button" type="button"
:disabled="loading" :disabled="loading"
@click="loadStatus" @click="loadStatus"
@@ -786,8 +788,7 @@ onMounted(() => {
} }
.top-button { .top-button {
height: 32px; --legacy-button-height: 32px;
min-height: 32px;
padding: 2px 8px; padding: 2px 8px;
text-decoration: none; text-decoration: none;
} }
@@ -338,7 +338,13 @@ onMounted(() => {
<main id="nation-betting-container" class="nation-betting-page legacy-bg0"> <main id="nation-betting-container" class="nation-betting-page legacy-bg0">
<header class="legacy-top-bar"> <header class="legacy-top-bar">
<RouterLink v-slot="{ navigate }" custom to="/"> <RouterLink v-slot="{ navigate }" custom to="/">
<button class="legacy-button legacy-button--navigation" type="button" @click="navigate">돌아가기</button> <button
class="legacy-button legacy-button--navigation legacy-button--fixed-height"
type="button"
@click="navigate"
>
돌아가기
</button>
</RouterLink> </RouterLink>
<div></div> <div></div>
<h1>국가 베팅장</h1> <h1>국가 베팅장</h1>
@@ -395,7 +401,7 @@ onMounted(() => {
<div>사용 포인트: {{ usedAmount.toLocaleString('ko-KR') }}</div> <div>사용 포인트: {{ usedAmount.toLocaleString('ko-KR') }}</div>
<div>대상: {{ selectionLabel(selectedKey) }}</div> <div>대상: {{ selectionLabel(selectedKey) }}</div>
<input v-model.number="amount" aria-label="베팅 금액" type="number" min="10" max="1000" step="10" /> <input v-model.number="amount" aria-label="베팅 금액" type="number" min="10" max="1000" step="10" />
<button type="submit" :disabled="submitting">베팅</button> <button class="legacy-button legacy-button--primary" type="submit" :disabled="submitting">베팅</button>
</form> </form>
<div class="payout-table"> <div class="payout-table">
@@ -475,33 +481,10 @@ onMounted(() => {
text-align: center; text-align: center;
} }
.legacy-nav-button, .legacy-top-bar .legacy-button {
.betting-form button { --legacy-button-height: 32px;
height: 32px;
border: 0;
background: #00582c;
color: #fff;
font-weight: 600;
text-align: center;
text-decoration: none;
cursor: pointer;
} }
.legacy-nav-button {
display: grid;
place-items: center;
margin-right: 2px;
}
.legacy-nav-button:hover,
.legacy-nav-button:focus,
.betting-form button:hover,
.betting-form button:focus {
filter: brightness(1.18);
}
.legacy-nav-button:focus-visible,
.betting-form button:focus-visible,
.betting-candidate:focus-visible, .betting-candidate:focus-visible,
.betting-item:focus-visible { .betting-item:focus-visible {
outline: 2px solid #f39c12; outline: 2px solid #f39c12;
@@ -662,9 +645,8 @@ onMounted(() => {
padding-top: 20px; padding-top: 20px;
} }
.betting-footer .legacy-nav-button { .betting-footer .legacy-button {
width: 90px; width: 90px;
height: 35.5px;
} }
.betting-notice, .betting-notice,
@@ -536,14 +536,25 @@ onBeforeUnmount(() => {
<main class="general-page legacy-bg0"> <main class="general-page legacy-bg0">
<header class="top-bar"> <header class="top-bar">
<span class="left-actions"> <span class="left-actions">
<button class="top-button nation-button" @click="router.push('/')">돌아가기</button> <button
<button class="top-button nation-button" :disabled="loading" @click="load">갱신</button> class="legacy-button legacy-button--navigation legacy-button--fixed-height top-button nation-button"
@click="router.push('/')"
>
돌아가기
</button>
<button
class="legacy-button legacy-button--navigation legacy-button--fixed-height top-button nation-button"
:disabled="loading"
@click="load"
>
갱신
</button>
</span> </span>
<strong>세력 장수</strong> <strong>세력 장수</strong>
<span class="right-actions"> <span class="right-actions">
<span class="dropdown"> <span class="dropdown">
<button <button
class="top-button mode-button" class="legacy-button legacy-button--primary legacy-button--fixed-height top-button mode-button"
:aria-expanded="viewMenuOpen" :aria-expanded="viewMenuOpen"
@click=" @click="
viewMenuOpen = !viewMenuOpen; viewMenuOpen = !viewMenuOpen;
@@ -576,7 +587,7 @@ onBeforeUnmount(() => {
</span> </span>
<span class="dropdown"> <span class="dropdown">
<button <button
class="top-button columns-button" class="legacy-button legacy-button--info legacy-button--fixed-height top-button columns-button"
:aria-expanded="columnMenuOpen" :aria-expanded="columnMenuOpen"
@click=" @click="
columnMenuOpen = !columnMenuOpen; columnMenuOpen = !columnMenuOpen;
@@ -850,45 +861,30 @@ onBeforeUnmount(() => {
right: 0; right: 0;
} }
.top-button { .top-button {
--legacy-button-height: 32px;
display: inline-flex; display: inline-flex;
width: 89px; width: 89px;
height: 32px;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
padding: 0; padding: 0;
border: 0;
border-right: 1px solid #151515;
border-radius: 3px;
color: #fff;
font-size: 14px; font-size: 14px;
font-weight: 700;
text-decoration: none; text-decoration: none;
cursor: pointer;
} }
.nation-button { .nation-button {
background: #006c48; --legacy-button-bg: #006c48;
} --legacy-button-border: #005d3e;
.nation-button:hover {
background: #00855a;
} }
.mode-button { .mode-button {
background: #375a7f; --legacy-button-bg: #375a7f;
border-bottom: 0 solid #325172; --legacy-button-border: #325172;
}
.mode-button:hover,
.mode-button[aria-expanded='true'],
.mode-button:active {
border-bottom-width: 3px;
} }
.mode-button, .mode-button,
.columns-button { .columns-button {
width: 90px; width: 90px;
} }
.columns-button { .columns-button {
background: #3297cf; --legacy-button-bg: #3297cf;
} --legacy-button-border: #2b80b0;
.columns-button:hover {
filter: brightness(1.12);
} }
.dropdown { .dropdown {
position: relative; position: relative;
@@ -173,7 +173,9 @@ onMounted(() => void loadStratFinan());
<template> <template>
<main id="finance-container" class="page-finance"> <main id="finance-container" class="page-finance">
<nav class="top-back-bar"> <nav class="top-back-bar">
<RouterLink class="legacy-button" to="/">돌아가기</RouterLink> <RouterLink class="legacy-button legacy-button--navigation legacy-button--fixed-height" to="/"
>돌아가기</RouterLink
>
<span /> <span />
<strong>내무부</strong> <strong>내무부</strong>
<span /> <span />
@@ -224,7 +226,7 @@ onMounted(() => void loadStratFinan());
<span> <span>
<button <button
v-if="canEdit && !editingNationMsg" v-if="canEdit && !editingNationMsg"
class="message-button" class="legacy-button legacy-button--secondary message-button"
type="button" type="button"
@click="enableEditNationMsg" @click="enableEditNationMsg"
> >
@@ -232,7 +234,7 @@ onMounted(() => void loadStratFinan());
</button> </button>
<button <button
v-if="canEdit && editingNationMsg" v-if="canEdit && editingNationMsg"
class="policy-submit" class="legacy-button legacy-button--primary policy-submit"
type="button" type="button"
@click="saveNationMsg" @click="saveNationMsg"
> >
@@ -240,7 +242,7 @@ onMounted(() => void loadStratFinan());
</button> </button>
<button <button
v-if="canEdit && editingNationMsg" v-if="canEdit && editingNationMsg"
class="policy-cancel" class="legacy-button legacy-button--secondary policy-cancel"
type="button" type="button"
@click="rollbackNationMsg" @click="rollbackNationMsg"
> >
@@ -249,24 +251,15 @@ onMounted(() => void loadStratFinan());
</span> </span>
</header> </header>
<div v-if="!editingNationMsg" class="message-preview" v-html="nationMsg || '내용 없음'" /> <div v-if="!editingNationMsg" class="message-preview" v-html="nationMsg || '내용 없음'" />
<LegacyHtmlEditor <LegacyHtmlEditor v-else v-model="nationMsgDraft" :max-length="16384" aria-label="국가 방침" />
v-else
v-model="nationMsgDraft"
:max-length="16384"
aria-label="국가 방침"
/>
</section> </section>
<section <section id="scout-message-form" class="message-form" :class="{ 'message-form--editing': editingScoutMsg }">
id="scout-message-form"
class="message-form"
:class="{ 'message-form--editing': editingScoutMsg }"
>
<header class="green-header"> <header class="green-header">
<span>임관 권유</span> <span>임관 권유</span>
<span> <span>
<button <button
v-if="canEdit && !editingScoutMsg" v-if="canEdit && !editingScoutMsg"
class="message-button" class="legacy-button legacy-button--secondary message-button"
type="button" type="button"
@click="enableEditScoutMsg" @click="enableEditScoutMsg"
> >
@@ -274,7 +267,7 @@ onMounted(() => void loadStratFinan());
</button> </button>
<button <button
v-if="canEdit && editingScoutMsg" v-if="canEdit && editingScoutMsg"
class="policy-submit" class="legacy-button legacy-button--primary policy-submit"
type="button" type="button"
@click="saveScoutMsg" @click="saveScoutMsg"
> >
@@ -282,7 +275,7 @@ onMounted(() => void loadStratFinan());
</button> </button>
<button <button
v-if="canEdit && editingScoutMsg" v-if="canEdit && editingScoutMsg"
class="policy-cancel" class="legacy-button legacy-button--secondary policy-cancel"
type="button" type="button"
@click="rollbackScoutMsg" @click="rollbackScoutMsg"
> >
@@ -292,12 +285,7 @@ onMounted(() => void loadStratFinan());
</header> </header>
<div class="scout-limit">870px x 200px를 넘어서는 내용은 표시되지 않습니다.</div> <div class="scout-limit">870px x 200px를 넘어서는 내용은 표시되지 않습니다.</div>
<div v-if="!editingScoutMsg" class="message-preview scout-preview" v-html="scoutMsg || '내용 없음'" /> <div v-if="!editingScoutMsg" class="message-preview scout-preview" v-html="scoutMsg || '내용 없음'" />
<LegacyHtmlEditor <LegacyHtmlEditor v-else v-model="scoutMsgDraft" :max-length="1000" aria-label="임관 권유" />
v-else
v-model="scoutMsgDraft"
:max-length="1000"
aria-label="임관 권유"
/>
</section> </section>
<div class="finance-title">예산&amp;정책</div> <div class="finance-title">예산&amp;정책</div>
@@ -362,10 +350,17 @@ onMounted(() => void loadStratFinan());
<input v-model.number="policy.rate" aria-label="세율" type="number" min="5" max="30" /><span <input v-model.number="policy.rate" aria-label="세율" type="number" min="5" max="30" /><span
>%</span >%</span
> >
<button v-if="canEdit" class="policy-submit" type="button" @click="setRate">변경</button>
<button <button
v-if="canEdit" v-if="canEdit"
class="policy-cancel" class="legacy-button legacy-button--primary policy-submit"
type="button"
@click="setRate"
>
변경
</button>
<button
v-if="canEdit"
class="legacy-button legacy-button--secondary policy-cancel"
type="button" type="button"
@click="policy.rate = oldPolicy.rate" @click="policy.rate = oldPolicy.rate"
> >
@@ -379,10 +374,17 @@ onMounted(() => void loadStratFinan());
<input v-model.number="policy.bill" aria-label="지급률" type="number" min="20" max="200" /><span <input v-model.number="policy.bill" aria-label="지급률" type="number" min="20" max="200" /><span
>%</span >%</span
> >
<button v-if="canEdit" class="policy-submit" type="button" @click="setBill">변경</button>
<button <button
v-if="canEdit" v-if="canEdit"
class="policy-cancel" class="legacy-button legacy-button--primary policy-submit"
type="button"
@click="setBill"
>
변경
</button>
<button
v-if="canEdit"
class="legacy-button legacy-button--secondary policy-cancel"
type="button" type="button"
@click="policy.bill = oldPolicy.bill" @click="policy.bill = oldPolicy.bill"
> >
@@ -400,10 +402,17 @@ onMounted(() => void loadStratFinan());
min="1" min="1"
max="99" max="99"
/><span></span> /><span></span>
<button v-if="canEdit" class="policy-submit" type="button" @click="setSecretLimit">변경</button>
<button <button
v-if="canEdit" v-if="canEdit"
class="policy-cancel" class="legacy-button legacy-button--primary policy-submit"
type="button"
@click="setSecretLimit"
>
변경
</button>
<button
v-if="canEdit"
class="legacy-button legacy-button--secondary policy-cancel"
type="button" type="button"
@click="policy.secretLimit = oldPolicy.secretLimit" @click="policy.secretLimit = oldPolicy.secretLimit"
> >
@@ -438,7 +447,7 @@ onMounted(() => void loadStratFinan());
<input v-for="index in 4" :key="`compat-input-${index}`" type="hidden" /> <input v-for="index in 4" :key="`compat-input-${index}`" type="hidden" />
</div> </div>
<footer class="bottom-bar"> <footer class="bottom-bar">
<RouterLink class="legacy-button" to="/">돌아가기</RouterLink> <RouterLink class="legacy-button legacy-button--navigation" to="/">돌아가기</RouterLink>
</footer> </footer>
</template> </template>
</main> </main>
@@ -472,32 +481,8 @@ onMounted(() => void loadStratFinan());
.top-back-bar button { .top-back-bar button {
grid-column: 5; grid-column: 5;
} }
.legacy-button, .top-back-bar .legacy-button {
button { --legacy-button-height: 32px;
box-sizing: border-box;
border: 1px solid #00502a;
border-radius: 4px;
padding: 5.25px 10.5px;
color: #fff;
background: #00582c;
font: inherit;
line-height: 21px;
text-align: center;
text-decoration: none;
cursor: pointer;
}
.message-button,
.policy-cancel {
border-color: #6c757d;
background: #6c757d;
}
.policy-submit {
border-color: #325172;
background: #375a7f;
}
button:hover,
.legacy-button:hover {
filter: brightness(1.2);
} }
button:focus-visible, button:focus-visible,
.legacy-button:focus-visible, .legacy-button:focus-visible,
+4 -10
View File
@@ -355,13 +355,14 @@ const submitPriority = async (section: PrioritySectionKey) => {
error.value = `설정하지 못했습니다: ${resolveErrorMessage(caught)}`; error.value = `설정하지 못했습니다: ${resolveErrorMessage(caught)}`;
} }
}; };
</script> </script>
<template> <template>
<main id="npc-policy-page" class="npc-page"> <main id="npc-policy-page" class="npc-page">
<nav class="top-back-bar legacy-bg0"> <nav class="top-back-bar legacy-bg0">
<RouterLink class="back-button" to="/">돌아가기</RouterLink> <RouterLink class="legacy-button legacy-button--navigation legacy-button--fixed-height back-button" to="/"
>돌아가기</RouterLink
>
<strong>NPC 정책</strong> <strong>NPC 정책</strong>
</nav> </nav>
@@ -579,21 +580,14 @@ const submitPriority = async (section: PrioritySectionKey) => {
} }
.back-button { .back-button {
--legacy-button-height: 32px;
position: absolute; position: absolute;
inset: 0 auto 0 0; inset: 0 auto 0 0;
width: 88px; width: 88px;
color: #fff;
background: #087f45;
border: 1px solid #0a9960;
border-radius: 0 0 4px;
font-weight: 700;
line-height: 30px;
text-decoration: none; text-decoration: none;
} }
.back-button:hover,
.back-button:focus-visible { .back-button:focus-visible {
background: #0a9960;
outline: 2px solid #fff; outline: 2px solid #fff;
outline-offset: -2px; outline-offset: -2px;
} }
+7 -4
View File
@@ -209,11 +209,15 @@ onMounted(() => {
<template> <template>
<main id="container" class="pageVote bg0"> <main id="container" class="pageVote bg0">
<header class="back_bar bg0"> <header class="back_bar bg0">
<button class="legacy-button legacy-button--navigation back_btn" type="button" @click="router.push('/')"> <button
class="legacy-button legacy-button--navigation legacy-button--fixed-height back_btn"
type="button"
@click="router.push('/')"
>
닫기 닫기
</button> </button>
<button <button
class="legacy-button legacy-button--navigation reload_btn" class="legacy-button legacy-button--navigation legacy-button--fixed-height reload_btn"
type="button" type="button"
:disabled="loading" :disabled="loading"
@click="reloadVote" @click="reloadVote"
@@ -453,8 +457,7 @@ onMounted(() => {
.back_btn, .back_btn,
.reload_btn { .reload_btn {
height: 32px; --legacy-button-height: 32px;
min-height: 32px;
margin-right: 2px; margin-right: 2px;
font-weight: 600; font-weight: 600;
} }
+18 -9
View File
@@ -190,10 +190,16 @@ const start = async () => {
<TournamentPageHeader class="bg0" active-page="tournament" title="삼모전 토너먼트" /> <TournamentPageHeader class="bg0" active-page="tournament" title="삼모전 토너먼트" />
<section class="toolbar bg0"> <section class="toolbar bg0">
<button type="button" @click="load">갱신</button> <button
class="legacy-button legacy-button--secondary legacy-button--fixed-height"
type="button"
@click="load"
>
갱신
</button>
<button <button
type="button" type="button"
class="join-button" class="legacy-button legacy-button--navigation legacy-button--fixed-height join-button"
:disabled="snapshot?.state?.stage !== 1 || isParticipant" :disabled="snapshot?.state?.stage !== 1 || isParticipant"
@click="join" @click="join"
> >
@@ -382,7 +388,9 @@ const start = async () => {
<input type="hidden" name="tournamentAction" value="join" /> <input type="hidden" name="tournamentAction" value="join" />
<footer class="tournament-footer bg0"> <footer class="tournament-footer bg0">
<RouterLink v-slot="{ navigate }" custom to="/"> <RouterLink v-slot="{ navigate }" custom to="/">
<button class="close-button" type="button" @click="navigate"> 닫기</button> <button class="legacy-button legacy-button--navigation close-button" type="button" @click="navigate">
닫기
</button>
</RouterLink> </RouterLink>
<small> <small>
삼국지 모의전투 HiDCHe / KOEI의 이미지를 사용, 응용하였습니다 / 제작 : HideD(hided62@gmail.com) / Credit 삼국지 모의전투 HiDCHe / KOEI의 이미지를 사용, 응용하였습니다 / 제작 : HideD(hided62@gmail.com) / Credit
@@ -439,8 +447,8 @@ const start = async () => {
padding: 1px; padding: 1px;
} }
.toolbar button { .toolbar button {
--legacy-button-height: 44px;
min-width: 72px; min-width: 72px;
height: 44px;
padding: 10px 16px; padding: 10px 16px;
font-size: 14px; font-size: 14px;
} }
@@ -451,7 +459,7 @@ const start = async () => {
min-height: 32px; min-height: 32px;
padding: 5px; padding: 5px;
} }
button { button:not(.legacy-button) {
height: 35.5px; height: 35.5px;
margin: 0 2px; margin: 0 2px;
border: 1px solid #666; border: 1px solid #666;
@@ -460,16 +468,17 @@ button {
color: #fff; color: #fff;
cursor: pointer; cursor: pointer;
} }
button:hover, button:not(.legacy-button):hover,
button:focus { button:not(.legacy-button):focus {
filter: brightness(1.25); filter: brightness(1.25);
} }
button:focus-visible { button:not(.legacy-button):focus-visible {
outline: 2px solid #f39c12; outline: 2px solid #f39c12;
outline-offset: 1px; outline-offset: 1px;
} }
.join-button { .join-button {
background: #8a5b13; --legacy-button-bg: #8a5b13;
--legacy-button-border: #704a0f;
} }
.operator-row span { .operator-row span {
color: orange; color: orange;
+3 -4
View File
@@ -179,14 +179,14 @@ onMounted(() => {
<main id="container" class="legacy-troop-page"> <main id="container" class="legacy-troop-page">
<header class="topBackBar bg0"> <header class="topBackBar bg0">
<button <button
class="legacy-button legacy-button--navigation legacyNavButton backLink" class="legacy-button legacy-button--navigation legacy-button--fixed-height legacyNavButton backLink"
type="button" type="button"
@click="router.push('/')" @click="router.push('/')"
> >
돌아가기 돌아가기
</button> </button>
<button <button
class="legacy-button legacy-button--navigation legacyNavButton reloadButton" class="legacy-button legacy-button--navigation legacy-button--fixed-height legacyNavButton reloadButton"
type="button" type="button"
:disabled="loading" :disabled="loading"
@click="refresh" @click="refresh"
@@ -395,8 +395,7 @@ onMounted(() => {
} }
.legacyNavButton { .legacyNavButton {
height: 32px; --legacy-button-height: 32px;
min-height: 32px;
margin-right: 2px; margin-right: 2px;
font-weight: 600; font-weight: 600;
} }
+12 -15
View File
@@ -137,11 +137,18 @@ onMounted(async () => {
<main id="yearbook-container" class="yearbook-page legacy-bg0"> <main id="yearbook-container" class="yearbook-page legacy-bg0">
<header class="yearbook-title legacy-bg0"> <header class="yearbook-title legacy-bg0">
<strong> </strong> <strong> </strong>
<button class="legacy-button close-button" type="button" @click="closePage"> 닫기</button> <button
class="legacy-button legacy-button--navigation legacy-button--fixed-height close-button"
type="button"
@click="closePage"
>
닫기
</button>
<span class="settings-menu"> <span class="settings-menu">
<button <button
class="legacy-button legacy-button--navigation" class="legacy-button legacy-button--navigation legacy-button--fixed-height"
type="button" type="button"
:aria-expanded="settingsOpen"
@click="settingsOpen = !settingsOpen" @click="settingsOpen = !settingsOpen"
> >
설정 설정
@@ -235,7 +242,7 @@ onMounted(async () => {
</section> </section>
<footer class="yearbook-footer"> <footer class="yearbook-footer">
<button class="legacy-button" type="button" @click="closePage">창 닫기</button> <button class="legacy-button legacy-button--navigation" type="button" @click="closePage">창 닫기</button>
</footer> </footer>
<div class="dropdown-compat-buttons" aria-hidden="true"> <div class="dropdown-compat-buttons" aria-hidden="true">
<button type="button" tabindex="-1" /><button type="button" tabindex="-1" /> <button type="button" tabindex="-1" /><button type="button" tabindex="-1" />
@@ -286,14 +293,14 @@ onMounted(async () => {
} }
.yearbook-title .close-button { .yearbook-title .close-button {
left: 0; left: 0;
height: 32px; --legacy-button-height: 32px;
} }
.settings-menu { .settings-menu {
right: 0; right: 0;
height: 32px; height: 32px;
} }
.settings-menu > .legacy-button { .settings-menu > .legacy-button {
height: 32px; --legacy-button-height: 32px;
} }
.settings-item { .settings-item {
position: absolute; position: absolute;
@@ -393,16 +400,6 @@ onMounted(async () => {
.dropdown-compat-buttons { .dropdown-compat-buttons {
display: none; display: none;
} }
.year-selector .legacy-button {
border: 0;
background: #444;
}
.settings-menu > .legacy-button,
.yearbook-title .close-button {
border: 0;
background: #00582c;
}
.log-content { .log-content {
min-height: 72px; min-height: 72px;
padding: 1px 0; padding: 1px 0;
@@ -0,0 +1,71 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import path from 'node:path';
import { describe, it } from 'node:test';
const source = (relativePath: string) => readFile(path.resolve(import.meta.dirname, '../src', relativePath), 'utf8');
void describe('shared Lumen button family', () => {
void it('keeps fixed-height state compensation in the shared control layer', async () => {
const css = await source('assets/styles/legacy-controls.css');
assert.match(css, /\.legacy-button\.legacy-button--fixed-height\s*\{/u);
assert.match(css, /height:\s*calc\(var\(--legacy-button-height\) - 1px\)/u);
assert.match(css, /height:\s*calc\(var\(--legacy-button-height\) - 2px\)/u);
});
void it('connects every reported control to a semantic Lumen variant', async () => {
const [nationGenerals, tournamentHeader, reservedEditor] = await Promise.all([
source('views/NationGeneralsView.vue'),
source('components/tournament/TournamentPageHeader.vue'),
source('components/command/ReservedCommandEditor.vue'),
]);
assert.match(
nationGenerals,
/legacy-button legacy-button--navigation legacy-button--fixed-height top-button nation-button/u
);
assert.match(
nationGenerals,
/legacy-button legacy-button--primary legacy-button--fixed-height top-button mode-button/u
);
assert.match(
nationGenerals,
/legacy-button legacy-button--info legacy-button--fixed-height top-button columns-button/u
);
for (const label of ['돌아가기', '갱신', '보기 모드⌄', '열 선택⌄']) {
assert.match(nationGenerals, new RegExp(label, 'u'));
}
for (const label of ['토너먼트', '베팅장', '창 닫기']) {
assert.match(tournamentHeader, new RegExp(`legacy-button[\\s\\S]{0,260}${label}`, 'u'));
}
assert.match(reservedEditor, /legacy-button legacy-button--info legacy-button--fixed-height select-command/u);
assert.match(reservedEditor, / /u);
});
void it('uses the same family for audited Ref TopBackBar navigation controls', async () => {
const files = [
'views/AuctionView.vue',
'views/BattleCenterView.vue',
'views/BoardView.vue',
'views/ChiefCenterView.vue',
'views/GlobalInfoView.vue',
'views/InheritView.vue',
'views/NationBettingView.vue',
'views/NationStratFinanView.vue',
'views/NpcControlView.vue',
'views/SurveyView.vue',
'views/TroopView.vue',
'views/YearbookView.vue',
];
const contents = await Promise.all(files.map(source));
for (const [index, content] of contents.entries()) {
assert.match(
content,
/legacy-button legacy-button--navigation/u,
`${files[index]} must opt its raised navigation control into the shared family`
);
}
});
});
+5 -4
View File
@@ -49,7 +49,6 @@ const zServerAction = z.enum([
'SHUTDOWN', 'SHUTDOWN',
]); ]);
const TURN_TERM_MINUTES = [1, 2, 5, 10, 20, 30, 60, 120] as const;
const AUTORUN_USER_OPTIONS = ['develop', 'warp', 'recruit', 'recruit_high', 'train', 'battle', 'chief'] as const; const AUTORUN_USER_OPTIONS = ['develop', 'warp', 'recruit', 'recruit_high', 'train', 'battle', 'chief'] as const;
const ADMIN_ROLE_PREFIX = 'admin.'; const ADMIN_ROLE_PREFIX = 'admin.';
@@ -430,7 +429,9 @@ const zInstallAutorun = z.object({
options: z.array(z.enum(AUTORUN_USER_OPTIONS)), options: z.array(z.enum(AUTORUN_USER_OPTIONS)),
}); });
const isAllowedTurnTerm = (value: number): boolean => TURN_TERM_MINUTES.some((term) => term === value); // Ref aligns a 12-month game year inside a 24-hour wall-clock day. That makes
// every positive divisor of 120 valid, including 3 and 8 minutes.
const isAllowedTurnTerm = (value: number): boolean => Number.isInteger(value) && value > 0 && 120 % value === 0;
const zRuntimeSettings = z const zRuntimeSettings = z
.object({ .object({
@@ -438,7 +439,7 @@ const zRuntimeSettings = z
.number() .number()
.int() .int()
.refine((value) => isAllowedTurnTerm(value), { .refine((value) => isAllowedTurnTerm(value), {
message: 'turnTermMinutes must divide 120.', message: '턴 시간은 120의 약수여야 합니다.',
}) })
.optional(), .optional(),
blockGeneralCreate: z.union([z.literal(0), z.literal(1), z.literal(2)]).optional(), blockGeneralCreate: z.union([z.literal(0), z.literal(1), z.literal(2)]).optional(),
@@ -464,7 +465,7 @@ const zInstallOptions = z.object({
.number() .number()
.int() .int()
.refine((value) => isAllowedTurnTerm(value), { .refine((value) => isAllowedTurnTerm(value), {
message: 'turnTermMinutes must divide 120.', message: '턴 시간은 120의 약수여야 합니다.',
}), }),
sync: z.boolean(), sync: z.boolean(),
fiction: z.number().int().min(0).max(1), fiction: z.number().int().min(0).max(1),
+39 -1
View File
@@ -825,7 +825,7 @@ describe('admin operation API', () => {
{ adminRoles: ['admin.profiles.settings:che:2'], firstUserIsAdmin: false } { adminRoles: ['admin.profiles.settings:che:2'], firstUserIsAdmin: false }
); );
const resetDefaults = { const resetDefaults = {
turnTermMinutes: 10, turnTermMinutes: 3,
sync: true, sync: true,
fiction: 1 as const, fiction: 1 as const,
extend: true, extend: true,
@@ -853,6 +853,44 @@ describe('admin operation API', () => {
).rejects.toBeDefined(); ).rejects.toBeDefined();
}); });
it('queues a three-minute reset because it is a valid divisor of 120', async () => {
const harness = await buildCaller(async (input) => ({
id: '66666666-6666-4666-8666-666666666666',
profileName: input.profileName,
type: 'RESET',
status: 'QUEUED',
sourceMode: input.sourceMode,
sourceRef: input.sourceRef,
payload: input.payload ?? {},
requestedBy: input.requestedBy,
createdAt: '2026-08-21T00:00:00.000Z',
updatedAt: '2026-08-21T00:00:00.000Z',
}));
await harness.caller.admin.operations.requestReset({
profileName: 'che:2',
sourceMode: 'COMMIT',
sourceRef: 'HEAD',
install: {
scenarioId: 1010,
turnTermMinutes: 3,
sync: true,
fiction: 1,
extend: true,
blockGeneralCreate: 0,
npcMode: 0,
showImgLevel: 3,
tournamentTrig: true,
joinMode: 'full',
},
});
expect(harness.createdInputs[0]).toMatchObject({
type: 'RESET',
payload: { install: { turnTermMinutes: 3 } },
});
});
it('stores event season zero as the next season number', async () => { it('stores event season zero as the next season number', async () => {
const harness = await buildCaller( const harness = await buildCaller(
async () => { async () => {
@@ -983,7 +983,7 @@ test('loads server metadata defaults into the reset form and submits them', asyn
runtimeRunning: true, runtimeRunning: true,
requestBodies: [], requestBodies: [],
resetDefaults: { resetDefaults: {
turnTermMinutes: 20, turnTermMinutes: 3,
sync: false, sync: false,
fiction: 0, fiction: 0,
extend: false, extend: false,
@@ -999,7 +999,7 @@ test('loads server metadata defaults into the reset form and submits them', asyn
page.on('dialog', (dialog) => dialog.accept()); page.on('dialog', (dialog) => dialog.accept());
await page.goto('admin/servers/che%3Adefault/scenario'); await page.goto('admin/servers/che%3Adefault/scenario');
await expect(page.getByTestId('reset-turn-term')).toHaveValue('20'); await expect(page.getByTestId('reset-turn-term')).toHaveValue('3');
await page.getByText('고급 시나리오 옵션').click(); await page.getByText('고급 시나리오 옵션').click();
await expect(page.getByTestId('reset-defaults-source')).toContainText('서버의 메타'); await expect(page.getByTestId('reset-defaults-source')).toContainText('서버의 메타');
await expect(page.getByTestId('reset-npc-mode')).toHaveValue('1'); await expect(page.getByTestId('reset-npc-mode')).toHaveValue('1');
@@ -1013,7 +1013,7 @@ test('loads server metadata defaults into the reset form and submits them', asyn
const request = JSON.stringify( const request = JSON.stringify(
state.requestBodies.find((entry) => entry.operation === 'admin.operations.requestReset')?.body state.requestBodies.find((entry) => entry.operation === 'admin.operations.requestReset')?.body
); );
expect(request).toContain('"turnTermMinutes":20'); expect(request).toContain('"turnTermMinutes":3');
expect(request).toContain('"npcMode":1'); expect(request).toContain('"npcMode":1');
expect(request).toContain('"joinMode":"onlyRandom"'); expect(request).toContain('"joinMode":"onlyRandom"');
expect(request).toContain('"limitMinutes":720'); expect(request).toContain('"limitMinutes":720');
@@ -1125,7 +1125,7 @@ test('edits server reset defaults through profile metadata settings', async ({ p
await page.goto('admin/servers/che%3Adefault'); await page.goto('admin/servers/che%3Adefault');
await page.getByText('서버 리셋 기본 옵션').click(); await page.getByText('서버 리셋 기본 옵션').click();
await page.getByTestId('meta-reset-turn-term').selectOption('10'); await page.getByTestId('meta-reset-turn-term').selectOption('3');
await page.getByTestId('meta-reset-npc-mode').selectOption('2'); await page.getByTestId('meta-reset-npc-mode').selectOption('2');
await page.getByRole('button', { name: '메타 저장' }).click(); await page.getByRole('button', { name: '메타 저장' }).click();
@@ -1172,7 +1172,7 @@ test('edits server reset defaults through profile metadata settings', async ({ p
state.requestBodies.find((entry) => entry.operation === 'admin.profiles.updateMeta')?.body state.requestBodies.find((entry) => entry.operation === 'admin.profiles.updateMeta')?.body
); );
expect(request).toContain('"resetDefaults"'); expect(request).toContain('"resetDefaults"');
expect(request).toContain('"turnTermMinutes":10'); expect(request).toContain('"turnTermMinutes":3');
expect(request).toContain('"npcMode":2'); expect(request).toContain('"npcMode":2');
}); });
@@ -1,4 +1,6 @@
export const PROFILE_TURN_TERM_MINUTES = [1, 2, 5, 10, 20, 30, 60, 120] as const; // A 12-month game year must fit an integer number of times in one wall-clock
// day, so every positive divisor of 120 is valid.
export const PROFILE_TURN_TERM_MINUTES = [1, 2, 3, 4, 5, 6, 8, 10, 12, 15, 20, 24, 30, 40, 60, 120] as const;
export const RESET_AUTORUN_OPTIONS = [ export const RESET_AUTORUN_OPTIONS = [
'develop', 'develop',
'warp', 'warp',
@@ -24,7 +26,7 @@ export const RESET_AUTORUN_LABELS: ReadonlyArray<{ value: ResetAutorunOption; la
export const RESET_OPTION_COPY = { export const RESET_OPTION_COPY = {
turnTerm: { turnTerm: {
label: '턴 시간(분)', label: '턴 시간(분)',
help: '한 턴이 진행되는 실제 시간입니다. ref 초기화 화면과 같은 분 단위 값입니다.', help: '한 턴이 진행되는 실제 시간입니다. 하루 경계를 정수 게임 연도로 유지하기 위해 120의 약수만 사용할 수 있습니다.',
}, },
sync: { sync: {
label: '시간 동기화', label: '시간 동기화',
@@ -8,6 +8,7 @@ import { useToast } from '../composables/useToast';
import AdminConsoleLayout from '../layouts/AdminConsoleLayout.vue'; import AdminConsoleLayout from '../layouts/AdminConsoleLayout.vue';
import { import {
normalizeProfileResetDefaults, normalizeProfileResetDefaults,
PROFILE_TURN_TERM_MINUTES,
RESET_AUTORUN_LABELS, RESET_AUTORUN_LABELS,
RESET_OPTION_COPY, RESET_OPTION_COPY,
SYSTEM_PROFILE_RESET_DEFAULTS, SYSTEM_PROFILE_RESET_DEFAULTS,
@@ -1078,11 +1079,7 @@ onBeforeUnmount(() => {
class="w-full rounded border border-zinc-700 bg-zinc-950 px-3 py-2 text-sm text-white" class="w-full rounded border border-zinc-700 bg-zinc-950 px-3 py-2 text-sm text-white"
data-testid="reset-turn-term" data-testid="reset-turn-term"
> >
<option <option v-for="minutes in PROFILE_TURN_TERM_MINUTES" :key="minutes" :value="minutes">
v-for="minutes in [1, 2, 3, 4, 5, 6, 10, 12, 15, 20, 24, 30, 40, 60, 120]"
:key="minutes"
:value="minutes"
>
{{ minutes }} {{ minutes }}
</option> </option>
</select> </select>
+2
View File
@@ -71,6 +71,8 @@ Gateway 관리자 콘솔은 `/gateway/admin`에서 시작합니다. 공개 로
턴 간격, 동기화, 가상 장수, 연장, 가입 방식, 장수 생성 제한, NPC, 이미지, 턴 간격, 동기화, 가상 장수, 연장, 가입 방식, 장수 생성 제한, NPC, 이미지,
토너먼트와 유저 자동턴 기본값을 저장합니다. 시나리오 초기화 화면은 대상 토너먼트와 유저 자동턴 기본값을 저장합니다. 시나리오 초기화 화면은 대상
서버를 URL에서 결정한 뒤 이 메타를 별도 권한 검사로 읽어 폼에 적용합니다. 서버를 URL에서 결정한 뒤 이 메타를 별도 권한 검사로 읽어 폼에 적용합니다.
턴 간격은 하루를 `12개월 × 턴 간격`의 정수 개 게임 연도로 나눌 수 있는
120의 양의 약수만 허용하므로 3분과 8분도 유효합니다.
유저 자동턴은 내정, 이동, 징병, 고급 징병, 훈련, 전투, 중신의 7개 동작을 유저 자동턴은 내정, 이동, 징병, 고급 징병, 훈련, 전투, 중신의 7개 동작을
각각 보존해 RESET payload로 전달합니다. 각각 보존해 RESET payload로 전달합니다.
메타가 없거나 유효하지 않으면 기존 시스템 기본값을 사용합니다. 시나리오와 메타가 없거나 유효하지 않으면 기존 시스템 기본값을 사용합니다. 시나리오와
+4 -3
View File
@@ -80,10 +80,11 @@ validating one representative button.
The primitive does not set a fixed `min-height`: Ref's 35.5px default height is 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 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. 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 A fixed row opts into `.legacy-button--fixed-height` and supplies only
45px/44px/43px state compensation. `--legacy-button-height`; the shared layer derives the hover/active heights so
every owner keeps the same bottom-coordinate contract.
Only layout belongs in the SFC: width, grid column, fixed-height compensation, Only layout belongs in the SFC: width, grid column, the fixed height variable,
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
border construction, font weight, hover/focus/active, and disabled presentation border construction, font weight, hover/focus/active, and disabled presentation
+2
View File
@@ -175,6 +175,8 @@ migration과 scenario seed부터 진행합니다. 상위 권한자가 버전 업
장수·연장·토너먼트 사용, 기본 NPC, 이미지 단계 3, 전체 가입과 유저 자동턴 장수·연장·토너먼트 사용, 기본 NPC, 이미지 단계 3, 전체 가입과 유저 자동턴
미사용이라는 기존 시스템 기본값으로 돌아갑니다. 실행마다 달라지는 scenario와 미사용이라는 기존 시스템 기본값으로 돌아갑니다. 실행마다 달라지는 scenario와
예약·가오픈·정식 오픈 시각은 자동 입력하지 않습니다. 예약·가오픈·정식 오픈 시각은 자동 입력하지 않습니다.
턴 간격은 120의 양의 약수만 허용하며 3분과 8분도 RESET·저장 기본값·실행 중
변경에서 동일하게 사용할 수 있습니다.
이 모드는 build와 migration 후 기존 season/tick metadata를 읽고 scenario seeder를 실행합니다. 이 모드는 build와 migration 후 기존 season/tick metadata를 읽고 scenario seeder를 실행합니다.
빈 profile schema도 migration을 먼저 적용하므로 최초 `world_state` 조회가 빈 profile schema도 migration을 먼저 적용하므로 최초 `world_state` 조회가
@@ -190,6 +190,84 @@ test.beforeEach(async ({ page }) => {
await installFixture(page); await installFixture(page);
}); });
test('tournament controls share fixed Lumen state geometry on desktop and mobile', async ({ page }, testInfo) => {
const evidence: Record<string, unknown> = {};
for (const viewport of [
{ width: 1024, height: 768 },
{ width: 500, height: 900 },
]) {
await page.setViewportSize(viewport);
await page.goto(`${gameUrl}/che/tournament`);
await expect(page.getByText('삼모전 토너먼트')).toBeVisible();
const controls = [
page.getByRole('tab', { name: '토너먼트' }),
page.getByRole('tab', { name: '베팅장' }),
page.getByRole('button', { name: '창 닫기' }).first(),
page.getByRole('button', { name: '갱신' }),
];
const viewportEvidence: Record<string, unknown> = {};
for (const control of controls) {
const label = (await control.textContent())?.trim() ?? 'unknown';
await control.scrollIntoViewIfNeeded();
await expect(control).toHaveClass(/legacy-button--fixed-height/u);
const measure = () =>
control.evaluate((element) => {
const rect = element.getBoundingClientRect();
const style = getComputedStyle(element);
return {
top: rect.top,
bottom: rect.bottom,
height: rect.height,
marginTop: style.marginTop,
borderBottomWidth: style.borderBottomWidth,
borderRadius: style.borderRadius,
backgroundColor: style.backgroundColor,
fontFamily: style.fontFamily,
fontSize: style.fontSize,
};
});
await page.mouse.move(viewport.width - 1, viewport.height - 1);
const base = await measure();
expect(base).toMatchObject({
height: 44,
marginTop: '0px',
borderBottomWidth: '4px',
borderRadius: '5.25px',
fontSize: '14px',
});
expect(base.fontFamily).toContain('Pretendard');
await control.hover();
const hover = await measure();
expect(hover).toMatchObject({ height: 43, marginTop: '1px', borderBottomWidth: '3px' });
expect(hover.bottom).toBeCloseTo(base.bottom, 2);
const box = await control.boundingBox();
if (!box) throw new Error(`${label} tournament control is not measurable`);
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
await page.mouse.down();
const active = await measure();
expect(active).toMatchObject({ height: 42, marginTop: '2px', borderBottomWidth: '2px' });
expect(active.bottom).toBeCloseTo(base.bottom, 2);
await page.mouse.move(viewport.width - 1, viewport.height - 1);
await page.mouse.up();
viewportEvidence[label] = { default: base, hover, active };
}
evidence[`${viewport.width}x${viewport.height}`] = viewportEvidence;
if (artifactDir) {
await page.screenshot({
path: `${artifactDir}/core-tournament-buttons-${viewport.width}.png`,
fullPage: true,
});
}
}
await testInfo.attach('tournament-button-geometry', {
body: JSON.stringify(evidence, null, 2),
contentType: 'application/json',
});
});
test('tournament keeps the legacy 2000px bracket and 250px group geometry', async ({ page }) => { test('tournament keeps the legacy 2000px bracket and 250px group geometry', async ({ page }) => {
await page.setViewportSize({ width: 2200, height: 1000 }); await page.setViewportSize({ width: 2200, height: 1000 });
await page.goto(`${gameUrl}/che/tournament`); await page.goto(`${gameUrl}/che/tournament`);
@@ -210,6 +288,9 @@ test('tournament keeps the legacy 2000px bracket and 250px group geometry', asyn
groupWidth: groupTable.getBoundingClientRect().width, groupWidth: groupTable.getBoundingClientRect().width,
titleHeight: title.getBoundingClientRect().height, titleHeight: title.getBoundingClientRect().height,
refreshHeight: refresh.getBoundingClientRect().height, refreshHeight: refresh.getBoundingClientRect().height,
refreshBottom: refresh.getBoundingClientRect().bottom,
refreshMarginTop: getComputedStyle(refresh).marginTop,
refreshEdge: getComputedStyle(refresh).borderBottomWidth,
refreshRadius: getComputedStyle(refresh).borderRadius, refreshRadius: getComputedStyle(refresh).borderRadius,
fontFamily: style.fontFamily, fontFamily: style.fontFamily,
fontSize: style.fontSize, fontSize: style.fontSize,
@@ -222,7 +303,9 @@ test('tournament keeps the legacy 2000px bracket and 250px group geometry', asyn
candidateWidth: 125, candidateWidth: 125,
groupWidth: 250, groupWidth: 250,
titleHeight: 55.6875, titleHeight: 55.6875,
refreshHeight: 35.5, refreshHeight: 44,
refreshMarginTop: '0px',
refreshEdge: '4px',
refreshRadius: '5.25px', refreshRadius: '5.25px',
fontSize: '14px', fontSize: '14px',
}); });
@@ -231,12 +314,39 @@ test('tournament keeps the legacy 2000px bracket and 250px group geometry', asyn
if (artifactDir) await page.screenshot({ path: `${artifactDir}/core-tournament.png`, fullPage: true }); if (artifactDir) await page.screenshot({ path: `${artifactDir}/core-tournament.png`, fullPage: true });
const refresh = page.getByRole('button', { name: '갱신' }); const refresh = page.getByRole('button', { name: '갱신' });
const before = await refresh.evaluate((element) => getComputedStyle(element).filter);
await refresh.hover(); await refresh.hover();
const hover = await refresh.evaluate((element) => getComputedStyle(element).filter); const hover = await refresh.evaluate((element) => {
const rect = element.getBoundingClientRect();
const style = getComputedStyle(element);
return {
bottom: rect.bottom,
height: rect.height,
marginTop: style.marginTop,
borderBottomWidth: style.borderBottomWidth,
filter: style.filter,
};
});
expect(hover).toMatchObject({ height: 43, marginTop: '1px', borderBottomWidth: '3px', filter: 'none' });
expect(hover.bottom).toBeCloseTo(geometry.refreshBottom, 2);
const box = await refresh.boundingBox();
if (!box) throw new Error('tournament refresh is not measurable');
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
await page.mouse.down();
const active = await refresh.evaluate((element) => {
const rect = element.getBoundingClientRect();
const style = getComputedStyle(element);
return {
bottom: rect.bottom,
height: rect.height,
marginTop: style.marginTop,
borderBottomWidth: style.borderBottomWidth,
};
});
expect(active).toMatchObject({ height: 42, marginTop: '2px', borderBottomWidth: '2px' });
expect(active.bottom).toBeCloseTo(geometry.refreshBottom, 2);
await page.mouse.up();
await refresh.focus(); await refresh.focus();
await expect(refresh).toBeFocused(); await expect(refresh).toBeFocused();
expect(hover).not.toBe(before);
}); });
test('tournament keeps the fixed legacy canvas at a 1024px viewport', async ({ page }) => { test('tournament keeps the fixed legacy canvas at a 1024px viewport', async ({ page }) => {