merge: 최신 main을 가오픈 서버 시계 수정에 통합한다

This commit is contained in:
2026-08-21 16:57:52 +00:00
43 changed files with 1643 additions and 481 deletions
+3 -2
View File
@@ -175,13 +175,14 @@ export const worldRouter = router({
const turns = generalIds.length
? await ctx.db.generalTurn.findMany({
where: { generalId: { in: generalIds }, turnIdx: { lt: 5 } },
select: { generalId: true, turnIdx: true, actionCode: true, arg: true },
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) {
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);
}
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
@@ -350,7 +350,7 @@ describeDb('scenario database seed', () => {
databaseUrl,
now: new Date('2030-01-01T00:00:00Z'),
installOptions: {
turnTermMinutes: 60,
turnTermMinutes: 3,
sync: false,
fiction: 1,
extend: false,
@@ -386,7 +386,7 @@ describeDb('scenario database seed', () => {
if (!worldState) {
return;
}
expect(worldState.tickSeconds).toBe(3600);
expect(worldState.tickSeconds).toBe(180);
expect(worldState.currentMonth).toBe(1);
expect(worldState.clockWallAnchor).toEqual(new Date('2030-01-01T02:00:00.000Z'));
@@ -399,7 +399,7 @@ describeDb('scenario database seed', () => {
expect(meta.develcost).toBe(
(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 autorunOptions = (autorun.options ?? {}) as Record<string, unknown>;
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 === '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') {
return response({ turns: [], revision: 0 });
}
@@ -373,7 +410,12 @@ const install = async (
crew: 500,
train: 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
? 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 reservedTurns = rows.nth(0).locator('.turns');
const npcTurns = rows.nth(1).locator('.turns');
await expect(reservedTurns).toContainText('1 : 징병');
await expect(reservedTurns).toContainText('2 : 훈련');
await expect(reservedTurns).toContainText('1 : 【보병】 300명 징병');
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(npcTurns).toHaveText('NPC 장수');
await expect(npcTurns).not.toHaveClass(/turns--reserved/);
+24 -26
View File
@@ -38,6 +38,18 @@ const readGeneralPanelImages = async (panel: Locator) =>
})
);
const readGeneralSummaryRows = async (summary: Locator) =>
summary.evaluate((element) => {
const rows = new Map<number, string[]>();
for (const label of element.querySelectorAll<HTMLElement>(':scope > span')) {
const top = Math.round(label.getBoundingClientRect().top * 100) / 100;
const row = rows.get(top) ?? [];
row.push(label.textContent?.trim() ?? '');
rows.set(top, row);
}
return [...rows.values()];
});
type FixtureState = {
permission: 'head' | 'member';
myset: number;
@@ -1242,19 +1254,12 @@ test('내 정보&설정 keeps desktop density and becomes a 390px horizontal-ide
await expect(page.locator('.battle-general-extra')).toContainText('피살6,789');
await expect(page.locator('.battle-general-extra__recent-value')).toHaveText('01-01 00:00');
await expect(page.locator('.legacy-general-details')).toHaveCount(0);
await expect(page.locator('.battle-general-extra > span')).toHaveText([
'명성',
'계',
'전투',
'승리',
'패배',
'계략',
'사관',
'사살',
'피살',
'승률',
'살상률',
'최근 전투',
expect(await readGeneralSummaryRows(page.locator('.battle-general-extra'))).toEqual([
['명성', '계급', ''],
['전투', '계', '사관'],
['승률', '승리', '패배'],
['살상률', '사살', '피살'],
['최근 전투'],
]);
await expect(page.locator('.item-group')).toContainText('명마');
await expect(page.locator('#container')).not.toContainText('che_');
@@ -2043,19 +2048,12 @@ test('감찰부 keeps the selector interaction and shows the permission error pa
await expect(page.locator('.battle-general-extra')).toContainText('사관4년');
await expect(page.locator('.battle-general-extra')).toContainText('승률62.50%');
await expect(page.locator('.battle-general-extra')).toContainText('살상률181.84%');
await expect(page.locator('.battle-general-extra > span')).toHaveText([
'명성',
'계',
'전투',
'승리',
'패배',
'계략',
'사관',
'사살',
'피살',
'승률',
'살상률',
'최근 전투',
expect(await readGeneralSummaryRows(page.locator('.battle-general-extra'))).toEqual([
['명성', '계급', ''],
['전투', '계', '사관'],
['승률', '승리', '패배'],
['살상률', '사살', '피살'],
['최근 전투'],
]);
const battleImages = await readGeneralPanelImages(page.locator('.battle-general-card'));
expect(battleImages).toHaveLength(2);
+351 -4
View File
@@ -15,6 +15,7 @@ const errorResponse = (path: string, message: string) => ({
});
const artifactRoot = process.env.MAIN_NAVIGATION_ARTIFACT_DIR;
const autoRefreshArtifactRoot = process.env.AUTO_REFRESH_ARTIFACT_DIR;
const mapSeasonArtifactRoot = process.env.MAP_SEASON_ARTIFACT_DIR;
const productionBundle = process.env.PLAYWRIGHT_FRONTEND_MODE === 'production';
const basePath = `/${(process.env.PLAYWRIGHT_GAME_BASE_PATH ?? 'che').replace(/^\/+|\/+$/g, '')}`;
const gameProfile = process.env.PLAYWRIGHT_GAME_PROFILE ?? 'che:default';
@@ -55,6 +56,10 @@ type NavigationFixture = {
refCommandCategories?: boolean;
currentYear?: number;
currentMonth?: number;
mapName?: string;
validMapImages?: boolean;
mapImageGate?: Promise<void>;
imageRequests?: string[];
serverId?: string;
profile?: string;
gameIdx?: number;
@@ -146,6 +151,13 @@ const emitReadModelInvalidation = (page: Page, invalidation: ReturnType<typeof r
);
}, invalidation);
const waitForMainRealtime = (page: Page) =>
expect
.poll(() =>
page.evaluate(() => (window as unknown as { __hasMainRealtime: () => boolean }).__hasMainRealtime())
)
.toBe(true);
const refCommandCategoryFixture = ['개인', '내정', '군사', '인사', '계략', '국가'].map((category, index) => ({
category,
values: [
@@ -506,9 +518,29 @@ const installFixture = async (page: Page, state: NavigationFixture) => {
{ profile: gameProfile }
);
await page.route('**/image/**', async (route) => {
await route.fulfill({ status: 200, contentType: 'image/jpeg', body: Buffer.from('') });
page.on('request', (request) => {
const url = new URL(request.url());
if (url.pathname.includes('/image/') || url.hostname === 'sam-image.hided.net') {
(state.imageRequests ??= []).push(url.pathname);
}
});
const handleImageRoute = async (route: Route) => {
await state.mapImageGate;
if (state.validMapImages) {
const transparentPixel = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M/wHwAEAQH/69aQ6wAAAABJRU5ErkJggg==',
'base64'
);
await route.fulfill({ status: 200, contentType: 'image/png', body: transparentPixel });
return;
}
await route.fulfill({ status: 200, contentType: 'image/jpeg', body: Buffer.from('') });
};
if (process.env.SAMMO_E2E_REAL_MAP_ASSETS !== '1') {
await page.route('**/image/**', handleImageRoute);
await page.route('https://sam-image.hided.net/game/**', handleImageRoute);
}
await page.route('**/events**', async (route) => {
await route.abort();
});
@@ -663,7 +695,7 @@ const installFixture = async (page: Page, state: NavigationFixture) => {
}
if (operation === 'world.getMapLayout') {
return response({
mapName: 'che',
mapName: state.mapName ?? 'che',
cityList: [{ id: 1, name: '업', level: 8, region: 1, x: 200, y: 120, path: [] }],
regionMap: { 1: '하북' },
levelMap: { 8: '특' },
@@ -2228,7 +2260,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 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');
await expect(picker).toBeVisible();
// The trigger can end up directly above a newly opened category button.
@@ -4224,6 +4292,285 @@ test('global activity, world history, and a month boundary refresh their visible
);
});
test('seasonal map decodes the next background and crossfades it without remounting map content', async ({ page }) => {
const useRealAssets = process.env.SAMMO_E2E_REAL_MAP_ASSETS === '1';
let releaseInitialImages: () => void = () => undefined;
const initialImageGate = new Promise<void>((resolveGate) => {
releaseInitialImages = resolveGate;
});
const state: NavigationFixture = {
officerLevel: 5,
permission: 2,
nationLevel: 3,
stage: 0,
npcMode: 1,
generalMeCalls: 0,
operations: [],
currentMonth: 3,
validMapImages: true,
mapImageGate: useRealAssets ? undefined : initialImageGate,
};
await installRealtimeHarness(page);
await installFixture(page, state);
await page.setViewportSize({ width: 1200, height: 900 });
await waitForMain(page);
await waitForMainRealtime(page);
const map = page.locator('[data-main-target="map"] .map-viewer').first();
if (!useRealAssets) {
await expect(map.locator('.skeleton-line')).toHaveCount(4);
await expect(map.locator('.map-area')).toHaveCount(0);
releaseInitialImages();
state.mapImageGate = undefined;
}
const currentLayer = map.locator('[data-map-background-layer="current"]');
const outgoingLayer = map.locator('[data-map-background-layer="outgoing"]');
await expect(currentLayer.locator('img')).toHaveAttribute('src', /bg_spring\.jpg/u);
await expect(map.locator('.map-area')).toBeVisible();
await expect
.poll(() => state.imageRequests?.some((url) => url.endsWith('/game/map/che/bg_summer.jpg')) ?? false)
.toBe(true);
const initialGeometry = await map.locator('.map-area').evaluate((area) => {
const rect = area.getBoundingClientRect();
const road = area.querySelector('.map-bgroad');
const city = area.querySelector('.city-base');
Object.defineProperty(window, '__mapSeasonTransitionProbe', {
configurable: true,
value: { area, road, city },
});
return { width: rect.width, height: rect.height };
});
expect(initialGeometry).toEqual({ width: 700, height: 500 });
if (mapSeasonArtifactRoot) {
await mkdir(mapSeasonArtifactRoot, { recursive: true });
await map.screenshot({ path: resolve(mapSeasonArtifactRoot, 'season-spring-initial.png') });
}
state.currentMonth = 4;
await emitReadModelInvalidation(page, readModelInvalidation({ lobby: true, map: true }));
await expect(map).toContainText('185年 4月');
await expect(outgoingLayer).toHaveClass(/is-transitioning/u);
await expect(outgoingLayer).toHaveCSS('transition-duration', '0.48s');
let midpointOpacity = 0;
await expect
.poll(
async () => {
midpointOpacity = Number.parseFloat(
await outgoingLayer.evaluate((element) => getComputedStyle(element).opacity)
);
return midpointOpacity > 0 && midpointOpacity < 1;
},
{ intervals: [16, 16, 16, 16, 16, 16], timeout: 350 }
)
.toBe(true);
await expect(currentLayer.locator('img')).toHaveAttribute('src', /bg_summer\.jpg/u);
await expect(outgoingLayer.locator('img')).toHaveAttribute('src', /bg_spring\.jpg/u);
if (mapSeasonArtifactRoot) {
await map.screenshot({ path: resolve(mapSeasonArtifactRoot, 'season-spring-to-summer-midpoint.png') });
}
await expect(outgoingLayer).not.toHaveClass(/is-transitioning/u);
await expect(outgoingLayer.locator('img')).toHaveCount(0);
const finalState = await map.locator('.map-area').evaluate((area) => {
const probe = (
window as unknown as {
__mapSeasonTransitionProbe: { area: Element; road: Element | null; city: Element | null };
}
).__mapSeasonTransitionProbe;
const rect = area.getBoundingClientRect();
return {
areaMounted: probe.area === area,
roadMounted: probe.road === area.querySelector('.map-bgroad'),
cityMounted: probe.city === area.querySelector('.city-base'),
width: rect.width,
height: rect.height,
};
});
expect(finalState).toEqual({
areaMounted: true,
roadMounted: true,
cityMounted: true,
width: 700,
height: 500,
});
if (mapSeasonArtifactRoot) {
await map.screenshot({ path: resolve(mapSeasonArtifactRoot, 'season-summer-complete.png') });
}
await page.setViewportSize({ width: 500, height: 900 });
await expect
.poll(() =>
map.locator('.map-area').evaluate((area) => {
const rect = area.getBoundingClientRect();
return { width: rect.width, height: rect.height };
})
)
.toEqual({ width: 500, height: 357.140625 });
await map.locator('.map-area').evaluate((area) => {
Object.defineProperty(window, '__mobileMapSeasonTransitionProbe', {
configurable: true,
value: area,
});
});
state.currentMonth = 7;
await emitReadModelInvalidation(page, readModelInvalidation({ lobby: true, map: true }));
await expect(map).toContainText('185年 7月');
await expect(currentLayer.locator('img')).toHaveAttribute('src', /bg_fall\.jpg/u);
await expect(outgoingLayer).not.toHaveClass(/is-transitioning/u);
await expect(outgoingLayer.locator('img')).toHaveCount(0);
expect(
await map.locator('.map-area').evaluate((area) => {
const probe = (window as unknown as { __mobileMapSeasonTransitionProbe: Element })
.__mobileMapSeasonTransitionProbe;
const rect = area.getBoundingClientRect();
return {
areaMounted: probe === area,
width: rect.width,
height: rect.height,
};
})
).toEqual({ areaMounted: true, width: 500, height: 357.140625 });
if (mapSeasonArtifactRoot) {
await map.screenshot({ path: resolve(mapSeasonArtifactRoot, 'season-fall-mobile-complete.png') });
}
});
test('reduced-motion map swaps the decoded seasonal background without a fade', async ({ page }) => {
const state: NavigationFixture = {
officerLevel: 5,
permission: 2,
nationLevel: 3,
stage: 0,
npcMode: 1,
generalMeCalls: 0,
operations: [],
currentMonth: 3,
validMapImages: true,
};
await page.emulateMedia({ reducedMotion: 'reduce' });
await installRealtimeHarness(page);
await installFixture(page, state);
await page.setViewportSize({ width: 1200, height: 900 });
await waitForMain(page);
await waitForMainRealtime(page);
const map = page.locator('[data-main-target="map"] .map-viewer').first();
const currentLayer = map.locator('[data-map-background-layer="current"]');
const outgoingLayer = map.locator('[data-map-background-layer="outgoing"]');
await expect(currentLayer.locator('img')).toHaveAttribute('src', /bg_spring\.jpg/u);
await outgoingLayer.evaluate((outgoing) => {
let transitionCount = 0;
const observer = new MutationObserver(() => {
if (outgoing.classList.contains('is-transitioning')) transitionCount += 1;
});
observer.observe(outgoing, { attributes: true, attributeFilter: ['class'] });
Object.defineProperty(window, '__reducedMotionMapProbe', {
configurable: true,
value: {
observer,
get transitionCount() {
return transitionCount;
},
},
});
});
state.currentMonth = 4;
await emitReadModelInvalidation(page, readModelInvalidation({ lobby: true, map: true }));
await expect(map).toContainText('185年 4月');
await expect(currentLayer.locator('img')).toHaveAttribute('src', /bg_summer\.jpg/u);
await page.waitForTimeout(100);
await expect(outgoingLayer).not.toHaveClass(/is-transitioning/u);
await expect(outgoingLayer.locator('img')).toHaveCount(0);
expect(
await outgoingLayer.evaluate(() => {
const probe = (
window as unknown as {
__reducedMotionMapProbe: { observer: MutationObserver; transitionCount: number };
}
).__reducedMotionMapProbe;
probe.observer.disconnect();
return probe.transitionCount;
})
).toBe(0);
});
test('seasonless map keeps its fixed background and does not start a month-boundary crossfade', async ({ page }) => {
const state: NavigationFixture = {
officerLevel: 5,
permission: 2,
nationLevel: 3,
stage: 0,
npcMode: 1,
generalMeCalls: 0,
operations: [],
currentMonth: 3,
mapName: 'ludo_rathowm',
validMapImages: true,
};
await installRealtimeHarness(page);
await installFixture(page, state);
await page.setViewportSize({ width: 1200, height: 900 });
await waitForMain(page);
await waitForMainRealtime(page);
const map = page.locator('[data-main-target="map"] .map-viewer').first();
const currentLayer = map.locator('[data-map-background-layer="current"]');
const outgoingLayer = map.locator('[data-map-background-layer="outgoing"]');
await expect(currentLayer.locator('img')).toHaveAttribute('src', /map\/ludo_rathowm\/back\.jpg/u);
await map.locator('.map-area').evaluate((area) => {
const outgoing = area.querySelector('[data-map-background-layer="outgoing"]');
let transitionCount = 0;
const observer = new MutationObserver(() => {
if (outgoing?.classList.contains('is-transitioning')) transitionCount += 1;
});
if (outgoing) observer.observe(outgoing, { attributes: true, attributeFilter: ['class'] });
Object.defineProperty(window, '__seasonlessMapProbe', {
configurable: true,
value: {
area,
outgoing,
observer,
get transitionCount() {
return transitionCount;
},
},
});
});
state.currentMonth = 4;
await emitReadModelInvalidation(page, readModelInvalidation({ lobby: true, map: true }));
await expect(map).toContainText('185年 4月');
await page.waitForTimeout(650);
await expect(currentLayer.locator('img')).toHaveAttribute('src', /map\/ludo_rathowm\/back\.jpg/u);
await expect(outgoingLayer).not.toHaveClass(/is-transitioning/u);
await expect(outgoingLayer.locator('img')).toHaveCount(0);
expect(state.imageRequests?.some((url) => url.includes('/game/map/che/bg_summer.jpg')) ?? false).toBe(false);
expect(
await map.locator('.map-area').evaluate((area) => {
const probe = (
window as unknown as {
__seasonlessMapProbe: {
area: Element;
observer: MutationObserver;
transitionCount: number;
};
}
).__seasonlessMapProbe;
probe.observer.disconnect();
return { areaMounted: probe.area === area, transitionCount: probe.transitionCount };
})
).toEqual({ areaMounted: true, transitionCount: 0 });
if (mapSeasonArtifactRoot) {
await map.screenshot({ path: resolve(mapSeasonArtifactRoot, 'seasonless-ludo-complete.png') });
}
});
test('same-account main tabs share one realtime diff and exclude a tab while sync is off', async ({
context,
page,
@@ -205,6 +205,86 @@ test('nation generals keeps the 1000px legacy grid and redacted member columns',
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) => {
await install(page);
await page.setViewportSize({ width: 1200, height: 900 });
@@ -405,17 +485,20 @@ test('secret office renders five Ref-style command briefs and the forbidden erro
'5 : 휴식',
]);
await expect(commandRows.nth(2)).toHaveAttribute('title', '【다른장수】에게 쌀 200을 증여');
const geometry = await page.locator('#secret-general-list .turns').first().evaluate((element) => {
const rect = element.getBoundingClientRect();
const style = getComputedStyle(element);
return {
width: rect.width,
height: rect.height,
fontSize: style.fontSize,
textAlign: style.textAlign,
horizontalOverflow: element.scrollWidth - element.clientWidth,
};
});
const geometry = await page
.locator('#secret-general-list .turns')
.first()
.evaluate((element) => {
const rect = element.getBoundingClientRect();
const style = getComputedStyle(element);
return {
width: rect.width,
height: rect.height,
fontSize: style.fontSize,
textAlign: style.textAlign,
horizontalOverflow: element.scrollWidth - element.clientWidth,
};
});
expect(geometry.width).toBeGreaterThanOrEqual(190);
expect(geometry.width).toBeLessThanOrEqual(230);
expect(geometry.height).toBeGreaterThanOrEqual(60);
+2 -2
View File
@@ -278,8 +278,8 @@ test('past plays is available without a current general and preserves desktop in
await expect(detailToggle).toHaveAttribute('aria-expanded', 'true');
await expect(page.locator('.archive-general-card')).toHaveAttribute('data-general-basic-card', '');
await expect(page.locator('.archive-general-card [role="progressbar"]')).toHaveCount(14);
await expect(page.locator('[data-general-battle-summary]')).toContainText('승률62.5%');
await expect(page.locator('[data-general-battle-summary]')).toContainText('살상률75.0%');
await expect(page.locator('[data-general-battle-summary]')).toContainText('승률62.50%');
await expect(page.locator('[data-general-battle-summary]')).toContainText('살상률75.00%');
const hallBattle = page.locator('[data-hall-battle-record]');
await expect(hallBattle).toContainText('명예의 전당 보존 기록');
await expect(hallBattle).toContainText('항목별 기록 시점이 서로 다를 수 있습니다.');
@@ -395,7 +395,10 @@ test('join refresh shows the assigned preliminary group immediately with accessi
await refresh.focus();
await expect(refresh).toBeFocused();
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);
});
@@ -602,7 +605,11 @@ test('mobile betting rankings use tabs and keep dedicated icons beside general n
await expect(dialog.getByText('예상 환수금 280')).toBeVisible();
await dialog.getByLabel('베팅 금액').selectOption('50');
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 expect(dialog).not.toBeVisible();
await expect(page.getByRole('status')).toHaveText('베팅이 등록되었습니다.');
@@ -144,7 +144,7 @@
/*
* Ref Bootstrap 5.2 + Lumen button family. This class owns the common raised
* 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.
*/
.legacy-button:is(
@@ -173,6 +173,15 @@
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-bg: var(--sammo-button-secondary-bg);
--legacy-button-border: var(--sammo-button-secondary-border);
@@ -213,6 +222,10 @@
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--lumen,
.legacy-button--primary,
@@ -244,6 +257,10 @@
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--lumen,
.legacy-button--primary,
@@ -588,7 +588,13 @@ const clickOutsideMenu = (event: Event) => {
</button>
</div>
</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 class="queue-area">
@@ -806,8 +812,7 @@ const clickOutsideMenu = (event: Event) => {
}
.control-pad > button,
.clock,
.legacy-menu > summary,
.select-command {
.legacy-menu > summary {
box-sizing: border-box;
min-height: 34px;
border: 0;
@@ -822,6 +827,12 @@ const clickOutsideMenu = (event: Event) => {
cursor: pointer;
list-style: none;
}
.select-command {
--legacy-button-height: 34px;
display: grid;
place-items: center;
padding: 4px;
}
.clock {
background: #345c85;
font-variant-numeric: tabular-nums;
@@ -1024,9 +1035,6 @@ const clickOutsideMenu = (event: Event) => {
grid-template-columns: 5fr 7fr;
order: 1;
}
.advanced-actions > * {
border-radius: 0 !important;
}
.bottom-actions {
display: grid;
grid-template-columns: repeat(3, 1fr);
@@ -61,11 +61,12 @@ const killRate = computed(() => {
</div>
<template v-else>
<span>명성</span><strong>{{ numberText(summary.experience) }}</strong> <span>계급</span
><strong>{{ summary.dedicationText || '-' }}</strong> <span>전투</span
><strong>{{ summary.dedicationText || '-' }}</strong>
<span class="battle-general-extra__empty" aria-hidden="true"></span>
<strong class="battle-general-extra__empty" aria-hidden="true"></strong>
<span>전투</span
><strong>{{ numberText(summary.warnum) }}<template v-if="summary.warnum != null"></template></strong>
<span>승리</span><strong>{{ numberText(summary.wins) }}</strong> <span>패배</span
><strong>{{ numberText(summary.losses) }}</strong> <span>계략</span
><strong>{{ numberText(summary.strategies) }}</strong>
<span>계략</span><strong>{{ numberText(summary.strategies) }}</strong>
<template v-if="summary.serviceYears !== undefined">
<span>사관</span
><strong
@@ -73,11 +74,28 @@ const killRate = computed(() => {
}}<template v-if="summary.serviceYears != null"></template></strong
>
</template>
<template v-else>
<span class="battle-general-extra__empty" aria-hidden="true"></span>
<strong class="battle-general-extra__empty" aria-hidden="true"></strong>
</template>
<template v-if="showWinRate">
<span>승률</span><strong>{{ winRate }}</strong>
</template>
<template v-else>
<span class="battle-general-extra__empty" aria-hidden="true"></span>
<strong class="battle-general-extra__empty" aria-hidden="true"></strong>
</template>
<span>승리</span><strong>{{ numberText(summary.wins) }}</strong> <span>패배</span
><strong>{{ numberText(summary.losses) }}</strong>
<template v-if="showWinRate">
<span>살상률</span><strong>{{ killRate }}</strong>
</template>
<template v-else>
<span class="battle-general-extra__empty" aria-hidden="true"></span>
<strong class="battle-general-extra__empty" aria-hidden="true"></strong>
</template>
<span>사살</span><strong>{{ numberText(summary.killCrew) }}</strong> <span>피살</span
><strong>{{ numberText(summary.deathCrew) }}</strong>
<template v-if="showWinRate">
<span>승률</span><strong>{{ winRate }}</strong> <span>살상률</span><strong>{{ killRate }}</strong>
</template>
<span class="battle-general-extra__recent-label">최근 전투</span>
<strong class="battle-general-extra__recent-value">
{{ formatServerDateTime(summary.recentWar, { format: 'monthDayTime', fallback: '-' }) }}
@@ -113,6 +131,10 @@ const killRate = computed(() => {
white-space: nowrap;
}
.battle-general-extra > .battle-general-extra__empty {
color: transparent;
}
.battle-general-extra > .battle-general-extra__recent-label {
grid-column: 1;
}
@@ -61,7 +61,7 @@ const onAction = (action: NonNullable<MainNavigationLinkItem['action']>) => {
>
<div class="bottom-item">
<button
class="bottom-trigger legacy-button legacy-button--navigation"
class="bottom-trigger legacy-button legacy-button--navigation legacy-button--fixed-height"
type="button"
data-bottom-menu="global"
:aria-expanded="openId === 'global'"
@@ -132,7 +132,7 @@ const onAction = (action: NonNullable<MainNavigationLinkItem['action']>) => {
<div class="bottom-item nation-bottom-item">
<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="{
'--legacy-button-bg': nationMenuColor,
'--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">
<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"
data-bottom-menu="quick"
:aria-expanded="openId === 'quick'"
@@ -221,7 +221,7 @@ const onAction = (action: NonNullable<MainNavigationLinkItem['action']>) => {
<div class="bottom-refresh-controls">
<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 }"
type="button"
data-bottom-menu="auto-refresh"
@@ -232,7 +232,7 @@ const onAction = (action: NonNullable<MainNavigationLinkItem['action']>) => {
<strong>{{ realtimeEnabled ? 'ON' : 'OFF' }}</strong>
</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"
data-bottom-menu="manual-refresh"
aria-label="직접 갱신"
@@ -279,9 +279,9 @@ const onAction = (action: NonNullable<MainNavigationLinkItem['action']>) => {
}
.bottom-trigger {
--legacy-button-height: 45px;
box-sizing: border-box;
width: 125px;
height: 45px;
padding: 6px 4px;
font-family: inherit;
font-size: 16px;
@@ -316,15 +316,6 @@ const onAction = (action: NonNullable<MainNavigationLinkItem['action']>) => {
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 {
display: inline-block;
margin-left: 4px;
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, ref, useId } from 'vue';
import { computed, nextTick, onBeforeUnmount, ref, useId, watch } from 'vue';
import { storeToRefs } from 'pinia';
import { onClickOutside, useElementSize, useMediaQuery, useMouseInElement } from '@vueuse/core';
import SkeletonLines from '../ui/SkeletonLines.vue';
@@ -7,6 +7,7 @@ import MapCityBasic from './MapCityBasic.vue';
import MapCityDetail from './MapCityDetail.vue';
import { useMapViewerStore } from '../../stores/mapViewer';
import { buildAssetUrl } from '../../utils/mapAssets';
import { resolveMapBackgroundPath, resolveMapSeason, resolveNextMapSeason } from '../../utils/mapBackground';
import { configuredGameAssetUrl } from '../../utils/imageAssets';
interface MapSummary {
@@ -90,6 +91,45 @@ const emit = defineEmits<{
const BASE_MAP_WIDTH = 700;
const BASE_MAP_HEIGHT = 500;
const SMALL_MAP_SCALE = 5 / 7;
const MAP_BACKGROUND_TRANSITION_MS = 480;
const decodedImageCache = new Map<string, Promise<void>>();
const decodedImageElements = new Map<string, HTMLImageElement>();
const preloadDecodedImage = (url: string): Promise<void> => {
const cached = decodedImageCache.get(url);
if (cached) return cached;
const pending = new Promise<void>((resolve, reject) => {
if (typeof Image === 'undefined') {
resolve();
return;
}
const image = new Image();
image.decoding = 'async';
image.onload = () => {
if (typeof image.decode !== 'function') {
decodedImageElements.set(url, image);
resolve();
return;
}
void image.decode().then(() => {
decodedImageElements.set(url, image);
resolve();
}, reject);
};
image.onerror = () => reject(new Error(`map background image load failed: ${url}`));
image.src = url;
}).catch((error: unknown) => {
decodedImageCache.delete(url);
decodedImageElements.delete(url);
throw error;
});
decodedImageCache.set(url, pending);
return pending;
};
const isWide = useMediaQuery('(min-width: 1024px)');
const mapStore = useMapViewerStore();
@@ -101,6 +141,7 @@ const {
selectedCityId: storeSelectedCityId,
} = storeToRefs(mapStore);
const hasTouchInput = useMediaQuery('(any-pointer: coarse)');
const reduceMotion = useMediaQuery('(prefers-reduced-motion: reduce)');
const mapArea = ref<HTMLElement | null>(null);
const mapBody = ref<HTMLElement | null>(null);
@@ -110,19 +151,6 @@ const mapOptionsMenuId = `map-options-${useId()}`;
const { width: mapBodyWidth } = useElementSize(mapBody);
const { elementX, elementY } = useMouseInElement(mapArea);
const resolveSeason = (month: number): string => {
if (month <= 3) {
return 'spring';
}
if (month <= 6) {
return 'summer';
}
if (month <= 9) {
return 'fall';
}
return 'winter';
};
const resolveStateClass = (state: number): CityStateClass => {
if (state < 10) {
return 'good';
@@ -242,7 +270,7 @@ const mapSeason = computed(() => {
if (!props.mapData) {
return 'spring';
}
return resolveSeason(props.mapData.month);
return resolveMapSeason(props.mapData.month);
});
const mapTheme = computed(() => props.mapLayout?.mapName ?? 'che');
@@ -330,24 +358,14 @@ const mapSeasonClass = computed(() => {
return `map-season-${mapSeason.value}`;
});
const mapBackgroundImage = computed(() => {
const theme = mapTheme.value;
const season = mapSeason.value;
const mapBackground = computed(() => resolveMapBackgroundPath(mapTheme.value, mapSeason.value));
if (theme === 'ludo_rathowm') {
return resolveAsset('map/ludo_rathowm/back.jpg');
}
if (theme === 'chess') {
return resolveAsset('map/chess/chessboard.png');
}
if (theme === 'pokemon_v1') {
return resolveAsset('map/pokemon_v1/back_pal8.png');
}
if (theme === 'cr') {
return resolveAsset('map/cr/bg-fs8.png');
}
const mapBackgroundImage = computed(() => resolveAsset(mapBackground.value.path));
return resolveAsset(`map/che/bg_${season}.jpg`);
const nextSeasonBackgroundImage = computed(() => {
if (!mapBackground.value.seasonal) return null;
const nextSeason = resolveNextMapSeason(mapSeason.value);
return resolveAsset(resolveMapBackgroundPath(mapTheme.value, nextSeason).path);
});
const mapRoadImage = computed(() => {
@@ -364,16 +382,165 @@ const mapRoadImage = computed(() => {
return null;
});
const mapBackgroundStyle = computed(() => ({
backgroundImage: mapBackgroundImage.value ? `url('${mapBackgroundImage.value}')` : 'none',
backgroundSize: '100% 100%',
}));
const renderedBackgroundImage = ref<string | null>(null);
const outgoingBackgroundImage = ref<string | null>(null);
const renderedBackgroundElement = ref<HTMLImageElement | null>(null);
const backgroundReady = ref(false);
const outgoingBackgroundVisible = ref(false);
const backgroundTransitioning = ref(false);
const mapRoadStyle = computed(() => ({
backgroundImage: mapRoadImage.value ? `url('${mapRoadImage.value}')` : 'none',
backgroundSize: '100% 100%',
}));
type BackgroundRequest = {
imageUrl: string;
roadUrl: string | null;
nextSeasonUrl: string | null;
};
const backgroundRequest = computed<BackgroundRequest | null>(() => {
if (!props.mapData || !props.mapLayout) return null;
return {
imageUrl: mapBackgroundImage.value,
roadUrl: mapRoadImage.value,
nextSeasonUrl: nextSeasonBackgroundImage.value,
};
});
const backgroundRequestKey = computed(() => {
const request = backgroundRequest.value;
return request ? `${request.imageUrl}\u0000${request.roadUrl ?? ''}` : '';
});
let pendingBackgroundRequest: BackgroundRequest | null = null;
let backgroundWorkerRunning = false;
let backgroundWorkerDisposed = false;
const waitForPaint = () =>
new Promise<void>((resolve) => {
let settled = false;
const done = () => {
if (settled) return;
settled = true;
window.clearTimeout(timeoutId);
resolve();
};
const timeoutId = window.setTimeout(done, 80);
window.requestAnimationFrame(() => window.requestAnimationFrame(done));
});
const waitForBackgroundTransition = () =>
new Promise<void>((resolve) => window.setTimeout(resolve, MAP_BACKGROUND_TRANSITION_MS));
const prefetchUpcomingImages = (request: BackgroundRequest) => {
if (request.nextSeasonUrl) {
void preloadDecodedImage(request.nextSeasonUrl).catch(() => undefined);
}
const nextSeasonIcon = resolveAsset(`${resolveNextMapSeason(mapSeason.value)}.gif`);
void preloadDecodedImage(nextSeasonIcon).catch(() => undefined);
};
const showBackground = async (request: BackgroundRequest) => {
const roadPromise = request.roadUrl
? preloadDecodedImage(request.roadUrl).catch(() => undefined)
: Promise.resolve();
try {
await preloadDecodedImage(request.imageUrl);
await roadPromise;
} catch {
if (!renderedBackgroundImage.value) {
// Preserve the old initial-load fallback: reveal the map and let the
// actual img make its normal request instead of leaving a skeleton.
renderedBackgroundImage.value = request.imageUrl;
backgroundReady.value = true;
}
return;
}
if (backgroundWorkerDisposed) return;
if (pendingBackgroundRequest && pendingBackgroundRequest.imageUrl !== request.imageUrl) return;
const currentImage = renderedBackgroundImage.value;
if (!currentImage) {
renderedBackgroundImage.value = request.imageUrl;
backgroundReady.value = true;
prefetchUpcomingImages(request);
return;
}
if (currentImage === request.imageUrl) {
backgroundReady.value = true;
prefetchUpcomingImages(request);
return;
}
if (reduceMotion.value) {
renderedBackgroundImage.value = request.imageUrl;
outgoingBackgroundImage.value = null;
outgoingBackgroundVisible.value = false;
backgroundTransitioning.value = false;
prefetchUpcomingImages(request);
return;
}
// Keep the old decoded image fully covering the new background for one
// paint, then fade only that outgoing layer. Road/city/control DOM remains.
outgoingBackgroundImage.value = currentImage;
outgoingBackgroundVisible.value = true;
backgroundTransitioning.value = false;
renderedBackgroundImage.value = request.imageUrl;
await nextTick();
try {
await renderedBackgroundElement.value?.decode();
} catch {
renderedBackgroundImage.value = currentImage;
outgoingBackgroundImage.value = null;
outgoingBackgroundVisible.value = false;
return;
}
await waitForPaint();
if (backgroundWorkerDisposed) return;
backgroundTransitioning.value = true;
outgoingBackgroundVisible.value = false;
await waitForBackgroundTransition();
if (backgroundWorkerDisposed) return;
outgoingBackgroundImage.value = null;
backgroundTransitioning.value = false;
prefetchUpcomingImages(request);
};
const runBackgroundWorker = async () => {
if (backgroundWorkerRunning) return;
backgroundWorkerRunning = true;
try {
while (pendingBackgroundRequest && !backgroundWorkerDisposed) {
const request = pendingBackgroundRequest;
pendingBackgroundRequest = null;
await showBackground(request);
}
} finally {
backgroundWorkerRunning = false;
}
};
watch(
backgroundRequestKey,
() => {
pendingBackgroundRequest = backgroundRequest.value;
void runBackgroundWorker();
},
{ immediate: true }
);
onBeforeUnmount(() => {
backgroundWorkerDisposed = true;
pendingBackgroundRequest = null;
});
const detailProps = computed(() =>
effectiveDetailMode.value
? {
@@ -463,7 +630,7 @@ const selectCity = (cityId: number) => {
</div>
</div>
</div>
<div v-if="props.loading">
<div v-if="props.loading || (props.mapData && props.mapLayout && !backgroundReady)">
<SkeletonLines :lines="4" />
</div>
<div v-else-if="!props.mapData || !props.mapLayout" class="map-empty">지도 데이터를 불러오지 못했습니다.</div>
@@ -478,8 +645,32 @@ const selectCity = (cityId: number) => {
closeMapOptions();
"
>
<div class="map-layer map-bglayer1" :style="mapBackgroundStyle" />
<div class="map-layer map-bglayer2" />
<div class="map-layer map-bglayer1" data-map-background-layer="current">
<img
v-if="renderedBackgroundImage"
ref="renderedBackgroundElement"
class="map-background-image"
:src="renderedBackgroundImage"
alt=""
draggable="false"
/>
</div>
<div
class="map-layer map-bglayer2"
data-map-background-layer="outgoing"
:class="{
'is-visible': outgoingBackgroundVisible,
'is-transitioning': backgroundTransitioning,
}"
>
<img
v-if="outgoingBackgroundImage"
class="map-background-image"
:src="outgoingBackgroundImage"
alt=""
draggable="false"
/>
</div>
<div v-if="mapRoadImage" class="map-layer map-bgroad" :style="mapRoadStyle" />
<component
:is="effectiveDetailMode ? MapCityDetail : MapCityBasic"
@@ -738,6 +929,27 @@ const selectCity = (cityId: number) => {
pointer-events: none;
}
.map-bglayer2 {
opacity: 0;
}
.map-bglayer2.is-visible {
opacity: 1;
}
.map-bglayer2.is-transitioning {
transition: opacity 480ms ease-in-out;
will-change: opacity;
}
.map-background-image {
display: block;
width: 100%;
height: 100%;
object-fit: fill;
user-select: none;
}
.map-tooltip {
position: absolute;
z-index: 16;
@@ -799,4 +1011,10 @@ const selectCity = (cityId: number) => {
.map-empty {
color: rgba(232, 221, 196, 0.6);
}
@media (prefers-reduced-motion: reduce) {
.map-bglayer2.is-transitioning {
transition: none;
}
}
</style>
@@ -14,6 +14,7 @@ defineProps<{
<button
type="button"
role="tab"
class="legacy-button legacy-button--dark legacy-button--fixed-height"
:aria-selected="activePage === 'tournament'"
:class="{ active: activePage === 'tournament' }"
@click="navigate"
@@ -25,6 +26,7 @@ defineProps<{
<button
type="button"
role="tab"
class="legacy-button legacy-button--dark legacy-button--fixed-height"
:aria-selected="activePage === 'betting'"
:class="{ active: activePage === 'betting' }"
@click="navigate"
@@ -34,7 +36,13 @@ defineProps<{
</RouterLink>
</nav>
<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>
</div>
</header>
@@ -64,33 +72,21 @@ defineProps<{
gap: 4px;
}
button {
height: 44px;
--legacy-button-height: 44px;
margin: 0;
border: 1px solid #666;
border-radius: 5.25px;
background: #444;
color: #fff;
font-size: 14px;
line-height: 18px;
cursor: pointer;
}
.tournament-page-tabs button {
min-width: 72px;
padding: 10px 12px;
}
.tournament-page-tabs button.active {
border-color: #f39c12;
background: #8a5b13;
--legacy-button-bg: #8a5b13;
--legacy-button-border: #704a0f;
}
.close-button {
width: 88px;
padding: 10px 16px;
border-color: #375a7f;
background: #375a7f;
}
button:hover,
button:focus {
filter: brightness(1.25);
}
button:focus-visible {
outline: 2px solid #f39c12;
@@ -0,0 +1,35 @@
export type MapSeason = 'spring' | 'summer' | 'fall' | 'winter';
const FIXED_BACKGROUND_PATHS: Readonly<Record<string, string>> = {
ludo_rathowm: 'map/ludo_rathowm/back.jpg',
chess: 'map/chess/chessboard.png',
pokemon_v1: 'map/pokemon_v1/back_pal8.png',
cr: 'map/cr/bg-fs8.png',
};
const NEXT_SEASON: Readonly<Record<MapSeason, MapSeason>> = {
spring: 'summer',
summer: 'fall',
fall: 'winter',
winter: 'spring',
};
export const resolveMapSeason = (month: number): MapSeason => {
if (month <= 3) return 'spring';
if (month <= 6) return 'summer';
if (month <= 9) return 'fall';
return 'winter';
};
export const resolveNextMapSeason = (season: MapSeason): MapSeason => NEXT_SEASON[season];
export const resolveMapBackgroundPath = (theme: string, season: MapSeason): { path: string; seasonal: boolean } => {
const fixedPath = FIXED_BACKGROUND_PATHS[theme];
if (fixedPath) {
return { path: fixedPath, seasonal: false };
}
// Ref uses the CHE seasonal backgrounds for CHE, mini-CHE variants, and
// unknown legacy-compatible themes that do not define their own backdrop.
return { path: `map/che/bg_${season}.jpg`, seasonal: true };
};
+31 -62
View File
@@ -197,20 +197,31 @@ onMounted(() => {
:class="activeTab === 'resource' ? 'resource-page' : 'unique-page'"
>
<header class="top-back-bar bg0">
<button class="legacy-button close-button" type="button" @click="closeWindow"> 닫기</button>
<button class="legacy-button reload-button" type="button" :disabled="loading" @click="loadOverview">
<button
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>
<h1>{{ activeTab === 'resource' ? '경매장' : '유니크 경매장' }}</h1>
<button
class="legacy-button tab-button"
class="legacy-button legacy-button--dark tab-button"
:aria-pressed="activeTab === 'resource'"
@click="activeTab = 'resource'"
>
/
</button>
<button
class="legacy-button tab-button"
class="legacy-button legacy-button--dark tab-button"
:aria-pressed="activeTab === 'unique'"
@click="activeTab = 'unique'"
>
@@ -318,7 +329,12 @@ onMounted(() => {
step="10"
required
/>
<button class="legacy-button" :disabled="actionBusy || selectedResource.isCallerHost">입찰</button>
<button
class="legacy-button legacy-button--dark"
:disabled="actionBusy || selectedResource.isCallerHost"
>
입찰
</button>
</form>
<h3 class="subsection-title">경매 등록</h3>
@@ -331,7 +347,7 @@ onMounted(() => {
<legend>매물</legend>
<div class="item-toggle">
<button
class="legacy-button"
class="legacy-button legacy-button--dark"
type="button"
:aria-pressed="openForm.type === 'BUY_RICE'"
@click="openForm.type = 'BUY_RICE'"
@@ -339,7 +355,7 @@ onMounted(() => {
</button>
<button
class="legacy-button"
class="legacy-button legacy-button--dark"
type="button"
:aria-pressed="openForm.type === 'SELL_RICE'"
@click="openForm.type = 'SELL_RICE'"
@@ -363,7 +379,7 @@ onMounted(() => {
<span>마감가 ({{ openForm.type === 'BUY_RICE' ? '금' : '쌀' }})</span>
<input v-model.number="openForm.finishBidAmount" type="number" min="100" max="10000" step="10" />
</label>
<button class="legacy-button register-button" :disabled="actionBusy">등록</button>
<button class="legacy-button legacy-button--dark register-button" :disabled="actionBusy">등록</button>
</form>
<h3 class="subsection-title">이전 경매(최근 20)</h3>
@@ -408,7 +424,7 @@ onMounted(() => {
유산포인트 (잔여: {{ formatNumber(uniqueDetail.remainPoint) }}포인트)
</label>
<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>
</template>
</section>
@@ -480,7 +496,9 @@ onMounted(() => {
</section>
<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>
</main>
</template>
@@ -530,66 +548,17 @@ onMounted(() => {
line-height: 32px;
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,
.reload-button {
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 .reload-button {
height: 32px;
}
.tab-button {
border-color: #3d3d3d;
background: #444;
--legacy-button-height: 32px;
}
.tab-button[aria-pressed='true'] {
border-color: #3d3d3d;
background: #444;
}
.tab-button:hover,
.tab-button:focus {
border-color: #3d3d3d;
background: #444;
--legacy-button-bg: #333;
--legacy-button-border: #303030;
}
.section-title,
.subsection-title,
@@ -236,9 +236,20 @@ onMounted(() => {
<main class="ref-shell battle-page">
<header class="battle-top legacy-bg0">
<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>
<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>
<div></div>
<div></div>
@@ -305,7 +316,9 @@ onMounted(() => {
</section>
<footer class="battle-footer legacy-bg0">
<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>
</footer>
</main>
@@ -418,17 +431,11 @@ onMounted(() => {
text-align: center;
}
.battle-nav {
--legacy-button-height: 32px;
box-sizing: border-box;
height: 32px;
margin-right: 2px;
border: 0;
border-radius: 3px;
display: grid;
place-items: center;
background: #00582c;
color: #fff;
font: inherit;
font-weight: 700;
text-decoration: none;
}
.battle-footer {
+23 -16
View File
@@ -130,7 +130,13 @@ const placeBet = async () => {
<main id="tournament-betting-container" class="betting-page">
<TournamentPageHeader class="bg0" active-page="betting" title="베 팅 장" />
<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="message" role="status">{{ message }}</span>
</section>
@@ -159,12 +165,7 @@ const placeBet = async () => {
@request-bet="openBetDialog"
/>
<dialog
ref="betDialog"
class="bet-dialog"
aria-labelledby="bet-dialog-title"
@close="selectedTarget = null"
>
<dialog ref="betDialog" class="bet-dialog" aria-labelledby="bet-dialog-title" @close="selectedTarget = null">
<form v-if="selectedTarget" class="bet-dialog-content" @submit.prevent="placeBet">
<header>
<h2 id="bet-dialog-title">베팅하기</h2>
@@ -194,9 +195,13 @@ const placeBet = async () => {
<span aria-hidden="true">×</span>
<span class="gold-color">{{ selectedAmount }}</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>
<p class="bet-preview-note">현재 배당 기준 예상값이며, 베팅 상황에 따라 최종 배당은 달라질 있습니다.</p>
<p class="bet-preview-note">
현재 배당 기준 예상값이며, 베팅 상황에 따라 최종 배당은 달라질 있습니다.
</p>
<p v-if="betError" class="bet-dialog-error" role="alert">{{ betError }}</p>
<footer>
<button type="button" :disabled="placingBet" @click="closeBetDialog">취소</button>
@@ -289,7 +294,9 @@ const placeBet = async () => {
</section>
<footer class="betting-footer bg0">
<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>
<small>
삼국지 모의전투 HiDCHe / KOEI의 이미지를 사용, 응용하였습니다 / 제작 : HideD(hided62@gmail.com) / Credit
@@ -334,8 +341,8 @@ const placeBet = async () => {
text-align: left;
}
.toolbar button {
--legacy-button-height: 44px;
min-width: 72px;
height: 44px;
padding: 10px 16px;
font-size: 14px;
}
@@ -380,7 +387,7 @@ select {
background: #000;
color: #fff;
}
button {
button:not(.legacy-button) {
height: 35.5px;
color: #fff;
background: #444;
@@ -388,16 +395,16 @@ button {
border-radius: 5.25px;
cursor: pointer;
}
button:hover,
button:focus {
button:not(.legacy-button):hover,
button:not(.legacy-button):focus {
filter: brightness(1.25);
}
button:focus-visible,
button:not(.legacy-button):focus-visible,
select:focus-visible {
outline: 2px solid #f39c12;
outline-offset: 1px;
}
button:disabled,
button:not(.legacy-button):disabled,
select:disabled {
cursor: not-allowed;
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>
<main v-else id="container" class="legacy-board-page">
<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>
<h1>{{ title }}</h1>
<div></div>
@@ -163,7 +169,7 @@ onMounted(() => {
<div></div>
<button
id="submitArticle"
class="legacy-button legacy-button--secondary"
class="legacy-button legacy-button--secondary legacy-button--fixed-height"
type="button"
@click="submitArticle"
>
@@ -222,7 +228,7 @@ onMounted(() => {
@keyup.enter="submitComment(article.id)"
/>
<button
class="legacy-button submit-comment"
class="legacy-button legacy-button--dark legacy-button--fixed-height submit-comment"
type="button"
@click="submitComment(article.id)"
>
@@ -235,7 +241,13 @@ onMounted(() => {
</section>
<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>
</template>
</main>
@@ -296,47 +308,9 @@ onMounted(() => {
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 {
height: 32px;
--legacy-button-height: 32px;
margin-right: 2px;
border-color: #004f28;
background: #00582c;
}
.back-button:hover,
.back-button:focus {
border-color: #004523;
background: #004a25;
}
.board-state {
@@ -414,17 +388,10 @@ onMounted(() => {
}
.article-submit-row .legacy-button {
--legacy-button-height: 35.5px;
width: auto;
min-height: 35.5px;
margin-right: 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 {
@@ -513,8 +480,8 @@ onMounted(() => {
}
.submit-comment {
--legacy-button-height: 29.375px;
width: 83.333px;
min-height: 29.375px;
padding-top: 2px;
padding-bottom: 2px;
flex: 0 0 auto;
@@ -529,9 +496,9 @@ onMounted(() => {
}
.bottom-bar .back-button {
--legacy-button-height: 35.5px;
display: inline-block;
width: 71px;
height: 35.5px;
margin: 0;
padding-right: 6px;
padding-left: 6px;
+17 -11
View File
@@ -318,8 +318,19 @@ const repeatTurns = async (amount: number) => {
<template>
<main class="chief-page">
<header class="chief-top legacy-bg0">
<button class="chief-nav" type="button" @click="router.push('/')">돌아가기</button>
<button class="chief-nav" @click="loadChiefCenter">갱신</button>
<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>
<div></div>
<div></div>
@@ -442,7 +453,9 @@ const repeatTurns = async (amount: number) => {
</template>
</div>
<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>
</main>
</template>
@@ -643,19 +656,12 @@ const repeatTurns = async (amount: number) => {
text-align: center;
}
.chief-nav {
--legacy-button-height: 32px;
box-sizing: border-box;
height: 32px;
margin-right: 2px;
border: 0;
border-radius: 3px;
display: grid;
place-items: center;
background: #00582c;
color: #fff;
font: inherit;
font-weight: 700;
text-decoration: none;
cursor: pointer;
}
.layout-desktop {
display: block;
@@ -1,6 +1,8 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
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 { getNpcColor } from '../utils/npcColor';
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 General = Result['generals'][number];
type ReservedCommand = General['turns'][number];
const route = useRoute();
const router = useRouter();
const data = ref<Result | null>(null);
const commandTable = ref<CommandTable | null>(null);
const error = ref('');
const selected = ref<number>();
let loadSequence = 0;
@@ -29,8 +33,10 @@ const load = async (cityId?: number) => {
const sequence = ++loadSequence;
try {
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;
data.value = result;
commandTable.value = table;
selected.value = result.city.id;
error.value = '';
} catch (cause) {
@@ -76,6 +82,8 @@ const defenceTrainText = (value: number | null) => {
return '△';
};
const generalImage = (general: General): string => resolveGeneralIconUrl(general);
const commandBrief = (command: ReservedCommand): string =>
formatReservedCommandBrief('general', command.action, command.args, commandTable.value);
</script>
<template>
@@ -282,8 +290,12 @@ const generalImage = (general: General): string => resolveGeneralIconUrl(general
<td>{{ general.atmos ?? '?' }}</td>
<td class="turns" :class="{ 'turns--reserved': general.turns.length > 0 }">
<template v-if="general.turns.length">
<span v-for="(turn, index) in general.turns" :key="index" class="turn-line"
>{{ index + 1 }} : {{ turn }}</span
<span
v-for="(turn, index) in general.turns"
:key="index"
class="turn-line"
:title="commandBrief(turn)"
>{{ index + 1 }} : {{ commandBrief(turn) }}</span
>
</template>
<template v-else-if="general.npcState > 1">NPC 장수</template>
+17 -8
View File
@@ -53,7 +53,13 @@ onMounted(async () => {
<template>
<main class="global-page legacy-bg0">
<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>
<p v-if="error" class="error">{{ error }}</p>
<section v-if="data" class="section">
@@ -147,7 +153,15 @@ onMounted(async () => {
</div>
</div>
</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" />
</main>
</template>
@@ -178,15 +192,10 @@ onMounted(async () => {
line-height: 32px;
}
.legacy-title button {
--legacy-button-height: 32px;
position: absolute;
inset: 0 auto 0 0;
width: 88px;
border: 1px solid #0a9960;
border-radius: 0 0 4px;
background: #087f45;
color: #fff;
font-weight: 700;
cursor: pointer;
}
.legacy-compat-button {
display: none;
+5 -4
View File
@@ -449,10 +449,12 @@ onMounted(() => {
<template>
<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>
<button
class="top-button legacy-button legacy-button--navigation"
class="top-button legacy-button legacy-button--navigation legacy-button--fixed-height"
type="button"
:disabled="loading"
@click="loadStatus"
@@ -786,8 +788,7 @@ onMounted(() => {
}
.top-button {
height: 32px;
min-height: 32px;
--legacy-button-height: 32px;
padding: 2px 8px;
text-decoration: none;
}
@@ -338,7 +338,13 @@ onMounted(() => {
<main id="nation-betting-container" class="nation-betting-page legacy-bg0">
<header class="legacy-top-bar">
<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>
<div></div>
<h1>국가 베팅장</h1>
@@ -395,7 +401,7 @@ onMounted(() => {
<div>사용 포인트: {{ usedAmount.toLocaleString('ko-KR') }}</div>
<div>대상: {{ selectionLabel(selectedKey) }}</div>
<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>
<div class="payout-table">
@@ -475,33 +481,10 @@ onMounted(() => {
text-align: center;
}
.legacy-nav-button,
.betting-form button {
height: 32px;
border: 0;
background: #00582c;
color: #fff;
font-weight: 600;
text-align: center;
text-decoration: none;
cursor: pointer;
.legacy-top-bar .legacy-button {
--legacy-button-height: 32px;
}
.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-item:focus-visible {
outline: 2px solid #f39c12;
@@ -662,9 +645,8 @@ onMounted(() => {
padding-top: 20px;
}
.betting-footer .legacy-nav-button {
.betting-footer .legacy-button {
width: 90px;
height: 35.5px;
}
.betting-notice,
@@ -536,14 +536,25 @@ onBeforeUnmount(() => {
<main class="general-page legacy-bg0">
<header class="top-bar">
<span class="left-actions">
<button class="top-button nation-button" @click="router.push('/')">돌아가기</button>
<button class="top-button nation-button" :disabled="loading" @click="load">갱신</button>
<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>
<strong>세력 장수</strong>
<span class="right-actions">
<span class="dropdown">
<button
class="top-button mode-button"
class="legacy-button legacy-button--primary legacy-button--fixed-height top-button mode-button"
:aria-expanded="viewMenuOpen"
@click="
viewMenuOpen = !viewMenuOpen;
@@ -576,7 +587,7 @@ onBeforeUnmount(() => {
</span>
<span class="dropdown">
<button
class="top-button columns-button"
class="legacy-button legacy-button--info legacy-button--fixed-height top-button columns-button"
:aria-expanded="columnMenuOpen"
@click="
columnMenuOpen = !columnMenuOpen;
@@ -850,45 +861,30 @@ onBeforeUnmount(() => {
right: 0;
}
.top-button {
--legacy-button-height: 32px;
display: inline-flex;
width: 89px;
height: 32px;
align-items: center;
justify-content: center;
padding: 0;
border: 0;
border-right: 1px solid #151515;
border-radius: 3px;
color: #fff;
font-size: 14px;
font-weight: 700;
text-decoration: none;
cursor: pointer;
}
.nation-button {
background: #006c48;
}
.nation-button:hover {
background: #00855a;
--legacy-button-bg: #006c48;
--legacy-button-border: #005d3e;
}
.mode-button {
background: #375a7f;
border-bottom: 0 solid #325172;
}
.mode-button:hover,
.mode-button[aria-expanded='true'],
.mode-button:active {
border-bottom-width: 3px;
--legacy-button-bg: #375a7f;
--legacy-button-border: #325172;
}
.mode-button,
.columns-button {
width: 90px;
}
.columns-button {
background: #3297cf;
}
.columns-button:hover {
filter: brightness(1.12);
--legacy-button-bg: #3297cf;
--legacy-button-border: #2b80b0;
}
.dropdown {
position: relative;
@@ -173,7 +173,9 @@ onMounted(() => void loadStratFinan());
<template>
<main id="finance-container" class="page-finance">
<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 />
<strong>내무부</strong>
<span />
@@ -224,7 +226,7 @@ onMounted(() => void loadStratFinan());
<span>
<button
v-if="canEdit && !editingNationMsg"
class="message-button"
class="legacy-button legacy-button--secondary message-button"
type="button"
@click="enableEditNationMsg"
>
@@ -232,7 +234,7 @@ onMounted(() => void loadStratFinan());
</button>
<button
v-if="canEdit && editingNationMsg"
class="policy-submit"
class="legacy-button legacy-button--primary policy-submit"
type="button"
@click="saveNationMsg"
>
@@ -240,7 +242,7 @@ onMounted(() => void loadStratFinan());
</button>
<button
v-if="canEdit && editingNationMsg"
class="policy-cancel"
class="legacy-button legacy-button--secondary policy-cancel"
type="button"
@click="rollbackNationMsg"
>
@@ -249,24 +251,15 @@ onMounted(() => void loadStratFinan());
</span>
</header>
<div v-if="!editingNationMsg" class="message-preview" v-html="nationMsg || '내용 없음'" />
<LegacyHtmlEditor
v-else
v-model="nationMsgDraft"
:max-length="16384"
aria-label="국가 방침"
/>
<LegacyHtmlEditor v-else v-model="nationMsgDraft" :max-length="16384" aria-label="국가 방침" />
</section>
<section
id="scout-message-form"
class="message-form"
:class="{ 'message-form--editing': editingScoutMsg }"
>
<section id="scout-message-form" class="message-form" :class="{ 'message-form--editing': editingScoutMsg }">
<header class="green-header">
<span>임관 권유</span>
<span>
<button
v-if="canEdit && !editingScoutMsg"
class="message-button"
class="legacy-button legacy-button--secondary message-button"
type="button"
@click="enableEditScoutMsg"
>
@@ -274,7 +267,7 @@ onMounted(() => void loadStratFinan());
</button>
<button
v-if="canEdit && editingScoutMsg"
class="policy-submit"
class="legacy-button legacy-button--primary policy-submit"
type="button"
@click="saveScoutMsg"
>
@@ -282,7 +275,7 @@ onMounted(() => void loadStratFinan());
</button>
<button
v-if="canEdit && editingScoutMsg"
class="policy-cancel"
class="legacy-button legacy-button--secondary policy-cancel"
type="button"
@click="rollbackScoutMsg"
>
@@ -292,12 +285,7 @@ onMounted(() => void loadStratFinan());
</header>
<div class="scout-limit">870px x 200px를 넘어서는 내용은 표시되지 않습니다.</div>
<div v-if="!editingScoutMsg" class="message-preview scout-preview" v-html="scoutMsg || '내용 없음'" />
<LegacyHtmlEditor
v-else
v-model="scoutMsgDraft"
:max-length="1000"
aria-label="임관 권유"
/>
<LegacyHtmlEditor v-else v-model="scoutMsgDraft" :max-length="1000" aria-label="임관 권유" />
</section>
<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
>%</span
>
<button v-if="canEdit" class="policy-submit" type="button" @click="setRate">변경</button>
<button
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"
@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
>%</span
>
<button v-if="canEdit" class="policy-submit" type="button" @click="setBill">변경</button>
<button
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"
@click="policy.bill = oldPolicy.bill"
>
@@ -400,10 +402,17 @@ onMounted(() => void loadStratFinan());
min="1"
max="99"
/><span></span>
<button v-if="canEdit" class="policy-submit" type="button" @click="setSecretLimit">변경</button>
<button
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"
@click="policy.secretLimit = oldPolicy.secretLimit"
>
@@ -438,7 +447,7 @@ onMounted(() => void loadStratFinan());
<input v-for="index in 4" :key="`compat-input-${index}`" type="hidden" />
</div>
<footer class="bottom-bar">
<RouterLink class="legacy-button" to="/">돌아가기</RouterLink>
<RouterLink class="legacy-button legacy-button--navigation" to="/">돌아가기</RouterLink>
</footer>
</template>
</main>
@@ -472,32 +481,8 @@ onMounted(() => void loadStratFinan());
.top-back-bar button {
grid-column: 5;
}
.legacy-button,
button {
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);
.top-back-bar .legacy-button {
--legacy-button-height: 32px;
}
button:focus-visible,
.legacy-button:focus-visible,
+4 -10
View File
@@ -355,13 +355,14 @@ const submitPriority = async (section: PrioritySectionKey) => {
error.value = `설정하지 못했습니다: ${resolveErrorMessage(caught)}`;
}
};
</script>
<template>
<main id="npc-policy-page" class="npc-page">
<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>
</nav>
@@ -579,21 +580,14 @@ const submitPriority = async (section: PrioritySectionKey) => {
}
.back-button {
--legacy-button-height: 32px;
position: absolute;
inset: 0 auto 0 0;
width: 88px;
color: #fff;
background: #087f45;
border: 1px solid #0a9960;
border-radius: 0 0 4px;
font-weight: 700;
line-height: 30px;
text-decoration: none;
}
.back-button:hover,
.back-button:focus-visible {
background: #0a9960;
outline: 2px solid #fff;
outline-offset: -2px;
}
+7 -4
View File
@@ -209,11 +209,15 @@ onMounted(() => {
<template>
<main id="container" class="pageVote 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
class="legacy-button legacy-button--navigation reload_btn"
class="legacy-button legacy-button--navigation legacy-button--fixed-height reload_btn"
type="button"
:disabled="loading"
@click="reloadVote"
@@ -453,8 +457,7 @@ onMounted(() => {
.back_btn,
.reload_btn {
height: 32px;
min-height: 32px;
--legacy-button-height: 32px;
margin-right: 2px;
font-weight: 600;
}
+18 -9
View File
@@ -190,10 +190,16 @@ const start = async () => {
<TournamentPageHeader class="bg0" active-page="tournament" title="삼모전 토너먼트" />
<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
type="button"
class="join-button"
class="legacy-button legacy-button--navigation legacy-button--fixed-height join-button"
:disabled="snapshot?.state?.stage !== 1 || isParticipant"
@click="join"
>
@@ -382,7 +388,9 @@ const start = async () => {
<input type="hidden" name="tournamentAction" value="join" />
<footer class="tournament-footer bg0">
<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>
<small>
삼국지 모의전투 HiDCHe / KOEI의 이미지를 사용, 응용하였습니다 / 제작 : HideD(hided62@gmail.com) / Credit
@@ -439,8 +447,8 @@ const start = async () => {
padding: 1px;
}
.toolbar button {
--legacy-button-height: 44px;
min-width: 72px;
height: 44px;
padding: 10px 16px;
font-size: 14px;
}
@@ -451,7 +459,7 @@ const start = async () => {
min-height: 32px;
padding: 5px;
}
button {
button:not(.legacy-button) {
height: 35.5px;
margin: 0 2px;
border: 1px solid #666;
@@ -460,16 +468,17 @@ button {
color: #fff;
cursor: pointer;
}
button:hover,
button:focus {
button:not(.legacy-button):hover,
button:not(.legacy-button):focus {
filter: brightness(1.25);
}
button:focus-visible {
button:not(.legacy-button):focus-visible {
outline: 2px solid #f39c12;
outline-offset: 1px;
}
.join-button {
background: #8a5b13;
--legacy-button-bg: #8a5b13;
--legacy-button-border: #704a0f;
}
.operator-row span {
color: orange;
+3 -4
View File
@@ -179,14 +179,14 @@ onMounted(() => {
<main id="container" class="legacy-troop-page">
<header class="topBackBar bg0">
<button
class="legacy-button legacy-button--navigation legacyNavButton backLink"
class="legacy-button legacy-button--navigation legacy-button--fixed-height legacyNavButton backLink"
type="button"
@click="router.push('/')"
>
돌아가기
</button>
<button
class="legacy-button legacy-button--navigation legacyNavButton reloadButton"
class="legacy-button legacy-button--navigation legacy-button--fixed-height legacyNavButton reloadButton"
type="button"
:disabled="loading"
@click="refresh"
@@ -395,8 +395,7 @@ onMounted(() => {
}
.legacyNavButton {
height: 32px;
min-height: 32px;
--legacy-button-height: 32px;
margin-right: 2px;
font-weight: 600;
}
+12 -15
View File
@@ -137,11 +137,18 @@ onMounted(async () => {
<main id="yearbook-container" class="yearbook-page legacy-bg0">
<header class="yearbook-title legacy-bg0">
<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">
<button
class="legacy-button legacy-button--navigation"
class="legacy-button legacy-button--navigation legacy-button--fixed-height"
type="button"
:aria-expanded="settingsOpen"
@click="settingsOpen = !settingsOpen"
>
설정
@@ -235,7 +242,7 @@ onMounted(async () => {
</section>
<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>
<div class="dropdown-compat-buttons" aria-hidden="true">
<button type="button" tabindex="-1" /><button type="button" tabindex="-1" />
@@ -286,14 +293,14 @@ onMounted(async () => {
}
.yearbook-title .close-button {
left: 0;
height: 32px;
--legacy-button-height: 32px;
}
.settings-menu {
right: 0;
height: 32px;
}
.settings-menu > .legacy-button {
height: 32px;
--legacy-button-height: 32px;
}
.settings-item {
position: absolute;
@@ -393,16 +400,6 @@ onMounted(async () => {
.dropdown-compat-buttons {
display: none;
}
.year-selector .legacy-button {
border: 0;
background: #444;
}
.settings-menu > .legacy-button,
.yearbook-title .close-button {
border: 0;
background: #00582c;
}
.log-content {
min-height: 72px;
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`
);
}
});
});
@@ -0,0 +1,53 @@
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import { resolveMapBackgroundPath, resolveMapSeason, resolveNextMapSeason } from '../src/utils/mapBackground.ts';
void describe('mapBackground', () => {
void it('maps month boundaries to the four Ref seasons', () => {
assert.equal(resolveMapSeason(1), 'spring');
assert.equal(resolveMapSeason(3), 'spring');
assert.equal(resolveMapSeason(4), 'summer');
assert.equal(resolveMapSeason(6), 'summer');
assert.equal(resolveMapSeason(7), 'fall');
assert.equal(resolveMapSeason(9), 'fall');
assert.equal(resolveMapSeason(10), 'winter');
assert.equal(resolveMapSeason(12), 'winter');
});
void it('uses seasonal CHE backgrounds for CHE and mini-CHE themes', () => {
assert.deepEqual(resolveMapBackgroundPath('che', 'summer'), {
path: 'map/che/bg_summer.jpg',
seasonal: true,
});
assert.deepEqual(resolveMapBackgroundPath('miniche_clean', 'winter'), {
path: 'map/che/bg_winter.jpg',
seasonal: true,
});
});
void it('keeps seasonless theme backgrounds fixed across season changes', () => {
for (const [theme, expectedPath] of [
['ludo_rathowm', 'map/ludo_rathowm/back.jpg'],
['chess', 'map/chess/chessboard.png'],
['pokemon_v1', 'map/pokemon_v1/back_pal8.png'],
['cr', 'map/cr/bg-fs8.png'],
] as const) {
assert.deepEqual(resolveMapBackgroundPath(theme, 'spring'), {
path: expectedPath,
seasonal: false,
});
assert.deepEqual(resolveMapBackgroundPath(theme, 'winter'), {
path: expectedPath,
seasonal: false,
});
}
});
void it('cycles the next background season across a year boundary', () => {
assert.equal(resolveNextMapSeason('spring'), 'summer');
assert.equal(resolveNextMapSeason('summer'), 'fall');
assert.equal(resolveNextMapSeason('fall'), 'winter');
assert.equal(resolveNextMapSeason('winter'), 'spring');
});
});
+5 -4
View File
@@ -49,7 +49,6 @@ const zServerAction = z.enum([
'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 ADMIN_ROLE_PREFIX = 'admin.';
@@ -430,7 +429,9 @@ const zInstallAutorun = z.object({
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
.object({
@@ -438,7 +439,7 @@ const zRuntimeSettings = z
.number()
.int()
.refine((value) => isAllowedTurnTerm(value), {
message: 'turnTermMinutes must divide 120.',
message: '턴 시간은 120의 약수여야 합니다.',
})
.optional(),
blockGeneralCreate: z.union([z.literal(0), z.literal(1), z.literal(2)]).optional(),
@@ -464,7 +465,7 @@ const zInstallOptions = z.object({
.number()
.int()
.refine((value) => isAllowedTurnTerm(value), {
message: 'turnTermMinutes must divide 120.',
message: '턴 시간은 120의 약수여야 합니다.',
}),
sync: z.boolean(),
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 }
);
const resetDefaults = {
turnTermMinutes: 10,
turnTermMinutes: 3,
sync: true,
fiction: 1 as const,
extend: true,
@@ -853,6 +853,44 @@ describe('admin operation API', () => {
).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 () => {
const harness = await buildCaller(
async () => {
@@ -983,7 +983,7 @@ test('loads server metadata defaults into the reset form and submits them', asyn
runtimeRunning: true,
requestBodies: [],
resetDefaults: {
turnTermMinutes: 20,
turnTermMinutes: 3,
sync: false,
fiction: 0,
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());
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 expect(page.getByTestId('reset-defaults-source')).toContainText('서버의 메타');
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(
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('"joinMode":"onlyRandom"');
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.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.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
);
expect(request).toContain('"resetDefaults"');
expect(request).toContain('"turnTermMinutes":10');
expect(request).toContain('"turnTermMinutes":3');
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 = [
'develop',
'warp',
@@ -24,7 +26,7 @@ export const RESET_AUTORUN_LABELS: ReadonlyArray<{ value: ResetAutorunOption; la
export const RESET_OPTION_COPY = {
turnTerm: {
label: '턴 시간(분)',
help: '한 턴이 진행되는 실제 시간입니다. ref 초기화 화면과 같은 분 단위 값입니다.',
help: '한 턴이 진행되는 실제 시간입니다. 하루 경계를 정수 게임 연도로 유지하기 위해 120의 약수만 사용할 수 있습니다.',
},
sync: {
label: '시간 동기화',
@@ -8,6 +8,7 @@ import { useToast } from '../composables/useToast';
import AdminConsoleLayout from '../layouts/AdminConsoleLayout.vue';
import {
normalizeProfileResetDefaults,
PROFILE_TURN_TERM_MINUTES,
RESET_AUTORUN_LABELS,
RESET_OPTION_COPY,
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"
data-testid="reset-turn-term"
>
<option
v-for="minutes in [1, 2, 3, 4, 5, 6, 10, 12, 15, 20, 24, 30, 40, 60, 120]"
:key="minutes"
:value="minutes"
>
<option v-for="minutes in PROFILE_TURN_TERM_MINUTES" :key="minutes" :value="minutes">
{{ minutes }}
</option>
</select>
+2
View File
@@ -71,6 +71,8 @@ Gateway 관리자 콘솔은 `/gateway/admin`에서 시작합니다. 공개 로
턴 간격, 동기화, 가상 장수, 연장, 가입 방식, 장수 생성 제한, NPC, 이미지,
토너먼트와 유저 자동턴 기본값을 저장합니다. 시나리오 초기화 화면은 대상
서버를 URL에서 결정한 뒤 이 메타를 별도 권한 검사로 읽어 폼에 적용합니다.
턴 간격은 하루를 `12개월 × 턴 간격`의 정수 개 게임 연도로 나눌 수 있는
120의 양의 약수만 허용하므로 3분과 8분도 유효합니다.
유저 자동턴은 내정, 이동, 징병, 고급 징병, 훈련, 전투, 중신의 7개 동작을
각각 보존해 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 result of line-height, padding, and the 4px edge, so it naturally becomes
34.5px/33.5px while the 1px/2px top margin keeps the bottom coordinate fixed.
Only a fixed-height owner such as the mobile bottom bar supplies explicit
45px/44px/43px state compensation.
A fixed row opts into `.legacy-button--fixed-height` and supplies only
`--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
variables may be supplied by the owner for dynamic nation/scenario colors, but
border construction, font weight, hover/focus/active, and disabled presentation
+2
View File
@@ -175,6 +175,8 @@ migration과 scenario seed부터 진행합니다. 상위 권한자가 버전 업
장수·연장·토너먼트 사용, 기본 NPC, 이미지 단계 3, 전체 가입과 유저 자동턴
미사용이라는 기존 시스템 기본값으로 돌아갑니다. 실행마다 달라지는 scenario와
예약·가오픈·정식 오픈 시각은 자동 입력하지 않습니다.
턴 간격은 120의 양의 약수만 허용하며 3분과 8분도 RESET·저장 기본값·실행 중
변경에서 동일하게 사용할 수 있습니다.
이 모드는 build와 migration 후 기존 season/tick metadata를 읽고 scenario seeder를 실행합니다.
빈 profile schema도 migration을 먼저 적용하므로 최초 `world_state` 조회가
@@ -190,6 +190,84 @@ test.beforeEach(async ({ 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 }) => {
await page.setViewportSize({ width: 2200, height: 1000 });
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,
titleHeight: title.getBoundingClientRect().height,
refreshHeight: refresh.getBoundingClientRect().height,
refreshBottom: refresh.getBoundingClientRect().bottom,
refreshMarginTop: getComputedStyle(refresh).marginTop,
refreshEdge: getComputedStyle(refresh).borderBottomWidth,
refreshRadius: getComputedStyle(refresh).borderRadius,
fontFamily: style.fontFamily,
fontSize: style.fontSize,
@@ -222,7 +303,9 @@ test('tournament keeps the legacy 2000px bracket and 250px group geometry', asyn
candidateWidth: 125,
groupWidth: 250,
titleHeight: 55.6875,
refreshHeight: 35.5,
refreshHeight: 44,
refreshMarginTop: '0px',
refreshEdge: '4px',
refreshRadius: '5.25px',
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 });
const refresh = page.getByRole('button', { name: '갱신' });
const before = await refresh.evaluate((element) => getComputedStyle(element).filter);
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 expect(refresh).toBeFocused();
expect(hover).not.toBe(before);
});
test('tournament keeps the fixed legacy canvas at a 1024px viewport', async ({ page }) => {