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>