Merge remote-tracking branch 'origin/main' into feature/nation-general-controls-20260813
This commit is contained in:
@@ -137,7 +137,24 @@ export const tournamentRouter = router({
|
||||
store.getMatches(),
|
||||
store.getBettingEntries(),
|
||||
]);
|
||||
return { state, participants, matches, betCount: bets.length };
|
||||
const participantIds = [...new Set(participants.map((participant) => participant.id))];
|
||||
const iconRows =
|
||||
participantIds.length === 0
|
||||
? []
|
||||
: await ctx.db.general.findMany({
|
||||
where: { id: { in: participantIds } },
|
||||
select: { id: true, picture: true, imageServer: true },
|
||||
});
|
||||
const iconsByGeneralId = new Map(iconRows.map((general) => [general.id, general]));
|
||||
const publicParticipants = participants.map((participant) => {
|
||||
const icon = iconsByGeneralId.get(participant.id);
|
||||
return {
|
||||
...participant,
|
||||
picture: icon?.picture ?? null,
|
||||
imageServer: icon?.imageServer ?? 0,
|
||||
};
|
||||
});
|
||||
return { state, participants: publicParticipants, matches, betCount: bets.length };
|
||||
}),
|
||||
getRankings: authedProcedure.query(async ({ ctx }) => {
|
||||
await getMyGeneral(ctx);
|
||||
@@ -177,7 +194,16 @@ export const tournamentRouter = router({
|
||||
}
|
||||
const generals = await ctx.db.general.findMany({
|
||||
where: { id: { in: [...rankMap.keys()] } },
|
||||
select: { id: true, name: true, npcState: true, leadership: true, strength: true, intel: true },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
npcState: true,
|
||||
picture: true,
|
||||
imageServer: true,
|
||||
leadership: true,
|
||||
strength: true,
|
||||
intel: true,
|
||||
},
|
||||
});
|
||||
|
||||
return tournamentRankTypes.map((prefix) => {
|
||||
@@ -201,6 +227,8 @@ export const tournamentRouter = router({
|
||||
generalId: general.id,
|
||||
name: general.name,
|
||||
npcState: general.npcState,
|
||||
picture: general.picture,
|
||||
imageServer: general.imageServer,
|
||||
stat,
|
||||
games: win + draw + lose,
|
||||
win,
|
||||
|
||||
@@ -84,6 +84,8 @@ const buildGeneral = (id: number, userId: string, gold = 2_000): GeneralRow =>
|
||||
id,
|
||||
userId,
|
||||
name: `장수${id}`,
|
||||
picture: `${id}.jpg`,
|
||||
imageServer: id % 2,
|
||||
leadership: 70 + id,
|
||||
strength: 60 + id,
|
||||
intel: 50 + id,
|
||||
@@ -296,6 +298,10 @@ describe('tournament router permissions and mutations', () => {
|
||||
const sections = await ownerCaller.tournament.getRankings();
|
||||
expect(sections).toHaveLength(4);
|
||||
expect(sections[0]?.entries.map((entry) => entry.generalId)).toEqual([second.id, first.id]);
|
||||
expect(sections[0]?.entries[0]).toMatchObject({
|
||||
picture: '2.jpg',
|
||||
imageServer: 0,
|
||||
});
|
||||
|
||||
const generalLessCaller = appRouter.createCaller(
|
||||
buildContext({ redis, transport, generals: [first, second], userId: 'user-3', rankRows })
|
||||
@@ -303,6 +309,33 @@ describe('tournament router permissions and mutations', () => {
|
||||
await expect(generalLessCaller.tournament.getRankings()).rejects.toMatchObject({ code: 'NOT_FOUND' });
|
||||
});
|
||||
|
||||
it('joins current dedicated icon metadata to the public tournament snapshot', async () => {
|
||||
const redis = new MemoryRedis();
|
||||
const transport = new TournamentTransport();
|
||||
const owner = buildGeneral(11, 'user-1');
|
||||
const rival = buildGeneral(12, 'user-2');
|
||||
await setTournamentFixture(redis, {
|
||||
stage: 7,
|
||||
phase: 0,
|
||||
type: 0,
|
||||
auto: true,
|
||||
openYear: 193,
|
||||
openMonth: 1,
|
||||
termSeconds: 60,
|
||||
nextAt: '2026-07-26T01:00:00.000Z',
|
||||
});
|
||||
const caller = appRouter.createCaller(
|
||||
buildContext({ redis, transport, generals: [owner, rival], userId: 'user-1' })
|
||||
);
|
||||
|
||||
const snapshot = await caller.tournament.getSnapshot();
|
||||
|
||||
expect(snapshot.participants).toEqual([
|
||||
expect.objectContaining({ id: 11, picture: '11.jpg', imageServer: 1 }),
|
||||
expect.objectContaining({ id: 12, picture: '12.jpg', imageServer: 0 }),
|
||||
]);
|
||||
});
|
||||
|
||||
it('refunds gold when the tournament bet rank update fails', async () => {
|
||||
const redis = new MemoryRedis();
|
||||
const transport = new TournamentTransport();
|
||||
|
||||
@@ -50,6 +50,8 @@ integration('gateway runtime action consumer', () => {
|
||||
create: {
|
||||
profileName,
|
||||
profile: 'runtime',
|
||||
instanceKey: 'consumer-integration',
|
||||
currentScenario: 'consumer-integration',
|
||||
scenario: 'consumer-integration',
|
||||
apiPort: 15998,
|
||||
status: 'RUNNING',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||
import { basename, resolve } from 'node:path';
|
||||
import { expect, test, type Page, type Route } from '@playwright/test';
|
||||
import { expect, test, type Locator, type Page, type Route } from '@playwright/test';
|
||||
import { gameBasePath, gameProfile, gameTrpcRoute } from './gameTestPaths.js';
|
||||
|
||||
const response = (data: unknown) => ({ result: { data } });
|
||||
@@ -20,6 +20,23 @@ const persistParityArtifact = async (page: Page, name: string, geometry: unknown
|
||||
]);
|
||||
};
|
||||
|
||||
const readGeneralPanelImages = async (panel: Locator) =>
|
||||
panel.evaluate((element) =>
|
||||
[...element.querySelectorAll<HTMLElement>('.general-image')].map((image) => {
|
||||
const rect = image.getBoundingClientRect();
|
||||
const style = getComputedStyle(image);
|
||||
return {
|
||||
label: image.getAttribute('aria-label'),
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
backgroundImage: style.backgroundImage,
|
||||
backgroundSize: style.backgroundSize,
|
||||
pointerEvents: style.pointerEvents,
|
||||
userSelect: style.userSelect,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
type FixtureState = {
|
||||
permission: 'head' | 'member';
|
||||
myset: number;
|
||||
@@ -545,9 +562,15 @@ test('재야 메인은 국가 틀과 성격·특기 표기명을 Chromium에 표
|
||||
|
||||
const generalCard = page.locator('.general-card');
|
||||
await expect(generalCard).toContainText('성격안전');
|
||||
await expect(generalCard).toContainText('전투특기신산');
|
||||
await expect(generalCard).toContainText('내정특기상재');
|
||||
await expect(generalCard).toContainText('특기상재 / 신산');
|
||||
await expect(generalCard).not.toContainText('che_');
|
||||
await expect(generalCard).toHaveAttribute('data-general-basic-card', '');
|
||||
const mainImages = await readGeneralPanelImages(generalCard);
|
||||
expect(mainImages).toHaveLength(2);
|
||||
expect(mainImages[0]).toMatchObject({ width: 64, height: 64, pointerEvents: 'none', userSelect: 'none' });
|
||||
expect(mainImages[0]?.backgroundImage).toContain('/icons/default.jpg');
|
||||
expect(mainImages[1]).toMatchObject({ width: 64, height: 64, pointerEvents: 'none', userSelect: 'none' });
|
||||
expect(mainImages[1]?.backgroundImage).toContain('/game/crewtype1.png');
|
||||
|
||||
const geometry = await nationCard.evaluate((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
@@ -587,9 +610,9 @@ test('메인 카드의 국가·수도·관직·계급·병종은 Ref 출력명
|
||||
await expect(nationCard).toContainText('국가 등급주자사');
|
||||
|
||||
const generalCard = page.locator('.general-card');
|
||||
await expect(generalCard.locator('.general-title')).toContainText('검증장수 · 간의대부');
|
||||
await expect(generalCard.locator('.general-title')).toContainText('검증장수 【 간의대부 | 건강 】');
|
||||
await expect(generalCard).toContainText('병종보병');
|
||||
await expect(generalCard).toContainText('계급29품관');
|
||||
await expect(generalCard).toContainText('계급 29품관');
|
||||
|
||||
const cityCard = page.locator('.city-card');
|
||||
await expect(cityCard.locator('.title')).toContainText('【중원 | 특】 업');
|
||||
@@ -646,11 +669,19 @@ test('접속량정보 keeps the legacy public 1016px chart geometry', async ({ p
|
||||
expect(mobileWidth).toBe(1016);
|
||||
});
|
||||
|
||||
test('내 정보&설정 keeps the legacy 1000px/500px geometry and saves in place', async ({ page }) => {
|
||||
test('내 정보&설정 keeps desktop density and becomes a 390px horizontal-identity layout', async ({ page }) => {
|
||||
const state: FixtureState = { permission: 'head', myset: 3, settingMutations: [], accessPages: [] };
|
||||
await install(page, state);
|
||||
await page.setViewportSize({ width: 1000, height: 900 });
|
||||
await page.goto('my-page');
|
||||
await expect(page.locator('.general-table')).toHaveAttribute('data-general-basic-card', '');
|
||||
const myPageImages = await readGeneralPanelImages(page.locator('.general-table'));
|
||||
expect(myPageImages.map(({ width, height }) => ({ width, height }))).toEqual([
|
||||
{ width: 64, height: 64 },
|
||||
{ width: 64, height: 64 },
|
||||
]);
|
||||
expect(myPageImages[0]?.backgroundImage).toContain('/icons/default.jpg');
|
||||
expect(myPageImages[1]?.backgroundImage).toContain('/game/crewtype1.png');
|
||||
await expect(page.locator('.legacy-general-details')).toContainText('계급 29품관');
|
||||
await expect(page.locator('.legacy-general-details')).toContainText('병종 보병');
|
||||
await expect(page.locator('.item-group')).toContainText('명마');
|
||||
@@ -696,7 +727,7 @@ test('내 정보&설정 keeps the legacy 1000px/500px geometry and saves in plac
|
||||
};
|
||||
});
|
||||
expect(desktop.width).toBe(1000);
|
||||
expect(desktop.minWidth).toBe('500px');
|
||||
expect(desktop.minWidth).toBe('0px');
|
||||
expect(desktop.fontSize).toBe('14px');
|
||||
expect(desktop.columns.split(' ')).toHaveLength(2);
|
||||
expect(desktop.titleHeight).toBeCloseTo(54, 0);
|
||||
@@ -766,26 +797,39 @@ test('내 정보&설정 keeps the legacy 1000px/500px geometry and saves in plac
|
||||
expect(state.settingMutations.at(-1)).not.toHaveProperty('generalId');
|
||||
}
|
||||
|
||||
await page.setViewportSize({ width: 500, height: 900 });
|
||||
await page.setViewportSize({ width: 390, height: 900 });
|
||||
await page.reload();
|
||||
const mobile = await page.locator('#container').evaluate((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
const settings = element.querySelector<HTMLElement>('.settings-column')!.getBoundingClientRect();
|
||||
const icon = element.querySelector<HTMLElement>('[data-general-basic-card] .general-icon')!.getBoundingClientRect();
|
||||
const name = element.querySelector<HTMLElement>('[data-general-basic-card] .general-title')!.getBoundingClientRect();
|
||||
return {
|
||||
width: rect.width,
|
||||
scrollWidth: document.documentElement.scrollWidth,
|
||||
columns: getComputedStyle(element.querySelector('.top-grid')!).gridTemplateColumns,
|
||||
settingsOffset: settings.x - rect.x,
|
||||
settingsWidth: settings.width,
|
||||
identity: {
|
||||
iconRight: icon.right,
|
||||
nameLeft: name.left,
|
||||
iconTop: icon.top,
|
||||
iconBottom: icon.bottom,
|
||||
nameTop: name.top,
|
||||
nameBottom: name.bottom,
|
||||
},
|
||||
};
|
||||
});
|
||||
expect(mobile).toMatchObject({
|
||||
width: 500,
|
||||
scrollWidth: 500,
|
||||
columns: '500px',
|
||||
width: 390,
|
||||
scrollWidth: 390,
|
||||
columns: '390px',
|
||||
settingsOffset: 0,
|
||||
settingsWidth: 500,
|
||||
settingsWidth: 390,
|
||||
});
|
||||
expect(mobile.identity.nameLeft).toBeGreaterThanOrEqual(mobile.identity.iconRight - 1);
|
||||
expect(mobile.identity.nameTop).toBeLessThan(mobile.identity.iconBottom);
|
||||
expect(mobile.identity.nameBottom).toBeGreaterThan(mobile.identity.iconTop);
|
||||
await persistParityArtifact(page, 'core-my-page-mobile', mobile);
|
||||
});
|
||||
|
||||
@@ -1109,10 +1153,15 @@ test('감찰부 keeps the selector interaction and shows the permission error pa
|
||||
await expect(page.locator('.selector-row select').nth(1)).toHaveValue('8');
|
||||
await page.getByRole('button', { name: '다음 ▶' }).click();
|
||||
await expect(page.locator('.selector-row select').nth(1)).toHaveValue('7');
|
||||
await expect(page.locator('.battle-general-name')).toContainText('검증장수 (간의대부)');
|
||||
await expect(page.locator('.battle-general-name')).toContainText('검증장수 【 간의대부 | 건강 】');
|
||||
await expect(page.locator('.battle-general-extra')).toContainText('계급29품관');
|
||||
await expect(page.locator('.battle-general-extra')).toContainText('병종보병');
|
||||
await expect(page.locator('.battle-general-card')).toContainText('병종보병');
|
||||
await expect(page.locator('.battle-general-card')).not.toContainText('che_');
|
||||
await expect(page.locator('.battle-general-card')).toHaveAttribute('data-general-basic-card', '');
|
||||
const battleImages = await readGeneralPanelImages(page.locator('.battle-general-card'));
|
||||
expect(battleImages).toHaveLength(2);
|
||||
expect(battleImages[0]?.backgroundImage).toContain('/icons/default.jpg');
|
||||
expect(battleImages[1]?.backgroundImage).toContain('/game/crewtype1.png');
|
||||
await expect(page.locator('.battle-general-card [role="progressbar"]')).toHaveCount(14);
|
||||
await expect(page.locator('.battle-general-card [aria-label*="1,275,975 (EX+)"]')).toHaveCount(5);
|
||||
expect(
|
||||
|
||||
@@ -514,7 +514,7 @@ const installRealtimeHarness = async (page: Page) => {
|
||||
|
||||
const waitForMain = async (page: Page) => {
|
||||
await page.goto('./');
|
||||
await expect(page.getByRole('heading', { name: '전장 현황' })).toBeVisible();
|
||||
await expect(page.locator('.game-shell__title')).toBeVisible();
|
||||
await expect(page.locator('.main-global-menu').first()).toBeVisible();
|
||||
await expect(page.locator('.main-nation-menu')).toBeVisible();
|
||||
await expect(page.locator('[data-navigation-id="npc-list"]')).toHaveCount(3);
|
||||
@@ -567,8 +567,24 @@ const persistArtifact = async (page: Page, name: string) => {
|
||||
globalPopup: describe('#mobile-global-menu'),
|
||||
nationPopup: describe('#mobile-nation-menu'),
|
||||
quickPopup: describe('#mobile-quick-menu'),
|
||||
commandMenu: describe('.reserved-command-editor details[open] .menu-items'),
|
||||
commandDividers: [...document.querySelectorAll<HTMLElement>('.reserved-command-editor details[open] .menu-divider')].map(
|
||||
(element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
const style = getComputedStyle(element);
|
||||
return {
|
||||
rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
|
||||
borderTop: style.borderTop,
|
||||
margin: style.margin,
|
||||
};
|
||||
}
|
||||
),
|
||||
};
|
||||
});
|
||||
const commandMenu = page.locator('.reserved-command-editor details[open] .menu-items').first();
|
||||
if (await commandMenu.isVisible()) {
|
||||
await commandMenu.screenshot({ path: resolve(target, `${name}-menu.png`) });
|
||||
}
|
||||
await Promise.all([
|
||||
page.screenshot({ path: resolve(target, `${name}.png`), fullPage: true }),
|
||||
writeFile(resolve(target, `${name}.json`), `${JSON.stringify(geometry, null, 2)}\n`),
|
||||
@@ -928,6 +944,10 @@ test('main cards and command input stay inside their Ref-sized grid slots', asyn
|
||||
await tenthTurnButton.click();
|
||||
const quickPicker = page.getByTestId('command-picker');
|
||||
await expect(quickPicker).toBeVisible();
|
||||
await tenthTurnButton.click();
|
||||
await expect(quickPicker).toBeHidden();
|
||||
await tenthTurnButton.click();
|
||||
await expect(quickPicker).toBeVisible();
|
||||
const quickPickerAlignment = await quickPicker.evaluate((element) => {
|
||||
const row = element
|
||||
.closest('.reserved-command-editor')
|
||||
@@ -969,6 +989,30 @@ test('main cards and command input stay inside their Ref-sized grid slots', asyn
|
||||
expect(advancedControlGeometry.rangeTop).toBe(advancedControlGeometry.recentTop);
|
||||
expect(advancedControlGeometry.advancedTop).toBeGreaterThan(advancedControlGeometry.rangeTop);
|
||||
expect(advancedControlGeometry.advancedBottom).toBeLessThanOrEqual(advancedControlGeometry.queueTop);
|
||||
const rangeMenu = page.locator('[data-main-target="commands"] .range-menu');
|
||||
await rangeMenu.locator('summary').click();
|
||||
const rangeDividers = rangeMenu.locator('.menu-divider');
|
||||
await expect(rangeDividers).toHaveCount(1);
|
||||
await expect(rangeDividers.first()).toBeVisible();
|
||||
expect(await rangeDividers.first().evaluate((element) => getComputedStyle(element).borderTop)).toBe(
|
||||
'1px solid rgb(68, 68, 68)'
|
||||
);
|
||||
await persistArtifact(page, `${basePath.slice(1)}-command-range-divider-desktop-1200`);
|
||||
await rangeMenu.evaluate((element) => ((element as HTMLDetailsElement).open = false));
|
||||
await expect(rangeMenu).not.toHaveAttribute('open', '');
|
||||
|
||||
const selectedMenu = page.locator('[data-main-target="commands"] .selected-menu');
|
||||
await selectedMenu.locator('summary').click();
|
||||
const selectedMenuDividers = selectedMenu.locator('.menu-divider');
|
||||
await expect(selectedMenuDividers).toHaveCount(3);
|
||||
await expect(selectedMenuDividers.first()).toBeVisible();
|
||||
expect(await selectedMenuDividers.first().evaluate((element) => getComputedStyle(element).borderTop)).toBe(
|
||||
'1px solid rgb(68, 68, 68)'
|
||||
);
|
||||
await persistArtifact(page, `${basePath.slice(1)}-command-selected-dividers-desktop-1200`);
|
||||
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 picker = page.getByTestId('command-picker');
|
||||
await expect(picker).toBeVisible();
|
||||
@@ -1157,6 +1201,11 @@ test('main cards and command input stay inside their Ref-sized grid slots', asyn
|
||||
expect(mobileGeometry.controlBoxes).toHaveLength(3);
|
||||
expect(new Set(mobileGeometry.controlBoxes.map(({ y }) => y)).size).toBe(1);
|
||||
await expect(page.locator('[data-main-target="commands"] .edit-column button')).toHaveCount(30);
|
||||
const mobileTurnButton = page.getByRole('button', { name: '10턴 명령 입력' });
|
||||
await mobileTurnButton.click();
|
||||
await expect(page.getByTestId('command-picker')).toBeVisible();
|
||||
await mobileTurnButton.click();
|
||||
await expect(page.getByTestId('command-picker')).toBeHidden();
|
||||
await captureProgress('mobile-500');
|
||||
});
|
||||
|
||||
|
||||
@@ -1,15 +1,22 @@
|
||||
import { expect, test, type Page, type Route } from '@playwright/test';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { mkdir, readFile } from 'node:fs/promises';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { gameProfile, gameTrpcRoute } from './gameTestPaths.js';
|
||||
|
||||
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../..');
|
||||
const responsiveArtifactDir = process.env.TOURNAMENT_RESPONSIVE_ARTIFACT_DIR;
|
||||
const imageRoots = [
|
||||
...(process.env.FRONTEND_PARITY_IMAGE_ROOT ? [resolve(process.env.FRONTEND_PARITY_IMAGE_ROOT, 'game')] : []),
|
||||
resolve(repositoryRoot, '../image/game'),
|
||||
resolve(repositoryRoot, '../../image/game'),
|
||||
];
|
||||
const iconRoots = [
|
||||
...(process.env.FRONTEND_PARITY_IMAGE_ROOT ? [resolve(process.env.FRONTEND_PARITY_IMAGE_ROOT, 'icons')] : []),
|
||||
resolve(repositoryRoot, '../image/icons'),
|
||||
resolve(repositoryRoot, '../../image/icons'),
|
||||
resolve(repositoryRoot, '../../sam_rebuild/image/icons'),
|
||||
];
|
||||
const names = [
|
||||
'관우',
|
||||
'장료',
|
||||
@@ -35,6 +42,8 @@ const participants = names.map((name, index) => ({
|
||||
strength: 80,
|
||||
intel: 80,
|
||||
level: 10,
|
||||
picture: 'default.jpg',
|
||||
imageServer: 0,
|
||||
groupId: 10 + (index % 8),
|
||||
groupNo: Math.floor(index / 8),
|
||||
win: 3 - (index % 2),
|
||||
@@ -88,6 +97,26 @@ const readReferenceImage = async (filename: string): Promise<Buffer> => {
|
||||
throw new Error(`Reference image not found: ${filename}`);
|
||||
};
|
||||
|
||||
const readReferenceIcon = async (filename: string): Promise<Buffer> => {
|
||||
for (const iconRoot of iconRoots) {
|
||||
try {
|
||||
return await readFile(resolve(iconRoot, filename));
|
||||
} catch {
|
||||
// Worktrees can be nested at different depths.
|
||||
}
|
||||
}
|
||||
throw new Error(`Reference icon not found: ${filename}`);
|
||||
};
|
||||
|
||||
const persistScreenshot = async (page: Page, name: string, fallbackPath: string) => {
|
||||
if (!responsiveArtifactDir) {
|
||||
await page.screenshot({ path: fallbackPath, fullPage: true });
|
||||
return;
|
||||
}
|
||||
await mkdir(responsiveArtifactDir, { recursive: true });
|
||||
await page.screenshot({ path: resolve(responsiveArtifactDir, `${name}.webp`), fullPage: true });
|
||||
};
|
||||
|
||||
const installFixture = async (page: Page) => {
|
||||
await page.addInitScript((profile) => {
|
||||
window.localStorage.setItem('sammo-game-token', 'ga_tournament_bracket_playwright');
|
||||
@@ -98,6 +127,9 @@ const installFixture = async (page: Page) => {
|
||||
await route.fulfill({ status: 200, contentType: 'image/jpeg', body: await readReferenceImage(filename) });
|
||||
});
|
||||
}
|
||||
await page.route('**/icons/default.jpg', async (route) => {
|
||||
await route.fulfill({ status: 200, contentType: 'image/jpeg', body: await readReferenceIcon('default.jpg') });
|
||||
});
|
||||
await page.route(gameTrpcRoute, async (route) => {
|
||||
const results = operationNames(route).map((operation) => {
|
||||
if (operation === 'auth.status') return response({ ok: true });
|
||||
@@ -125,12 +157,43 @@ const installFixture = async (page: Page) => {
|
||||
}
|
||||
if (operation === 'tournament.getBettingSummary') {
|
||||
return response({
|
||||
totals: Object.fromEntries(participants.map((participant, index) => [participant.id, 100 + index * 10])),
|
||||
totals: Object.fromEntries(
|
||||
participants.map((participant, index) => [participant.id, 100 + index * 10])
|
||||
),
|
||||
myTotals: {},
|
||||
totalAmount: 2800,
|
||||
myAmount: 0,
|
||||
});
|
||||
}
|
||||
if (operation === 'tournament.getRankings') {
|
||||
return response(
|
||||
[
|
||||
['tt', '전 력 전', '종합'],
|
||||
['tl', '통 솔 전', '통솔'],
|
||||
['ts', '일 기 토', '무력'],
|
||||
['ti', '설 전', '지력'],
|
||||
].map(([prefix, title, statLabel]) => ({
|
||||
prefix,
|
||||
title,
|
||||
statLabel,
|
||||
entries: participants.slice(0, 6).map((participant, index) => ({
|
||||
rank: index + 1,
|
||||
generalId: participant.id,
|
||||
name: participant.name,
|
||||
picture: participant.picture,
|
||||
imageServer: participant.imageServer,
|
||||
npcState: 0,
|
||||
stat: 240 - index,
|
||||
games: 10,
|
||||
win: 7,
|
||||
draw: 1,
|
||||
lose: 2,
|
||||
score: 22 - index,
|
||||
prizes: 3,
|
||||
})),
|
||||
}))
|
||||
);
|
||||
}
|
||||
return response(null);
|
||||
});
|
||||
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(results) });
|
||||
@@ -162,16 +225,20 @@ test('desktop bracket connects every real general slot to the next round', async
|
||||
connectorCenter: firstConnector.x + firstConnector.width / 2,
|
||||
championCenter: champion.x + champion.width / 2,
|
||||
finalistCenters: finalists.map((rect) => rect.x + rect.width / 2),
|
||||
connectorQuarters: [firstConnector.x + firstConnector.width / 4, firstConnector.x + (firstConnector.width * 3) / 4],
|
||||
connectorQuarters: [
|
||||
firstConnector.x + firstConnector.width / 4,
|
||||
firstConnector.x + (firstConnector.width * 3) / 4,
|
||||
],
|
||||
};
|
||||
});
|
||||
expect(geometry.canvasWidth).toBe(2000);
|
||||
expect(geometry.canvasWidth).toBeGreaterThanOrEqual(1000);
|
||||
expect(geometry.canvasWidth).toBeLessThanOrEqual(1200);
|
||||
expect(Math.abs(geometry.connectorCenter - geometry.championCenter)).toBeLessThan(1);
|
||||
expect(geometry.finalistCenters).toHaveLength(2);
|
||||
expect(Math.abs(geometry.finalistCenters[0]! - geometry.connectorQuarters[0]!)).toBeLessThan(1);
|
||||
expect(Math.abs(geometry.finalistCenters[1]! - geometry.connectorQuarters[1]!)).toBeLessThan(1);
|
||||
|
||||
await page.screenshot({ path: testInfo.outputPath('tournament-bracket-desktop.webp'), fullPage: true });
|
||||
await persistScreenshot(page, 'tournament-desktop', testInfo.outputPath('tournament-bracket-desktop.webp'));
|
||||
});
|
||||
|
||||
test('mobile bracket shows every round and general within the handheld width', async ({ page }, testInfo) => {
|
||||
@@ -197,5 +264,62 @@ test('mobile bracket shows every round and general within the handheld width', a
|
||||
expect(bounds.width).toBe(390);
|
||||
expect(bounds.minX).toBeGreaterThanOrEqual(0);
|
||||
expect(bounds.maxX).toBeLessThanOrEqual(390);
|
||||
await page.screenshot({ path: testInfo.outputPath('tournament-bracket-mobile.webp'), fullPage: true });
|
||||
const identity = await bracket
|
||||
.locator('.mobile-bracket-name')
|
||||
.first()
|
||||
.evaluate((element) => {
|
||||
const icon = element.querySelector('img')!.getBoundingClientRect();
|
||||
const name = element.querySelector<HTMLElement>('.general-identity-name')!.getBoundingClientRect();
|
||||
return { iconRight: icon.right, nameLeft: name.left, iconY: icon.y, nameY: name.y };
|
||||
});
|
||||
expect(identity.nameLeft).toBeGreaterThanOrEqual(identity.iconRight);
|
||||
expect(Math.abs(identity.iconY - identity.nameY)).toBeLessThan(8);
|
||||
await expect(page.getByRole('tablist', { name: '본선 조 선택' })).toBeVisible();
|
||||
await page.getByRole('tab', { name: '二조' }).first().click();
|
||||
await expect(page.getByRole('tab', { name: '二조' }).first()).toHaveAttribute('aria-selected', 'true');
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(390);
|
||||
await persistScreenshot(page, 'tournament-mobile', testInfo.outputPath('tournament-bracket-mobile.webp'));
|
||||
});
|
||||
|
||||
test('mobile betting rankings use tabs and keep dedicated icons beside general names', async ({ page }, testInfo) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await installFixture(page);
|
||||
await page.goto('betting');
|
||||
|
||||
await expect(page.locator('.candidate-card')).toHaveCount(16);
|
||||
await expect(page.getByRole('tablist', { name: '토너먼트 랭킹 종목 선택' })).toBeVisible();
|
||||
await expect(page.locator('.ranking-table:visible')).toHaveCount(1);
|
||||
await page.getByRole('tab', { name: '통솔전' }).click();
|
||||
await expect(page.getByRole('tab', { name: '통솔전' })).toHaveAttribute('aria-selected', 'true');
|
||||
await expect(page.locator('.ranking-table:visible thead')).toContainText('통 솔 전');
|
||||
|
||||
const identity = await page
|
||||
.locator('.ranking-table:visible .general-identity')
|
||||
.first()
|
||||
.evaluate((element) => {
|
||||
const icon = element.querySelector('img')!.getBoundingClientRect();
|
||||
const name = element.querySelector<HTMLElement>('.general-identity-name')!.getBoundingClientRect();
|
||||
return { iconRight: icon.right, nameLeft: name.left, iconY: icon.y, nameY: name.y };
|
||||
});
|
||||
expect(identity.nameLeft).toBeGreaterThanOrEqual(identity.iconRight);
|
||||
expect(Math.abs(identity.iconY - identity.nameY)).toBeLessThan(8);
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(390);
|
||||
await persistScreenshot(page, 'tournament-ranking-mobile', testInfo.outputPath('tournament-ranking-mobile.webp'));
|
||||
});
|
||||
|
||||
test('desktop betting presents icon-and-name cards and all four rankings without document overflow', async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
await page.setViewportSize({ width: 1365, height: 900 });
|
||||
await installFixture(page);
|
||||
await page.goto('betting');
|
||||
|
||||
await expect(page.locator('.candidate-card')).toHaveCount(16);
|
||||
await expect(page.locator('.ranking-table:visible')).toHaveCount(4);
|
||||
const columns = await page
|
||||
.locator('.candidate-grid')
|
||||
.evaluate((element) => getComputedStyle(element).gridTemplateColumns.split(' ').length);
|
||||
expect(columns).toBe(4);
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(1365);
|
||||
await persistScreenshot(page, 'tournament-ranking-desktop', testInfo.outputPath('tournament-ranking-desktop.webp'));
|
||||
});
|
||||
|
||||
@@ -39,6 +39,13 @@ body {
|
||||
min-width: 500px;
|
||||
}
|
||||
|
||||
/* These redesigned identity/tournament screens own a true handheld layout. */
|
||||
#app:has(.responsive-settings-page),
|
||||
#app:has(#tournament-container),
|
||||
#app:has(#tournament-betting-container) {
|
||||
min-width: 320px;
|
||||
}
|
||||
|
||||
body:has(.battle-page),
|
||||
body:has(.chief-page),
|
||||
body:has(.global-page),
|
||||
|
||||
@@ -149,6 +149,14 @@ const closePicker = () => {
|
||||
quickTarget.value = null;
|
||||
selectedCommand.value = null;
|
||||
};
|
||||
const togglePicker = (turnIndex?: number) => {
|
||||
const target = turnIndex ?? null;
|
||||
if (pickerOpen.value && quickTarget.value === target) {
|
||||
closePicker();
|
||||
return;
|
||||
}
|
||||
openPicker(turnIndex);
|
||||
};
|
||||
const selectCommand = (commandKey: string) => {
|
||||
const command = props.commandTable?.[props.scope]
|
||||
.flatMap((group) => group.values)
|
||||
@@ -305,6 +313,7 @@ const clickOutsideMenu = (event: Event) => {
|
||||
>
|
||||
짝수턴
|
||||
</button>
|
||||
<hr class="menu-divider" />
|
||||
<template v-for="step in [3, 4, 5, 6, 7]" :key="step">
|
||||
<small>{{ step }}턴 간격</small>
|
||||
<div class="step-buttons">
|
||||
@@ -429,6 +438,7 @@ const clickOutsideMenu = (event: Event) => {
|
||||
>
|
||||
붙여넣기
|
||||
</button>
|
||||
<hr class="menu-divider" />
|
||||
<button
|
||||
@click="
|
||||
textCopy();
|
||||
@@ -437,6 +447,7 @@ const clickOutsideMenu = (event: Event) => {
|
||||
>
|
||||
텍스트 복사
|
||||
</button>
|
||||
<hr class="menu-divider" />
|
||||
<button
|
||||
@click="
|
||||
saveTemplate();
|
||||
@@ -453,6 +464,7 @@ const clickOutsideMenu = (event: Event) => {
|
||||
>
|
||||
반복하기
|
||||
</button>
|
||||
<hr class="menu-divider" />
|
||||
<button
|
||||
@click="
|
||||
clearSelection();
|
||||
@@ -479,7 +491,7 @@ const clickOutsideMenu = (event: Event) => {
|
||||
</button>
|
||||
</div>
|
||||
</details>
|
||||
<button type="button" class="select-command" @click="openPicker()">명령 선택 ▾</button>
|
||||
<button type="button" class="select-command" @click="togglePicker()">명령 선택 ▾</button>
|
||||
</div>
|
||||
|
||||
<div class="queue-area">
|
||||
@@ -542,7 +554,7 @@ const clickOutsideMenu = (event: Event) => {
|
||||
:key="row.index"
|
||||
type="button"
|
||||
:aria-label="`${row.index + 1}턴 명령 입력`"
|
||||
@click="openPicker(row.index)"
|
||||
@click="togglePicker(row.index)"
|
||||
>
|
||||
✎
|
||||
</button>
|
||||
@@ -708,6 +720,14 @@ const clickOutsideMenu = (event: Event) => {
|
||||
padding: 5px 8px;
|
||||
color: #bbb;
|
||||
}
|
||||
.menu-divider {
|
||||
width: 100%;
|
||||
height: 0;
|
||||
margin: 4px 0;
|
||||
border: 0;
|
||||
border-top: 1px solid #444;
|
||||
opacity: 1;
|
||||
}
|
||||
.step-buttons,
|
||||
.template-row {
|
||||
display: flex;
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
|
||||
import SkeletonLines from '../ui/SkeletonLines.vue';
|
||||
import LegacyProgressBar from '../ui/LegacyProgressBar.vue';
|
||||
import { formatSeoulHourMinute } from '../../utils/legacyDateTime';
|
||||
import { legacyExperiencePercent, ratioPercent } from '../../utils/legacyProgress';
|
||||
import { DEFAULT_GENERAL_ICON_URL, resolveGeneralIconBackgroundImage } from '../../utils/generalIcon';
|
||||
import { configuredGameAssetUrl } from '../../utils/imageAssets';
|
||||
|
||||
interface GeneralStats {
|
||||
leadership: number;
|
||||
@@ -19,9 +22,18 @@ interface GeneralProgression {
|
||||
statUpgradeLimit?: number;
|
||||
}
|
||||
|
||||
interface ItemDisplayNames {
|
||||
horse?: string | null;
|
||||
weapon?: string | null;
|
||||
book?: string | null;
|
||||
item?: string | null;
|
||||
}
|
||||
|
||||
interface GeneralInfo {
|
||||
id: number;
|
||||
name: string;
|
||||
picture?: string | null;
|
||||
imageServer?: number | null;
|
||||
npcState: number;
|
||||
officerLevel: number;
|
||||
officerLevelText: string;
|
||||
@@ -35,17 +47,36 @@ interface GeneralInfo {
|
||||
experience: number;
|
||||
dedication: number;
|
||||
age?: number;
|
||||
turnTime?: string;
|
||||
turnTime?: string | null;
|
||||
troopId?: number;
|
||||
crewTypeId?: number;
|
||||
crewTypeName?: string;
|
||||
traits?: { personal: string; specialWar: string; specialDomestic: string };
|
||||
progression?: GeneralProgression;
|
||||
itemNames?: ItemDisplayNames;
|
||||
equipmentNames?: ItemDisplayNames;
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
general: GeneralInfo | null;
|
||||
loading: boolean;
|
||||
}>();
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
general: GeneralInfo | null;
|
||||
loading: boolean;
|
||||
nationColor?: string | null;
|
||||
defenceText?: string | null;
|
||||
killTurn?: number | null;
|
||||
remainingMinutes?: number | null;
|
||||
troopText?: string | null;
|
||||
penaltyText?: string | number | null;
|
||||
}>(),
|
||||
{
|
||||
nationColor: '#173d27',
|
||||
defenceText: null,
|
||||
killTurn: null,
|
||||
remainingMinutes: null,
|
||||
troopText: null,
|
||||
penaltyText: null,
|
||||
}
|
||||
);
|
||||
|
||||
const statRows = computed(() => {
|
||||
const general = props.general;
|
||||
@@ -72,137 +103,320 @@ const statRows = computed(() => {
|
||||
const experiencePercent = computed(() =>
|
||||
legacyExperiencePercent(props.general?.experience ?? 0, props.general?.progression?.experienceLevel ?? 0)
|
||||
);
|
||||
|
||||
const itemNames = computed<ItemDisplayNames>(() => props.general?.itemNames ?? props.general?.equipmentNames ?? {});
|
||||
|
||||
const generalIconBackground = computed(() => resolveGeneralIconBackgroundImage(props.general ?? {}));
|
||||
|
||||
const crewTypeIconBackground = computed(() => {
|
||||
const crewTypeId = props.general?.crewTypeId;
|
||||
if (crewTypeId === undefined || !Number.isFinite(crewTypeId)) {
|
||||
return `url(${JSON.stringify(DEFAULT_GENERAL_ICON_URL)})`;
|
||||
}
|
||||
const crewTypeUrl = `${configuredGameAssetUrl()}/crewtype${Math.trunc(crewTypeId)}.png`;
|
||||
return `url(${JSON.stringify(crewTypeUrl)}), url(${JSON.stringify(DEFAULT_GENERAL_ICON_URL)})`;
|
||||
});
|
||||
|
||||
const injuryInfo = computed(() => {
|
||||
const injury = props.general?.injury ?? 0;
|
||||
if (injury > 60) return { text: '위독', color: '#ff4d4f' };
|
||||
if (injury > 40) return { text: '심각', color: '#ff00ff' };
|
||||
if (injury > 20) return { text: '중상', color: '#ff9f1a' };
|
||||
if (injury > 0) return { text: '경상', color: '#ffff00' };
|
||||
return { text: '건강', color: '#ffffff' };
|
||||
});
|
||||
|
||||
const isBrightColor = (color: string): boolean => {
|
||||
const normalized = /^#[0-9a-f]{6}$/iu.test(color) ? color.slice(1) : '173d27';
|
||||
const red = Number.parseInt(normalized.slice(0, 2), 16);
|
||||
const green = Number.parseInt(normalized.slice(2, 4), 16);
|
||||
const blue = Number.parseInt(normalized.slice(4, 6), 16);
|
||||
return (red * 299 + green * 587 + blue * 114) / 1000 >= 150;
|
||||
};
|
||||
|
||||
const titleStyle = computed(() => {
|
||||
const backgroundColor = props.nationColor || '#173d27';
|
||||
return {
|
||||
backgroundColor,
|
||||
color: isBrightColor(backgroundColor) ? '#000000' : '#ffffff',
|
||||
};
|
||||
});
|
||||
|
||||
const ageColor = computed(() => {
|
||||
const age = props.general?.age;
|
||||
if (age === undefined) return '#ffffff';
|
||||
if (age < 53) return '#32cd32';
|
||||
if (age < 70) return '#ffff00';
|
||||
return '#ff4d4f';
|
||||
});
|
||||
|
||||
const displayTroop = computed(() => props.troopText ?? (props.general?.troopId ? String(props.general.troopId) : '-'));
|
||||
const displayPenalty = computed(() => {
|
||||
const penalty = props.penaltyText ?? '-';
|
||||
const dedication = props.general?.progression?.dedicationText ?? '무품관';
|
||||
return `${penalty} · 계급 ${dedication}`;
|
||||
});
|
||||
const displayDefence = computed(() => props.defenceText ?? '-');
|
||||
const specialText = computed(() => {
|
||||
const traits = props.general?.traits;
|
||||
return traits ? `${traits.specialDomestic || '-'} / ${traits.specialWar || '-'}` : '-';
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="general-card">
|
||||
<div v-if="props.loading">
|
||||
<div class="general-card" data-general-basic-card>
|
||||
<div v-if="props.loading" class="general-loading">
|
||||
<SkeletonLines :lines="5" />
|
||||
</div>
|
||||
<div v-else-if="!props.general" class="empty">장수 정보를 불러오지 못했습니다.</div>
|
||||
<div v-else class="general-body">
|
||||
<div class="general-title">
|
||||
{{ props.general.name }} · {{ props.general.officerLevelText }} · {{ props.general.age ?? '-' }}세 ·
|
||||
다음 턴
|
||||
{{ props.general.turnTime ? formatSeoulHourMinute(props.general.turnTime) : '-' }}
|
||||
</div>
|
||||
<template v-else>
|
||||
<div class="general-basic-grid general-body">
|
||||
<span
|
||||
class="general-image general-icon"
|
||||
role="img"
|
||||
:aria-label="`${props.general.name} 초상`"
|
||||
:style="{ backgroundImage: generalIconBackground }"
|
||||
/>
|
||||
<div class="general-title battle-general-name" :style="titleStyle">
|
||||
{{ props.general.name }} 【 {{ props.general.officerLevelText }} |
|
||||
<span :style="{ color: injuryInfo.color }">{{ injuryInfo.text }}</span> 】 다음 턴
|
||||
{{ props.general.turnTime ? formatSeoulHourMinute(props.general.turnTime) : '-' }}
|
||||
</div>
|
||||
|
||||
<div class="stat-progress-grid">
|
||||
<template v-for="stat of statRows" :key="stat.key">
|
||||
<span class="cell-label">{{ stat.label }}</span>
|
||||
<strong>{{ stat.value }}</strong>
|
||||
<div class="bar-cell" :data-stat-progress="stat.key">
|
||||
<LegacyProgressBar
|
||||
:percent="stat.percent"
|
||||
:label="`${stat.label} 성장 ${stat.accumulated} / ${stat.limit}`"
|
||||
/>
|
||||
</div>
|
||||
<strong class="stat-value">
|
||||
<span>{{ stat.value }}</span>
|
||||
<span class="bar-cell" :data-stat-progress="stat.key">
|
||||
<LegacyProgressBar
|
||||
:percent="stat.percent"
|
||||
:label="`${stat.label} 성장 ${stat.accumulated} / ${stat.limit}`"
|
||||
/>
|
||||
</span>
|
||||
</strong>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="legacy-grid">
|
||||
<span>자금</span><strong>{{ props.general.gold.toLocaleString() }}</strong> <span>군량</span
|
||||
><strong>{{ props.general.rice.toLocaleString() }}</strong> <span>병력</span
|
||||
><strong>{{ props.general.crew.toLocaleString() }}</strong> <span>훈련</span
|
||||
><strong>{{ props.general.train }}</strong> <span>사기</span><strong>{{ props.general.atmos }}</strong>
|
||||
<span>부상</span><strong>{{ props.general.injury }}</strong> <span>병종</span
|
||||
><strong>{{ props.general.crewTypeName ?? '-' }}</strong> <span>성격</span
|
||||
><strong>{{ props.general.traits?.personal ?? '-' }}</strong> <span>전투특기</span
|
||||
><strong>{{ props.general.traits?.specialWar ?? '-' }}</strong> <span>내정특기</span
|
||||
><strong>{{ props.general.traits?.specialDomestic ?? '-' }}</strong> <span>계급</span
|
||||
><strong>{{ props.general.progression?.dedicationText ?? '무품관' }}</strong> <span>공헌</span
|
||||
><strong>{{ props.general.dedication.toLocaleString() }}</strong>
|
||||
</div>
|
||||
<span class="cell-label">명마</span><strong>{{ itemNames.horse ?? '-' }}</strong>
|
||||
<span class="cell-label">무기</span><strong>{{ itemNames.weapon ?? '-' }}</strong>
|
||||
<span class="cell-label">서적</span><strong>{{ itemNames.book ?? '-' }}</strong>
|
||||
|
||||
<div class="experience-row">
|
||||
<span class="cell-label">Lv</span>
|
||||
<strong>{{ props.general.progression?.experienceLevel ?? 0 }}</strong>
|
||||
<div class="bar-cell" data-experience-progress>
|
||||
<span
|
||||
class="general-image general-crew-type-icon"
|
||||
role="img"
|
||||
:aria-label="`${props.general.crewTypeName ?? '병종'} 이미지`"
|
||||
:style="{ backgroundImage: crewTypeIconBackground }"
|
||||
/>
|
||||
<span class="cell-label">자금</span><strong>{{ props.general.gold.toLocaleString('ko-KR') }}</strong>
|
||||
<span class="cell-label">군량</span><strong>{{ props.general.rice.toLocaleString('ko-KR') }}</strong>
|
||||
<span class="cell-label">도구</span><strong>{{ itemNames.item ?? '-' }}</strong>
|
||||
|
||||
<span class="cell-label">병종</span><strong>{{ props.general.crewTypeName ?? '-' }}</strong>
|
||||
<span class="cell-label">병사</span><strong>{{ props.general.crew.toLocaleString('ko-KR') }}</strong>
|
||||
<span class="cell-label">성격</span><strong>{{ props.general.traits?.personal ?? '-' }}</strong>
|
||||
|
||||
<span class="cell-label">훈련</span><strong>{{ props.general.train }}</strong>
|
||||
<span class="cell-label">사기</span><strong>{{ props.general.atmos }}</strong>
|
||||
<span class="cell-label">특기</span><strong :title="specialText">{{ specialText }}</strong>
|
||||
|
||||
<span class="cell-label level-label">Lv</span>
|
||||
<strong class="level-value">{{ props.general.progression?.experienceLevel ?? 0 }}</strong>
|
||||
<span class="experience-bar" data-experience-progress>
|
||||
<LegacyProgressBar
|
||||
:percent="experiencePercent"
|
||||
:label="`경험 레벨 진행 ${experiencePercent.toFixed(1)}%`"
|
||||
/>
|
||||
</div>
|
||||
<span class="experience-total">명성 {{ props.general.experience.toLocaleString() }}</span>
|
||||
</span>
|
||||
<span class="cell-label age-label">연령</span>
|
||||
<strong class="age-value" :style="{ color: ageColor }">{{ props.general.age ?? '-' }}세</strong>
|
||||
|
||||
<span class="cell-label defence-label">수비</span>
|
||||
<strong class="defence-value">{{ displayDefence }}</strong>
|
||||
<span class="cell-label kill-label">삭턴</span>
|
||||
<strong class="kill-value">{{ props.killTurn === null ? '-' : `${props.killTurn} 턴` }}</strong>
|
||||
<span class="cell-label execute-label">실행</span>
|
||||
<strong class="execute-value">{{
|
||||
props.remainingMinutes === null ? '-' : `${props.remainingMinutes}분 남음`
|
||||
}}</strong>
|
||||
|
||||
<span class="cell-label troop-label">부대</span>
|
||||
<strong class="troop-value">{{ displayTroop }}</strong>
|
||||
<span class="cell-label penalty-label">벌점</span>
|
||||
<strong class="penalty-value">{{ displayPenalty }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<slot name="details" />
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.general-title {
|
||||
.general-card {
|
||||
box-sizing: border-box;
|
||||
height: 20px;
|
||||
min-height: 20px;
|
||||
padding: 1px 6px;
|
||||
border-bottom: 1px solid #777;
|
||||
background: #173d27;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.stat-progress-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(30px, 1fr) minmax(34px, 1fr) 45px);
|
||||
grid-auto-rows: 21px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.stat-progress-grid > *,
|
||||
.legacy-grid > * {
|
||||
box-sizing: border-box;
|
||||
height: 21px;
|
||||
min-height: 0;
|
||||
border-right: 1px solid #666;
|
||||
border-bottom: 1px solid #666;
|
||||
padding: 2px 4px;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
background-color: #172a52;
|
||||
background-image: var(--sammo-texture-blue);
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.general-basic-grid {
|
||||
display: grid;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
grid-template-columns: 64px repeat(3, minmax(30px, 2fr) minmax(60px, 5fr));
|
||||
grid-template-rows: repeat(9, calc(64px / 3));
|
||||
border-right: 1px solid #777;
|
||||
border-bottom: 1px solid #777;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.general-basic-grid > * {
|
||||
box-sizing: border-box;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
border-top: 1px solid #777;
|
||||
border-left: 1px solid #777;
|
||||
padding: 1px 3px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.cell-label,
|
||||
.legacy-grid > span {
|
||||
background: rgb(20 75 42 / 70%);
|
||||
.general-basic-grid > strong {
|
||||
font-weight: 500;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat-progress-grid > strong,
|
||||
.legacy-grid > strong {
|
||||
text-align: right;
|
||||
font-weight: 400;
|
||||
.cell-label {
|
||||
background-color: rgb(20 75 42 / 70%);
|
||||
}
|
||||
|
||||
.bar-cell {
|
||||
.general-image {
|
||||
display: block;
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
padding: 0;
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
background-size: contain;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
-webkit-user-drag: none;
|
||||
}
|
||||
|
||||
.general-icon {
|
||||
grid-column: 1;
|
||||
grid-row: 1 / 4;
|
||||
}
|
||||
|
||||
.general-title {
|
||||
grid-column: 2 / 8;
|
||||
grid-row: 1;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(22px, auto) minmax(26px, 1fr);
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.bar-cell,
|
||||
.experience-bar {
|
||||
display: grid;
|
||||
align-content: center;
|
||||
padding: 0 1px;
|
||||
}
|
||||
|
||||
.legacy-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||
grid-auto-rows: 21px;
|
||||
font-size: 12px;
|
||||
.general-crew-type-icon {
|
||||
grid-column: 1;
|
||||
grid-row: 4 / 7;
|
||||
}
|
||||
|
||||
.experience-row {
|
||||
display: grid;
|
||||
box-sizing: border-box;
|
||||
grid-template-columns: 32px 38px minmax(120px, 1fr) 112px;
|
||||
height: 20px;
|
||||
min-height: 20px;
|
||||
border-bottom: 1px solid #666;
|
||||
font-size: 12px;
|
||||
.level-label {
|
||||
grid-column: 1;
|
||||
grid-row: 7;
|
||||
}
|
||||
|
||||
.experience-row > * {
|
||||
display: grid;
|
||||
align-content: center;
|
||||
box-sizing: border-box;
|
||||
border-right: 1px solid #666;
|
||||
padding: 1px 4px;
|
||||
text-align: center;
|
||||
.level-value {
|
||||
grid-column: 2;
|
||||
grid-row: 7;
|
||||
}
|
||||
|
||||
.experience-bar {
|
||||
grid-column: 3 / 6;
|
||||
grid-row: 7;
|
||||
}
|
||||
|
||||
.age-label {
|
||||
grid-column: 6;
|
||||
grid-row: 7;
|
||||
}
|
||||
|
||||
.age-value {
|
||||
grid-column: 7;
|
||||
grid-row: 7;
|
||||
}
|
||||
|
||||
.defence-label {
|
||||
grid-column: 1;
|
||||
grid-row: 8;
|
||||
}
|
||||
|
||||
.defence-value {
|
||||
grid-column: 2 / 4;
|
||||
grid-row: 8;
|
||||
}
|
||||
|
||||
.kill-label {
|
||||
grid-column: 4;
|
||||
grid-row: 8;
|
||||
}
|
||||
|
||||
.kill-value {
|
||||
grid-column: 5;
|
||||
grid-row: 8;
|
||||
}
|
||||
|
||||
.execute-label {
|
||||
grid-column: 6;
|
||||
grid-row: 8;
|
||||
}
|
||||
|
||||
.execute-value {
|
||||
grid-column: 7;
|
||||
grid-row: 8;
|
||||
}
|
||||
|
||||
.troop-label {
|
||||
grid-column: 1;
|
||||
grid-row: 9;
|
||||
}
|
||||
|
||||
.troop-value {
|
||||
grid-column: 2 / 4;
|
||||
grid-row: 9;
|
||||
}
|
||||
|
||||
.penalty-label {
|
||||
grid-column: 4;
|
||||
grid-row: 9;
|
||||
}
|
||||
|
||||
.penalty-value {
|
||||
grid-column: 5 / 8;
|
||||
grid-row: 9;
|
||||
}
|
||||
|
||||
.general-loading,
|
||||
.empty {
|
||||
min-height: 192px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.empty {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import GeneralIdentity from '../ui/GeneralIdentity.vue';
|
||||
import {
|
||||
buildTournamentBracket,
|
||||
type TournamentBracketMatch,
|
||||
@@ -89,7 +90,12 @@ const odds = (id: number | null) => {
|
||||
:class="{ advanced: bracket.champion.advanced }"
|
||||
:data-general-id="bracket.champion.id ?? undefined"
|
||||
>
|
||||
{{ bracket.champion.name }}
|
||||
<GeneralIdentity
|
||||
:name="bracket.champion.name"
|
||||
:picture="bracket.champion.picture"
|
||||
:image-server="bracket.champion.imageServer"
|
||||
:icon-size="24"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -110,7 +116,12 @@ const odds = (id: number | null) => {
|
||||
:class="{ advanced: slot.advanced }"
|
||||
:data-general-id="slot.id ?? undefined"
|
||||
>
|
||||
{{ slot.name }}
|
||||
<GeneralIdentity
|
||||
:name="slot.name"
|
||||
:picture="slot.picture"
|
||||
:image-server="slot.imageServer"
|
||||
:icon-size="22"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
<div class="connector-row" :style="{ '--connector-count': round.slots.length }">
|
||||
@@ -140,7 +151,12 @@ const odds = (id: number | null) => {
|
||||
:class="{ advanced: slot.advanced }"
|
||||
:data-general-id="slot.id ?? undefined"
|
||||
>
|
||||
{{ slot.name }}
|
||||
<GeneralIdentity
|
||||
:name="slot.name"
|
||||
:picture="slot.picture"
|
||||
:image-server="slot.imageServer"
|
||||
:icon-size="20"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
<div class="bracket-round bracket-odds" :style="roundStyle(bracket.top16)">
|
||||
@@ -183,9 +199,17 @@ const odds = (id: number | null) => {
|
||||
:key="`mobile-${columnIndex}-${slot.id ?? 'empty'}-${slotIndex}`"
|
||||
class="mobile-bracket-name"
|
||||
:class="{ advanced: slot.advanced }"
|
||||
:style="{ left: `${mobileX[columnIndex]}px`, top: `${mobileY(columnIndex, slotIndex)}px` }"
|
||||
:style="{
|
||||
left: `${(mobileX[columnIndex]! / 390) * 100}%`,
|
||||
top: `${mobileY(columnIndex, slotIndex)}px`,
|
||||
}"
|
||||
>
|
||||
{{ slot.name }}
|
||||
<GeneralIdentity
|
||||
:name="slot.name"
|
||||
:picture="slot.picture"
|
||||
:image-server="slot.imageServer"
|
||||
:icon-size="18"
|
||||
/>
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
@@ -210,21 +234,23 @@ const odds = (id: number | null) => {
|
||||
white-space: nowrap;
|
||||
}
|
||||
.bracket-canvas {
|
||||
width: 2000px;
|
||||
min-width: 2000px;
|
||||
width: 100%;
|
||||
min-width: 1000px;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.mobile-bracket {
|
||||
position: relative;
|
||||
display: none;
|
||||
width: 390px;
|
||||
width: 100%;
|
||||
max-width: 390px;
|
||||
height: 544px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.mobile-bracket svg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 390px;
|
||||
width: 100%;
|
||||
height: 544px;
|
||||
}
|
||||
.mobile-connector {
|
||||
@@ -239,14 +265,16 @@ const odds = (id: number | null) => {
|
||||
.mobile-bracket-name {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
width: 64px;
|
||||
width: clamp(58px, 18vw, 72px);
|
||||
overflow: hidden;
|
||||
transform: translate(-50%, -50%);
|
||||
border: 1px solid #555;
|
||||
background: rgb(58 33 24 / 92%);
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
line-height: 22px;
|
||||
min-height: 26px;
|
||||
padding: 2px;
|
||||
font-size: 11px;
|
||||
line-height: 20px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -265,7 +293,7 @@ const odds = (id: number | null) => {
|
||||
}
|
||||
.bracket-name {
|
||||
overflow: hidden;
|
||||
padding: 0 3px;
|
||||
padding: 2px 3px;
|
||||
color: #fff;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
@@ -321,8 +349,8 @@ const odds = (id: number | null) => {
|
||||
}
|
||||
@media (max-width: 800px) {
|
||||
.tournament-bracket {
|
||||
width: 100vw;
|
||||
max-width: 100vw;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
.bracket-canvas {
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { resolveGeneralIconUrl, useDefaultGeneralIcon, type GeneralIconSource } from '../../utils/generalIcon';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
name: string;
|
||||
picture?: GeneralIconSource['picture'];
|
||||
imageServer?: GeneralIconSource['imageServer'];
|
||||
iconSize?: number;
|
||||
hideIcon?: boolean;
|
||||
}>(),
|
||||
{
|
||||
picture: null,
|
||||
imageServer: 0,
|
||||
iconSize: 28,
|
||||
hideIcon: false,
|
||||
}
|
||||
);
|
||||
|
||||
const iconUrl = computed(() =>
|
||||
resolveGeneralIconUrl({
|
||||
picture: props.picture,
|
||||
imageServer: props.imageServer,
|
||||
})
|
||||
);
|
||||
const identityStyle = computed(() => ({ '--general-identity-icon-size': `${props.iconSize}px` }));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="general-identity" :style="identityStyle">
|
||||
<img
|
||||
v-if="!hideIcon && name !== '-'"
|
||||
class="general-identity-icon"
|
||||
:src="iconUrl"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
@error="useDefaultGeneralIcon"
|
||||
/>
|
||||
<span class="general-identity-name">{{ name }}</span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.general-identity {
|
||||
display: inline-flex;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 5px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.general-identity-icon {
|
||||
width: var(--general-identity-icon-size);
|
||||
height: var(--general-identity-icon-size);
|
||||
flex: 0 0 var(--general-identity-icon-size);
|
||||
border: 1px solid rgb(255 255 255 / 28%);
|
||||
background: #111;
|
||||
object-fit: cover;
|
||||
}
|
||||
.general-identity-name {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
@@ -15,7 +15,9 @@ type GeneralProgress = {
|
||||
};
|
||||
};
|
||||
|
||||
const props = defineProps<{ general: GeneralProgress }>();
|
||||
const props = withDefaults(defineProps<{ general: GeneralProgress; showPrimary?: boolean }>(), {
|
||||
showPrimary: true,
|
||||
});
|
||||
|
||||
const statRows = computed(() =>
|
||||
[
|
||||
@@ -50,7 +52,7 @@ const experiencePercent = computed(() =>
|
||||
|
||||
<template>
|
||||
<div class="legacy-general-progress">
|
||||
<div class="stat-grid">
|
||||
<div v-if="props.showPrimary" class="stat-grid">
|
||||
<template v-for="stat of statRows" :key="stat.key">
|
||||
<span class="cell-label">{{ stat.label }}</span>
|
||||
<strong>{{ stat.value }}</strong>
|
||||
@@ -60,7 +62,7 @@ const experiencePercent = computed(() =>
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
<div class="experience-row">
|
||||
<div v-if="props.showPrimary" class="experience-row">
|
||||
<span class="cell-label">Lv</span>
|
||||
<strong>{{ props.general.progression.experienceLevel }}</strong>
|
||||
<LegacyProgressBar
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
export interface TournamentBracketParticipant {
|
||||
id: number;
|
||||
name: string;
|
||||
picture?: string | null;
|
||||
imageServer?: number | null;
|
||||
}
|
||||
|
||||
export interface TournamentBracketMatch {
|
||||
@@ -15,6 +17,8 @@ export interface TournamentBracketMatch {
|
||||
export interface TournamentBracketSlot {
|
||||
id: number | null;
|
||||
name: string;
|
||||
picture: string | null;
|
||||
imageServer: number;
|
||||
advanced: boolean;
|
||||
}
|
||||
|
||||
@@ -31,7 +35,13 @@ export interface TournamentBracketModel {
|
||||
top16: TournamentBracketRound;
|
||||
}
|
||||
|
||||
const emptySlot = (): TournamentBracketSlot => ({ id: null, name: '-', advanced: false });
|
||||
const emptySlot = (): TournamentBracketSlot => ({
|
||||
id: null,
|
||||
name: '-',
|
||||
picture: null,
|
||||
imageServer: 0,
|
||||
advanced: false,
|
||||
});
|
||||
|
||||
export const buildTournamentBracket = (
|
||||
participants: TournamentBracketParticipant[],
|
||||
@@ -39,19 +49,24 @@ export const buildTournamentBracket = (
|
||||
winnerId?: number
|
||||
): TournamentBracketModel => {
|
||||
const participantsById = new Map(participants.map((participant) => [participant.id, participant]));
|
||||
const nameOf = (id: number | null): string =>
|
||||
id === null ? '-' : (participantsById.get(id)?.name ?? `#${id}`);
|
||||
const participantOf = (id: number | null): TournamentBracketParticipant | null =>
|
||||
id === null ? null : (participantsById.get(id) ?? { id, name: `#${id}` });
|
||||
|
||||
const buildRound = (stage: number, slotCount: number): TournamentBracketRound => {
|
||||
const roundMatches = matches
|
||||
.filter((match) => match.stage === stage)
|
||||
.sort((lhs, rhs) => lhs.roundIndex - rhs.roundIndex || lhs.id - rhs.id);
|
||||
const slots: TournamentBracketSlot[] = roundMatches.flatMap((match) =>
|
||||
[match.attackerId, match.defenderId].map((id) => ({
|
||||
id,
|
||||
name: nameOf(id),
|
||||
advanced: match.winnerId === id,
|
||||
}))
|
||||
[match.attackerId, match.defenderId].map((id) => {
|
||||
const participant = participantOf(id);
|
||||
return {
|
||||
id,
|
||||
name: participant?.name ?? '-',
|
||||
picture: participant?.picture ?? null,
|
||||
imageServer: participant?.imageServer ?? 0,
|
||||
advanced: match.winnerId === id,
|
||||
};
|
||||
})
|
||||
);
|
||||
while (slots.length < slotCount) {
|
||||
slots.push(emptySlot());
|
||||
@@ -65,7 +80,9 @@ export const buildTournamentBracket = (
|
||||
return {
|
||||
champion: {
|
||||
id: resolvedWinnerId,
|
||||
name: nameOf(resolvedWinnerId),
|
||||
name: participantOf(resolvedWinnerId)?.name ?? '-',
|
||||
picture: participantOf(resolvedWinnerId)?.picture ?? null,
|
||||
imageServer: participantOf(resolvedWinnerId)?.imageServer ?? 0,
|
||||
advanced: resolvedWinnerId !== null,
|
||||
},
|
||||
final,
|
||||
|
||||
@@ -5,10 +5,10 @@ import { useRoute } from 'vue-router';
|
||||
import PanelCard from '../components/ui/PanelCard.vue';
|
||||
import SkeletonLines from '../components/ui/SkeletonLines.vue';
|
||||
import LegacyGeneralProgress from '../components/ui/LegacyGeneralProgress.vue';
|
||||
import GeneralBasicCard from '../components/main/GeneralBasicCard.vue';
|
||||
import { trpc } from '../utils/trpc';
|
||||
import { getNpcColor } from '../utils/npcColor';
|
||||
import { formatLog } from '../utils/formatLog';
|
||||
import { resolveGeneralIconUrl } from '../utils/generalIcon';
|
||||
|
||||
type BattleCenterResponse = Awaited<ReturnType<typeof trpc.nation.getBattleCenter.query>>;
|
||||
type GeneralEntry = BattleCenterResponse['generals'][number];
|
||||
@@ -137,8 +137,6 @@ const formatGeneralLabel = (general: GeneralEntry): string => {
|
||||
return `${name} (${time})`;
|
||||
};
|
||||
|
||||
const generalImageUrl = (general: GeneralEntry): string => resolveGeneralIconUrl(general);
|
||||
|
||||
let logRequestId = 0;
|
||||
|
||||
const loadLogs = async (generalId: number) => {
|
||||
@@ -270,47 +268,29 @@ onMounted(() => {
|
||||
</PanelCard>
|
||||
|
||||
<PanelCard title="장수 정보">
|
||||
<SkeletonLines v-if="loading" :lines="5" />
|
||||
<div v-else-if="selectedGeneral" class="battle-general-card">
|
||||
<div class="battle-general-name">
|
||||
{{ selectedGeneral.name }} ({{ selectedGeneral.officerLevelText }})
|
||||
</div>
|
||||
<span
|
||||
class="battle-general-portrait"
|
||||
role="img"
|
||||
:aria-label="`${selectedGeneral.name} 초상`"
|
||||
:style="{ backgroundImage: `url(${generalImageUrl(selectedGeneral)})` }"
|
||||
/>
|
||||
<div class="battle-general-grid">
|
||||
<span>통솔</span><strong>{{ selectedGeneral.stats.leadership }}</strong> <span>무력</span
|
||||
><strong>{{ selectedGeneral.stats.strength }}</strong> <span>지력</span
|
||||
><strong>{{ selectedGeneral.stats.intelligence }}</strong> <span>자금</span
|
||||
><strong>{{ selectedGeneral.gold }}</strong> <span>군량</span
|
||||
><strong>{{ selectedGeneral.rice }}</strong> <span>병력</span
|
||||
><strong>{{ selectedGeneral.crew }}</strong> <span>훈련</span
|
||||
><strong>{{ selectedGeneral.train }}</strong> <span>사기</span
|
||||
><strong>{{ selectedGeneral.atmos }}</strong> <span>부상</span
|
||||
><strong>{{ selectedGeneral.injury }}</strong> <span>경험</span
|
||||
><strong>{{ selectedGeneral.experience }}</strong> <span>공헌</span
|
||||
><strong>{{ selectedGeneral.dedication }}</strong> <span>전투</span
|
||||
><strong>{{ selectedGeneral.warnum }}회</strong>
|
||||
</div>
|
||||
<div class="battle-general-extra">
|
||||
<span>명성</span><strong>{{ selectedGeneral.experience.toLocaleString('ko-KR') }}</strong>
|
||||
<span>계급</span><strong>{{ selectedGeneral.progression.dedicationText }}</strong>
|
||||
<span>나이</span><strong>{{ selectedGeneral.age }}세</strong> <span>병종</span
|
||||
><strong>{{ selectedGeneral.crewTypeName }}</strong> <span>승리</span
|
||||
><strong>{{ selectedGeneral.battleStats.kills }}</strong> <span>패배</span
|
||||
><strong>{{ selectedGeneral.battleStats.deaths }}</strong> <span>사살</span
|
||||
><strong>{{ selectedGeneral.battleStats.killCrew.toLocaleString('ko-KR') }}</strong>
|
||||
<span>피살</span
|
||||
><strong>{{ selectedGeneral.battleStats.deathCrew.toLocaleString('ko-KR') }}</strong>
|
||||
<span>전투 특기</span><strong>{{ selectedGeneral.traits.specialWar }}</strong>
|
||||
<span>내정 특기</span><strong>{{ selectedGeneral.traits.specialDomestic }}</strong>
|
||||
<span>성격</span><strong>{{ selectedGeneral.traits.personal }}</strong>
|
||||
</div>
|
||||
<LegacyGeneralProgress :general="selectedGeneral" />
|
||||
</div>
|
||||
<GeneralBasicCard
|
||||
class="battle-general-card"
|
||||
:general="selectedGeneral"
|
||||
:loading="loading"
|
||||
:nation-color="data?.nation.color"
|
||||
>
|
||||
<template v-if="selectedGeneral" #details>
|
||||
<div class="battle-general-extra">
|
||||
<span>명성</span
|
||||
><strong>{{ selectedGeneral.experience.toLocaleString('ko-KR') }}</strong>
|
||||
<span>계급</span><strong>{{ selectedGeneral.progression.dedicationText }}</strong>
|
||||
<span>전투</span><strong>{{ selectedGeneral.warnum }}회</strong> <span>승리</span
|
||||
><strong>{{ selectedGeneral.battleStats.kills }}</strong> <span>패배</span
|
||||
><strong>{{ selectedGeneral.battleStats.deaths }}</strong> <span>계략</span
|
||||
><strong>{{ selectedGeneral.battleStats.fire }}</strong> <span>사살</span
|
||||
><strong>{{ selectedGeneral.battleStats.killCrew.toLocaleString('ko-KR') }}</strong>
|
||||
<span>피살</span
|
||||
><strong>{{ selectedGeneral.battleStats.deathCrew.toLocaleString('ko-KR') }}</strong>
|
||||
<span>최근 전투</span><strong>{{ selectedGeneral.recentWar || '-' }}</strong>
|
||||
</div>
|
||||
<LegacyGeneralProgress :general="selectedGeneral" :show-primary="false" />
|
||||
</template>
|
||||
</GeneralBasicCard>
|
||||
<div v-if="selectedGeneral" class="general-meta">
|
||||
<div>
|
||||
최근 턴:
|
||||
@@ -384,39 +364,7 @@ onMounted(() => {
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.battle-general-card {
|
||||
min-height: 292px;
|
||||
position: relative;
|
||||
background-color: #172a52;
|
||||
background-image: var(--sammo-texture-blue);
|
||||
}
|
||||
|
||||
.battle-general-portrait {
|
||||
display: block;
|
||||
width: 64px;
|
||||
height: 80px;
|
||||
float: left;
|
||||
background-position: center;
|
||||
background-size: cover;
|
||||
}
|
||||
|
||||
.battle-general-name {
|
||||
min-height: 24px;
|
||||
padding: 2px 6px;
|
||||
text-align: center;
|
||||
border-bottom: 1px solid #777;
|
||||
background: rgba(220, 220, 220, 0.85);
|
||||
color: #111;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.battle-general-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, 1fr);
|
||||
}
|
||||
|
||||
.battle-general-extra {
|
||||
clear: both;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, 1fr);
|
||||
}
|
||||
@@ -442,24 +390,6 @@ onMounted(() => {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.battle-general-grid > * {
|
||||
min-height: 24px;
|
||||
padding: 2px 5px;
|
||||
border-right: 1px solid #777;
|
||||
border-bottom: 1px solid #777;
|
||||
}
|
||||
|
||||
.battle-general-grid > span {
|
||||
background-color: rgba(20, 75, 42, 0.7);
|
||||
color: #fff;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.battle-general-grid > strong {
|
||||
text-align: right;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.log-grid {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { formatServerDateTime } from '@sammo-ts/common';
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import TournamentBracket from '../components/tournament/TournamentBracket.vue';
|
||||
import GeneralIdentity from '../components/ui/GeneralIdentity.vue';
|
||||
import { trpc } from '../utils/trpc';
|
||||
|
||||
type Snapshot = Awaited<ReturnType<typeof trpc.tournament.getSnapshot.query>>;
|
||||
@@ -13,6 +14,7 @@ const loading = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
const message = ref<string | null>(null);
|
||||
const amounts = ref<Record<number, number>>({});
|
||||
const activeRankingPrefix = ref('tt');
|
||||
const typeNames = ['전력전', '통솔전', '일기토', '설전'];
|
||||
const stageNames = [
|
||||
'경기 없음',
|
||||
@@ -58,7 +60,13 @@ const final16Ids = computed(() =>
|
||||
const candidates = computed(() =>
|
||||
Array.from({ length: 16 }, (_, index) => {
|
||||
const id = final16Ids.value[index] ?? 0;
|
||||
return { id, name: id ? (participantMap.value.get(id)?.name ?? `#${id}`) : '-' };
|
||||
const participant = id ? participantMap.value.get(id) : null;
|
||||
return {
|
||||
id,
|
||||
name: id ? (participant?.name ?? `#${id}`) : '-',
|
||||
picture: participant?.picture ?? null,
|
||||
imageServer: participant?.imageServer ?? 0,
|
||||
};
|
||||
})
|
||||
);
|
||||
const totalAmount = computed(() => summary.value?.totalAmount ?? 0);
|
||||
@@ -132,58 +140,43 @@ const placeBet = async (targetId: number) => {
|
||||
:bet-totals="betTotals"
|
||||
:total-bet="totalAmount"
|
||||
:show-legend="false"
|
||||
force-desktop
|
||||
/>
|
||||
|
||||
<section class="candidate-table bg0">
|
||||
<div class="candidate-row names">
|
||||
<span v-for="candidate in candidates" :key="candidate.id || candidate.name">{{ candidate.name }}</span>
|
||||
<div class="candidate-grid">
|
||||
<article v-for="candidate in candidates" :key="candidate.id || candidate.name" class="candidate-card">
|
||||
<GeneralIdentity
|
||||
:name="candidate.name"
|
||||
:picture="candidate.picture"
|
||||
:image-server="candidate.imageServer"
|
||||
:icon-size="36"
|
||||
/>
|
||||
<div class="candidate-return">
|
||||
<span class="ratio-color">{{ ratio(candidate.id) }}</span>
|
||||
<span aria-hidden="true">×</span>
|
||||
<span class="gold-color">{{ amounts[candidate.id] ?? 10 }}</span>
|
||||
<span aria-hidden="true">=</span>
|
||||
<strong class="return-color">{{ expected(candidate.id) }}</strong>
|
||||
</div>
|
||||
<div v-if="bettingOpen" class="candidate-actions">
|
||||
<select
|
||||
v-model.number="amounts[candidate.id]"
|
||||
:aria-label="`${candidate.name} 베팅 금액`"
|
||||
:disabled="!candidate.id"
|
||||
>
|
||||
<option :value="10">금10</option>
|
||||
<option :value="20">금20</option>
|
||||
<option :value="50">금50</option>
|
||||
<option :value="100">금100</option>
|
||||
<option :value="200">금200</option>
|
||||
<option :value="500">금500</option>
|
||||
<option :value="1000">최대</option>
|
||||
</select>
|
||||
<button type="button" :disabled="!candidate.id" @click="placeBet(candidate.id)">베팅</button>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
<div class="candidate-row ratios">
|
||||
<span v-for="candidate in candidates" :key="candidate.id || candidate.name">{{
|
||||
ratio(candidate.id)
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="candidate-row multiply">
|
||||
<span v-for="candidate in candidates" :key="candidate.id || candidate.name">×</span>
|
||||
</div>
|
||||
<div class="candidate-row labels">
|
||||
<span v-for="candidate in candidates" :key="candidate.id || candidate.name">∥</span>
|
||||
</div>
|
||||
<div class="candidate-row expected">
|
||||
<span v-for="candidate in candidates" :key="candidate.id || candidate.name">{{
|
||||
expected(candidate.id)
|
||||
}}</span>
|
||||
</div>
|
||||
<div v-if="bettingOpen" class="candidate-row selects">
|
||||
<select
|
||||
v-for="candidate in candidates"
|
||||
:key="candidate.id || candidate.name"
|
||||
v-model.number="amounts[candidate.id]"
|
||||
:aria-label="`${candidate.name} 베팅 금액`"
|
||||
:disabled="!candidate.id"
|
||||
>
|
||||
<option :value="10">금10</option>
|
||||
<option :value="20">금20</option>
|
||||
<option :value="50">금50</option>
|
||||
<option :value="100">금100</option>
|
||||
<option :value="200">금200</option>
|
||||
<option :value="500">금500</option>
|
||||
<option :value="1000">최대</option>
|
||||
</select>
|
||||
</div>
|
||||
<div v-if="bettingOpen" class="candidate-row buttons">
|
||||
<button
|
||||
v-for="candidate in candidates"
|
||||
:key="candidate.id || candidate.name"
|
||||
type="button"
|
||||
:disabled="!candidate.id"
|
||||
@click="placeBet(candidate.id)"
|
||||
>
|
||||
베팅!
|
||||
</button>
|
||||
</div>
|
||||
<p>
|
||||
<p class="candidate-help">
|
||||
<span class="ratio-color">배당률</span> × <span class="gold-color">베팅금</span> =
|
||||
<span class="return-color">적중시 환수금</span><br />
|
||||
<span class="ratio-color">( 베팅후 500원 이하일땐 베팅이 불가능합니다. )</span>
|
||||
@@ -204,8 +197,26 @@ const placeBet = async (targetId: number) => {
|
||||
<section class="ranking-placeholder bg0">
|
||||
순위 / 장수명 / 능력치 / 경기수 / 승리 / 무승부 / 패배 / 집계점수 / 우승횟수
|
||||
</section>
|
||||
<div class="ranking-tabs bg0" role="tablist" aria-label="토너먼트 랭킹 종목 선택">
|
||||
<button
|
||||
v-for="section in rankings"
|
||||
:key="`ranking-tab-${section.prefix}`"
|
||||
type="button"
|
||||
role="tab"
|
||||
:aria-selected="activeRankingPrefix === section.prefix"
|
||||
:class="{ active: activeRankingPrefix === section.prefix }"
|
||||
@click="activeRankingPrefix = section.prefix"
|
||||
>
|
||||
{{ section.title.replaceAll(' ', '') }}
|
||||
</button>
|
||||
</div>
|
||||
<section class="ranking-grid bg0">
|
||||
<table v-for="section in rankings" :key="section.prefix" class="ranking-table">
|
||||
<table
|
||||
v-for="section in rankings"
|
||||
:key="section.prefix"
|
||||
class="ranking-table"
|
||||
:class="{ 'mobile-active': activeRankingPrefix === section.prefix }"
|
||||
>
|
||||
<thead>
|
||||
<tr>
|
||||
<th colspan="9">{{ section.title }}</th>
|
||||
@@ -225,7 +236,14 @@ const placeBet = async (targetId: number) => {
|
||||
<tbody>
|
||||
<tr v-for="entry in section.entries" :key="entry.generalId">
|
||||
<td>{{ entry.rank }}</td>
|
||||
<td>{{ entry.name }}</td>
|
||||
<td class="ranking-general">
|
||||
<GeneralIdentity
|
||||
:name="entry.name"
|
||||
:picture="entry.picture"
|
||||
:image-server="entry.imageServer"
|
||||
:icon-size="24"
|
||||
/>
|
||||
</td>
|
||||
<td>{{ entry.stat }}</td>
|
||||
<td>{{ entry.games }}</td>
|
||||
<td>{{ entry.win }}</td>
|
||||
@@ -259,9 +277,10 @@ const placeBet = async (targetId: number) => {
|
||||
|
||||
<style scoped>
|
||||
.betting-page {
|
||||
width: 1125px;
|
||||
height: 1346px;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
max-width: 1200px;
|
||||
min-width: 0;
|
||||
min-height: 100vh;
|
||||
margin: 0 auto;
|
||||
color: #fff;
|
||||
font-family: var(--sammo-font-sans);
|
||||
@@ -270,8 +289,8 @@ const placeBet = async (targetId: number) => {
|
||||
text-align: center;
|
||||
}
|
||||
.betting-bracket :deep(.bracket-canvas) {
|
||||
width: 1125px;
|
||||
min-width: 1125px;
|
||||
width: 100%;
|
||||
min-width: 1000px;
|
||||
}
|
||||
.betting-bracket :deep(.bracket-round),
|
||||
.betting-bracket :deep(.connector-row) {
|
||||
@@ -353,20 +372,34 @@ const placeBet = async (targetId: number) => {
|
||||
}
|
||||
.candidate-table {
|
||||
border: 1px solid gray;
|
||||
padding: 10px 0;
|
||||
font-size: 10px;
|
||||
padding: 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.candidate-row {
|
||||
.candidate-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(16, 70px);
|
||||
align-items: center;
|
||||
min-height: 10px;
|
||||
line-height: 10px;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
.names {
|
||||
min-height: 14px;
|
||||
.candidate-card {
|
||||
min-width: 0;
|
||||
padding: 8px;
|
||||
border: 1px solid #5b504b;
|
||||
background: rgb(0 0 0 / 26%);
|
||||
text-align: left;
|
||||
}
|
||||
.candidate-return {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 1fr auto 1fr;
|
||||
gap: 4px;
|
||||
margin: 8px 0;
|
||||
text-align: center;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.candidate-actions {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 64px;
|
||||
gap: 6px;
|
||||
}
|
||||
.ratios,
|
||||
.ratio-color {
|
||||
color: skyblue;
|
||||
}
|
||||
@@ -378,7 +411,7 @@ const placeBet = async (targetId: number) => {
|
||||
color: orange;
|
||||
}
|
||||
select,
|
||||
.buttons button {
|
||||
.candidate-actions button {
|
||||
width: 100%;
|
||||
min-height: 27px;
|
||||
padding: 2px 1px;
|
||||
@@ -412,7 +445,7 @@ select:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
.candidate-table p {
|
||||
.candidate-help {
|
||||
min-height: 20px;
|
||||
margin: 8px 0 0;
|
||||
font-size: 18px;
|
||||
@@ -431,11 +464,13 @@ select:disabled {
|
||||
}
|
||||
.ranking-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 280px);
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
align-items: start;
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
}
|
||||
.ranking-table {
|
||||
width: 280px;
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-size: 12px;
|
||||
@@ -443,7 +478,7 @@ select:disabled {
|
||||
}
|
||||
.ranking-table th,
|
||||
.ranking-table td {
|
||||
height: 14px;
|
||||
height: 28px;
|
||||
padding: 1px;
|
||||
border: 1px solid #555;
|
||||
}
|
||||
@@ -457,12 +492,20 @@ select:disabled {
|
||||
.ranking-table .bg1 {
|
||||
background: #213b52;
|
||||
}
|
||||
.ranking-table th:nth-child(2),
|
||||
.ranking-table td:nth-child(2) {
|
||||
max-width: 80px;
|
||||
width: 130px;
|
||||
max-width: 130px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ranking-general {
|
||||
text-align: left;
|
||||
}
|
||||
.ranking-tabs {
|
||||
display: none;
|
||||
}
|
||||
.guide {
|
||||
padding: 10px;
|
||||
text-align: left;
|
||||
@@ -470,4 +513,83 @@ select:disabled {
|
||||
.error {
|
||||
color: #ff8080;
|
||||
}
|
||||
@media (max-width: 800px) {
|
||||
.betting-page {
|
||||
max-width: 100%;
|
||||
font-size: 13px;
|
||||
}
|
||||
.title {
|
||||
height: auto;
|
||||
min-height: 55px;
|
||||
}
|
||||
.state {
|
||||
font-size: 18px;
|
||||
}
|
||||
.section-title,
|
||||
.ranking-title {
|
||||
font-size: 20px;
|
||||
}
|
||||
.candidate-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.candidate-card {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 112px;
|
||||
align-items: center;
|
||||
gap: 8px 12px;
|
||||
}
|
||||
.candidate-return {
|
||||
margin: 0;
|
||||
}
|
||||
.candidate-actions {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
.candidate-help {
|
||||
font-size: 14px;
|
||||
line-height: 18px;
|
||||
}
|
||||
.ranking-placeholder {
|
||||
display: none;
|
||||
}
|
||||
.ranking-tabs {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 5px;
|
||||
padding: 8px;
|
||||
}
|
||||
.ranking-tabs button {
|
||||
height: 36px;
|
||||
margin: 0;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.ranking-tabs button.active {
|
||||
border-color: #f39c12;
|
||||
background: #8a5b13;
|
||||
}
|
||||
.ranking-grid {
|
||||
display: block;
|
||||
overflow-x: auto;
|
||||
padding: 0;
|
||||
}
|
||||
.ranking-table {
|
||||
display: none;
|
||||
min-width: 390px;
|
||||
font-size: 11px;
|
||||
}
|
||||
.ranking-table.mobile-active {
|
||||
display: table;
|
||||
}
|
||||
.ranking-table th:nth-child(2),
|
||||
.ranking-table td:nth-child(2) {
|
||||
width: 112px;
|
||||
max-width: 112px;
|
||||
}
|
||||
.guide,
|
||||
.betting-footer {
|
||||
padding: 10px;
|
||||
}
|
||||
.betting-footer small {
|
||||
white-space: normal;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -232,7 +232,12 @@ watch(
|
||||
<NationBasicCard :nation="nation" :loading="loading" />
|
||||
</PanelCard>
|
||||
<PanelCard title="장수 스탯" data-main-target="general">
|
||||
<GeneralBasicCard :general="general" :loading="loading" />
|
||||
<GeneralBasicCard
|
||||
:general="general"
|
||||
:loading="loading"
|
||||
:nation-color="nation?.color"
|
||||
:troop-text="general?.troopId ? String(general.troopId) : '-'"
|
||||
/>
|
||||
</PanelCard>
|
||||
<PanelCard title="도시 정보" data-main-target="city">
|
||||
<CityBasicCard :city="city" :loading="loading" />
|
||||
@@ -347,7 +352,12 @@ watch(
|
||||
<NationBasicCard :nation="nation" :loading="loading" />
|
||||
</PanelCard>
|
||||
<PanelCard title="장수 스탯" data-main-target="general">
|
||||
<GeneralBasicCard :general="general" :loading="loading" />
|
||||
<GeneralBasicCard
|
||||
:general="general"
|
||||
:loading="loading"
|
||||
:nation-color="nation?.color"
|
||||
:troop-text="general?.troopId ? String(general.troopId) : '-'"
|
||||
/>
|
||||
</PanelCard>
|
||||
<MainNationMenu
|
||||
class="nation-menu-middle"
|
||||
@@ -596,12 +606,14 @@ button {
|
||||
.layout-desktop > [data-main-target='nation'] {
|
||||
grid-column: 1 / 6;
|
||||
grid-row: 3;
|
||||
align-self: stretch;
|
||||
min-height: 193px;
|
||||
}
|
||||
|
||||
.layout-desktop > [data-main-target='general'] {
|
||||
grid-column: 6 / 11;
|
||||
grid-row: 3;
|
||||
align-self: stretch;
|
||||
min-height: 193px;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,11 +2,12 @@
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue';
|
||||
import { trpc } from '../utils/trpc';
|
||||
import { formatLog } from '../utils/formatLog';
|
||||
import { formatSeoulDateTime, formatSeoulHourMinute } from '../utils/legacyDateTime';
|
||||
import { formatSeoulDateTime } from '../utils/legacyDateTime';
|
||||
import { isDefenceTrainPenaltyWaivedByScenarioEffect } from '@sammo-ts/logic';
|
||||
import { useSessionStore } from '../stores/session';
|
||||
import { resolveGeneralIconUrl, useDefaultGeneralIcon } from '../utils/generalIcon';
|
||||
import LegacyGeneralProgress from '../components/ui/LegacyGeneralProgress.vue';
|
||||
import GeneralBasicCard from '../components/main/GeneralBasicCard.vue';
|
||||
import { useGameFeedback } from '../composables/useGameFeedback';
|
||||
|
||||
const SCREEN_MODE_KEY = 'sam.screenMode';
|
||||
@@ -167,6 +168,7 @@ const items = computed<Array<{ key: ItemSlotKey; slotName: string; displayName:
|
||||
]
|
||||
);
|
||||
const iconChoices = computed(() => data.value?.iconChoices ?? []);
|
||||
const selectedIcon = computed(() => iconChoices.value.find((icon) => icon.id === selectedIconId.value) ?? null);
|
||||
|
||||
const autorunUser = computed(() => asRecord(world.value?.meta.autorun_user));
|
||||
const showAutoNationTurn = computed(() => asRecord(autorunUser.value.options).chief !== false);
|
||||
@@ -360,7 +362,7 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main id="container" class="legacy-page bg0" :class="`screen-${screenMode}`">
|
||||
<main id="container" class="legacy-page bg0 responsive-settings-page" :class="`screen-${screenMode}`">
|
||||
<div class="title-row">
|
||||
<span>내 정 보</span>
|
||||
<RouterLink class="legacy-button" to="/past-plays">지난 플레이</RouterLink>
|
||||
@@ -374,94 +376,43 @@ onMounted(() => {
|
||||
<section class="top-grid">
|
||||
<div class="general-column">
|
||||
<div class="section-title sky">장수 정보</div>
|
||||
<div v-if="loading || !data" class="loading">불러오는 중...</div>
|
||||
<div v-else class="general-table">
|
||||
<div class="portrait-cell">
|
||||
<span
|
||||
class="portrait-image"
|
||||
role="img"
|
||||
:style="{ backgroundImage: `url(${resolveGeneralIconUrl(data.general)})` }"
|
||||
></span>
|
||||
<strong>{{ data.general.name }}</strong>
|
||||
</div>
|
||||
<dl>
|
||||
<div>
|
||||
<dt>통솔</dt>
|
||||
<dd>{{ data.general.stats.leadership }}</dd>
|
||||
<GeneralBasicCard
|
||||
class="general-table"
|
||||
:general="data?.general ?? null"
|
||||
:loading="loading"
|
||||
:nation-color="data?.nation?.color"
|
||||
:defence-text="form.defence_train === 999 ? '수비 안함' : `수비 함(훈사${form.defence_train})`"
|
||||
:troop-text="data?.general.troopId ? String(data.general.troopId) : '-'"
|
||||
:penalty-text="penalties.length || '-'"
|
||||
>
|
||||
<template v-if="data" #details>
|
||||
<div class="legacy-general-details">
|
||||
<div>
|
||||
명망
|
||||
<strong
|
||||
>Lv {{ data.general.progression?.experienceLevel ?? 0 }} ({{
|
||||
data.general.experience
|
||||
}})</strong
|
||||
>
|
||||
· 계급
|
||||
<strong
|
||||
>{{ data.general.progression?.dedicationText ?? '무품관' }} ({{
|
||||
data.general.dedication
|
||||
}})</strong
|
||||
>
|
||||
</div>
|
||||
<div>전투 0 · 계략 0 · 사관 7년</div>
|
||||
<div>승률 0% · 승리 0 · 패배 0</div>
|
||||
<div>살상률 0% · 사살 0 · 피살 0</div>
|
||||
<div>
|
||||
소속 {{ data.nation?.name ?? '재야' }} · 도시 {{ data.city?.name ?? '-' }} · 병종
|
||||
{{ data.general.crewTypeName ?? '-' }} · 내정특기
|
||||
{{ data.general.traits?.specialDomestic ?? '-' }} · 부상 {{ data.general.injury }}
|
||||
</div>
|
||||
<LegacyGeneralProgress :general="data.general" :show-primary="false" />
|
||||
</div>
|
||||
<div>
|
||||
<dt>무력</dt>
|
||||
<dd>{{ data.general.stats.strength }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>지력</dt>
|
||||
<dd>{{ data.general.stats.intelligence }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>소속</dt>
|
||||
<dd>{{ data.nation?.name ?? '재야' }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>도시</dt>
|
||||
<dd>{{ data.city?.name ?? '-' }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>금/쌀</dt>
|
||||
<dd>{{ data.general.gold }} / {{ data.general.rice }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>병력</dt>
|
||||
<dd>{{ data.general.crew }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>훈련/사기</dt>
|
||||
<dd>{{ data.general.train }} / {{ data.general.atmos }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>경험/공헌</dt>
|
||||
<dd>{{ data.general.experience }} / {{ data.general.dedication }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>성격/특기</dt>
|
||||
<dd>
|
||||
{{ data.general.traits?.personal ?? '-' }} /
|
||||
{{ data.general.traits?.specialWar ?? '-' }}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>나이/다음턴</dt>
|
||||
<dd>
|
||||
{{ data.general.age ?? '-' }}세 /
|
||||
{{ data.general.turnTime ? formatSeoulHourMinute(data.general.turnTime) : '-' }}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
<div v-if="data" class="legacy-general-details">
|
||||
<div>
|
||||
명망
|
||||
<strong
|
||||
>Lv {{ data.general.progression?.experienceLevel ?? 0 }} ({{
|
||||
data.general.experience
|
||||
}})</strong
|
||||
>
|
||||
· 계급
|
||||
<strong
|
||||
>{{ data.general.progression?.dedicationText ?? '무품관' }} ({{
|
||||
data.general.dedication
|
||||
}})</strong
|
||||
>
|
||||
</div>
|
||||
<div>전투 0 · 계략 0 · 사관 7년</div>
|
||||
<div>승률 0% · 승리 0 · 패배 0</div>
|
||||
<div>살상률 0% · 사살 0 · 피살 0</div>
|
||||
<div>
|
||||
병종 {{ data.general.crewTypeName ?? '-' }} · 내정특기
|
||||
{{ data.general.traits?.specialDomestic ?? '-' }} · 부상 {{ data.general.injury }} · 부대
|
||||
{{ data.general.troopId || '-' }} · 벌점 {{ penalties.length || '-' }}
|
||||
</div>
|
||||
<LegacyGeneralProgress :general="data.general" />
|
||||
</div>
|
||||
</template>
|
||||
</GeneralBasicCard>
|
||||
</div>
|
||||
|
||||
<div class="settings-column">
|
||||
@@ -544,6 +495,16 @@ onMounted(() => {
|
||||
<span v-if="data.iconChangeAvailableAt" class="hint">
|
||||
다음 변경 가능: {{ formatSeoulDateTime(data.iconChangeAvailableAt) }}
|
||||
</span>
|
||||
<div v-if="selectedIcon" class="selected-general-icon" aria-live="polite">
|
||||
<img
|
||||
:src="resolveGeneralIconUrl(selectedIcon)"
|
||||
width="48"
|
||||
height="48"
|
||||
alt=""
|
||||
@error="useDefaultGeneralIcon"
|
||||
/>
|
||||
<strong>{{ data.general.name }}</strong>
|
||||
</div>
|
||||
<div class="general-icon-list" role="radiogroup" aria-label="장수 전용 아이콘 선택">
|
||||
<label v-for="icon in iconChoices" :key="icon.id" class="general-icon-choice">
|
||||
<input v-model="selectedIconId" type="radio" :value="icon.id" />
|
||||
@@ -678,8 +639,7 @@ onMounted(() => {
|
||||
.legacy-page {
|
||||
width: 100%;
|
||||
max-width: 1000px;
|
||||
min-width: 500px;
|
||||
height: 1257.5px;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
margin: 0 auto;
|
||||
padding: 0;
|
||||
@@ -789,28 +749,6 @@ button:disabled {
|
||||
.sky {
|
||||
color: skyblue;
|
||||
}
|
||||
.general-table {
|
||||
display: grid;
|
||||
grid-template-columns: 150px 1fr;
|
||||
padding: 0;
|
||||
background-color: #172a52;
|
||||
background-image: var(--sammo-texture-blue);
|
||||
}
|
||||
.portrait-cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px;
|
||||
border-right: 1px solid #777;
|
||||
}
|
||||
.portrait-image {
|
||||
display: block;
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
background-position: center;
|
||||
background-size: cover;
|
||||
}
|
||||
.legacy-general-info-compat {
|
||||
display: none;
|
||||
}
|
||||
@@ -827,23 +765,6 @@ button:disabled {
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
}
|
||||
dl {
|
||||
margin: 0;
|
||||
}
|
||||
dl > div {
|
||||
display: grid;
|
||||
grid-template-columns: 80px 1fr;
|
||||
border-bottom: 1px solid #777;
|
||||
}
|
||||
dt,
|
||||
dd {
|
||||
margin: 0;
|
||||
padding: 2px 5px;
|
||||
border-right: 1px solid #777;
|
||||
}
|
||||
dt {
|
||||
color: #aaa;
|
||||
}
|
||||
.settings-column {
|
||||
padding: 10px 18px;
|
||||
}
|
||||
@@ -945,6 +866,21 @@ dt {
|
||||
gap: 6px;
|
||||
margin: 6px 0;
|
||||
}
|
||||
.selected-general-icon {
|
||||
display: flex;
|
||||
max-width: 260px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
margin: 8px auto;
|
||||
padding: 6px 10px;
|
||||
border: 1px solid #666;
|
||||
background: rgb(23 42 82 / 70%);
|
||||
}
|
||||
.selected-general-icon img {
|
||||
flex: 0 0 48px;
|
||||
object-fit: cover;
|
||||
}
|
||||
.general-icon-choice {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -952,16 +888,44 @@ dt {
|
||||
}
|
||||
@media (max-width: 991px) {
|
||||
.legacy-page {
|
||||
width: 500px;
|
||||
height: 1798.34px;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
.my-page-mobile-scroll-spacer {
|
||||
display: block;
|
||||
height: 100px;
|
||||
display: none;
|
||||
}
|
||||
.top-grid,
|
||||
.log-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
.title-row {
|
||||
height: auto;
|
||||
min-height: 54px;
|
||||
}
|
||||
dl > div {
|
||||
grid-template-columns: 62px minmax(0, 1fr);
|
||||
}
|
||||
dt,
|
||||
dd {
|
||||
padding: 2px 3px;
|
||||
}
|
||||
.settings-column {
|
||||
padding: 10px 12px;
|
||||
}
|
||||
.screen-mode-row {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 6px;
|
||||
}
|
||||
.button-group {
|
||||
overflow-x: auto;
|
||||
}
|
||||
.item-group {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
.custom-css textarea {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { formatServerDateTime } from '@sammo-ts/common';
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import TournamentBracket from '../components/tournament/TournamentBracket.vue';
|
||||
import GeneralIdentity from '../components/ui/GeneralIdentity.vue';
|
||||
import { trpc } from '../utils/trpc';
|
||||
import { resolveTournamentStageName } from '../utils/tournamentStatus';
|
||||
|
||||
@@ -14,8 +15,11 @@ const loading = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
const actionMessage = ref<string | null>(null);
|
||||
const adminEnabled = ref(false);
|
||||
const activeFinalGroup = ref(0);
|
||||
const activePreliminaryGroup = ref(0);
|
||||
|
||||
const typeNames = ['전력전', '통솔전', '일기토', '설전'];
|
||||
const typeStatNames = ['종합', '통솔', '무력', '지력'];
|
||||
const errorText = (value: unknown) => (value instanceof Error ? value.message : String(value));
|
||||
|
||||
const load = async () => {
|
||||
@@ -64,6 +68,26 @@ const groups = computed(() =>
|
||||
.sort((a, b) => (a.finalRank ?? 99) - (b.finalRank ?? 99) || (a.groupNo ?? 99) - (b.groupNo ?? 99))
|
||||
)
|
||||
);
|
||||
const preliminaryGroups = computed(() =>
|
||||
Array.from({ length: 8 }, (_, index) =>
|
||||
(snapshot.value?.participants ?? [])
|
||||
.filter((participant) => participant.groupId === index)
|
||||
.sort((a, b) => (a.seedRank ?? 99) - (b.seedRank ?? 99) || (a.groupNo ?? 99) - (b.groupNo ?? 99))
|
||||
)
|
||||
);
|
||||
const groupNames = ['一', '二', '三', '四', '五', '六', '七', '八'];
|
||||
const statOf = (participant: Snapshot['participants'][number] | undefined): number | '' => {
|
||||
if (!participant) return '';
|
||||
const type = snapshot.value?.state?.type ?? 0;
|
||||
if (type === 0) return participant.leadership + participant.strength + participant.intel;
|
||||
if (type === 1) return participant.leadership;
|
||||
if (type === 2) return participant.strength;
|
||||
return participant.intel;
|
||||
};
|
||||
const gamesOf = (participant: Snapshot['participants'][number] | undefined): number | '' =>
|
||||
participant ? (participant.win ?? 0) + (participant.draw ?? 0) + (participant.lose ?? 0) : '';
|
||||
const pointsOf = (participant: Snapshot['participants'][number] | undefined): number | '' =>
|
||||
participant ? (participant.win ?? 0) * 3 + (participant.draw ?? 0) : '';
|
||||
const currentMatch = computed(() => {
|
||||
const state = snapshot.value?.state;
|
||||
if (!state || state.stage < 7 || state.stage > 10) return null;
|
||||
@@ -154,7 +178,6 @@ const start = async () => {
|
||||
:winner-id="snapshot?.state?.winnerId"
|
||||
:bet-totals="betTotals"
|
||||
:total-bet="totalBet"
|
||||
force-desktop
|
||||
/>
|
||||
|
||||
<section v-if="currentMatch" class="fight bg0">
|
||||
@@ -163,18 +186,35 @@ const start = async () => {
|
||||
</section>
|
||||
|
||||
<section class="section-title groups-title bg2">조별 본선 순위</section>
|
||||
<div class="group-tabs bg0" role="tablist" aria-label="본선 조 선택">
|
||||
<button
|
||||
v-for="(groupName, groupIndex) in groupNames"
|
||||
:key="`final-tab-${groupName}`"
|
||||
type="button"
|
||||
role="tab"
|
||||
:aria-selected="activeFinalGroup === groupIndex"
|
||||
:class="{ active: activeFinalGroup === groupIndex }"
|
||||
@click="activeFinalGroup = groupIndex"
|
||||
>
|
||||
{{ groupName }}조
|
||||
</button>
|
||||
</div>
|
||||
<section class="group-grid bg0">
|
||||
<table v-for="(group, groupIndex) in groups" :key="groupIndex">
|
||||
<table
|
||||
v-for="(group, groupIndex) in groups"
|
||||
:key="groupIndex"
|
||||
:class="{ 'mobile-active': activeFinalGroup === groupIndex }"
|
||||
>
|
||||
<caption>
|
||||
{{
|
||||
['一', '二', '三', '四', '五', '六', '七', '八'][groupIndex]
|
||||
groupNames[groupIndex]
|
||||
}}조
|
||||
</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>순</th>
|
||||
<th>장수</th>
|
||||
<th>{{ typeNames[snapshot?.state?.type ?? 0].replace('전', '') }}</th>
|
||||
<th>{{ typeStatNames[snapshot?.state?.type ?? 0] }}</th>
|
||||
<th>경</th>
|
||||
<th>승</th>
|
||||
<th>무</th>
|
||||
@@ -186,26 +226,21 @@ const start = async () => {
|
||||
<tbody>
|
||||
<tr v-for="rowIndex in 4" :key="rowIndex">
|
||||
<td>{{ rowIndex }}</td>
|
||||
<td>{{ group[rowIndex - 1]?.name ?? '' }}</td>
|
||||
<td>
|
||||
{{
|
||||
group[rowIndex - 1]
|
||||
? (group[rowIndex - 1]!.win ?? 0) +
|
||||
(group[rowIndex - 1]!.draw ?? 0) +
|
||||
(group[rowIndex - 1]!.lose ?? 0)
|
||||
: ''
|
||||
}}
|
||||
<td class="general-cell">
|
||||
<GeneralIdentity
|
||||
v-if="group[rowIndex - 1]"
|
||||
:name="group[rowIndex - 1]!.name"
|
||||
:picture="group[rowIndex - 1]!.picture"
|
||||
:image-server="group[rowIndex - 1]!.imageServer"
|
||||
:icon-size="24"
|
||||
/>
|
||||
</td>
|
||||
<td>{{ statOf(group[rowIndex - 1]) }}</td>
|
||||
<td>{{ gamesOf(group[rowIndex - 1]) }}</td>
|
||||
<td>{{ group[rowIndex - 1]?.win ?? '' }}</td>
|
||||
<td>{{ group[rowIndex - 1]?.draw ?? '' }}</td>
|
||||
<td>{{ group[rowIndex - 1]?.lose ?? '' }}</td>
|
||||
<td>
|
||||
{{
|
||||
group[rowIndex - 1]
|
||||
? (group[rowIndex - 1]!.win ?? 0) * 3 + (group[rowIndex - 1]!.draw ?? 0)
|
||||
: ''
|
||||
}}
|
||||
</td>
|
||||
<td>{{ pointsOf(group[rowIndex - 1]) }}</td>
|
||||
<td>{{ group[rowIndex - 1]?.gl ?? '' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
@@ -213,18 +248,35 @@ const start = async () => {
|
||||
</section>
|
||||
|
||||
<section class="section-title groups-title bg2">조별 예선 순위</section>
|
||||
<div class="group-tabs bg0" role="tablist" aria-label="예선 조 선택">
|
||||
<button
|
||||
v-for="(groupName, groupIndex) in groupNames"
|
||||
:key="`preliminary-tab-${groupName}`"
|
||||
type="button"
|
||||
role="tab"
|
||||
:aria-selected="activePreliminaryGroup === groupIndex"
|
||||
:class="{ active: activePreliminaryGroup === groupIndex }"
|
||||
@click="activePreliminaryGroup = groupIndex"
|
||||
>
|
||||
{{ groupName }}조
|
||||
</button>
|
||||
</div>
|
||||
<section class="group-grid preliminary-grid bg0">
|
||||
<table v-for="groupIndex in 8" :key="`preliminary-${groupIndex}`">
|
||||
<table
|
||||
v-for="(group, groupIndex) in preliminaryGroups"
|
||||
:key="`preliminary-${groupIndex}`"
|
||||
:class="{ 'mobile-active': activePreliminaryGroup === groupIndex }"
|
||||
>
|
||||
<caption>
|
||||
{{
|
||||
['一', '二', '三', '四', '五', '六', '七', '八'][groupIndex - 1]
|
||||
groupNames[groupIndex]
|
||||
}}조
|
||||
</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>순</th>
|
||||
<th>장수</th>
|
||||
<th>{{ typeNames[snapshot?.state?.type ?? 0].replace('전', '') }}</th>
|
||||
<th>{{ typeStatNames[snapshot?.state?.type ?? 0] }}</th>
|
||||
<th>경</th>
|
||||
<th>승</th>
|
||||
<th>무</th>
|
||||
@@ -236,14 +288,22 @@ const start = async () => {
|
||||
<tbody>
|
||||
<tr v-for="rowIndex in 8" :key="rowIndex">
|
||||
<td>{{ rowIndex }}</td>
|
||||
<td></td>
|
||||
<td></td>
|
||||
<td></td>
|
||||
<td></td>
|
||||
<td></td>
|
||||
<td></td>
|
||||
<td></td>
|
||||
<td></td>
|
||||
<td class="general-cell">
|
||||
<GeneralIdentity
|
||||
v-if="group[rowIndex - 1]"
|
||||
:name="group[rowIndex - 1]!.name"
|
||||
:picture="group[rowIndex - 1]!.picture"
|
||||
:image-server="group[rowIndex - 1]!.imageServer"
|
||||
:icon-size="24"
|
||||
/>
|
||||
</td>
|
||||
<td>{{ statOf(group[rowIndex - 1]) }}</td>
|
||||
<td>{{ gamesOf(group[rowIndex - 1]) }}</td>
|
||||
<td>{{ group[rowIndex - 1]?.win ?? '' }}</td>
|
||||
<td>{{ group[rowIndex - 1]?.draw ?? '' }}</td>
|
||||
<td>{{ group[rowIndex - 1]?.lose ?? '' }}</td>
|
||||
<td>{{ pointsOf(group[rowIndex - 1]) }}</td>
|
||||
<td>{{ group[rowIndex - 1]?.gl ?? '' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -291,9 +351,10 @@ const start = async () => {
|
||||
|
||||
<style scoped>
|
||||
.legacy-page {
|
||||
width: 2009px;
|
||||
height: 1059px;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
max-width: 1200px;
|
||||
min-width: 0;
|
||||
min-height: 100vh;
|
||||
margin: 0 auto;
|
||||
color: #fff;
|
||||
font-family: var(--sammo-font-sans);
|
||||
@@ -420,13 +481,15 @@ button:focus-visible {
|
||||
}
|
||||
.group-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(8, 250px);
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
align-items: start;
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
}
|
||||
table {
|
||||
width: 250px;
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
table-layout: auto;
|
||||
table-layout: fixed;
|
||||
}
|
||||
caption {
|
||||
padding: 3px;
|
||||
@@ -439,14 +502,99 @@ th {
|
||||
}
|
||||
th,
|
||||
td {
|
||||
height: 17px;
|
||||
height: 30px;
|
||||
border: 1px solid #555;
|
||||
padding: 1px 3px;
|
||||
}
|
||||
.group-grid th:first-child,
|
||||
.group-grid td:first-child {
|
||||
width: 24px;
|
||||
}
|
||||
.group-grid th:nth-child(2),
|
||||
.group-grid td:nth-child(2) {
|
||||
width: 92px;
|
||||
}
|
||||
.general-cell {
|
||||
overflow: hidden;
|
||||
}
|
||||
.group-tabs {
|
||||
display: none;
|
||||
}
|
||||
.admin-row {
|
||||
text-align: left;
|
||||
}
|
||||
.error-row {
|
||||
color: #ff8080;
|
||||
}
|
||||
@media (max-width: 800px) {
|
||||
.legacy-page {
|
||||
max-width: 100%;
|
||||
font-size: 13px;
|
||||
}
|
||||
.legacy-title {
|
||||
height: auto;
|
||||
min-height: 55px;
|
||||
}
|
||||
.state-row {
|
||||
font-size: 18px;
|
||||
}
|
||||
.section-title {
|
||||
font-size: 20px;
|
||||
}
|
||||
.group-tabs {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(8, minmax(44px, 1fr));
|
||||
overflow-x: auto;
|
||||
padding: 6px;
|
||||
gap: 4px;
|
||||
}
|
||||
.group-tabs button {
|
||||
min-width: 44px;
|
||||
height: 34px;
|
||||
margin: 0;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.group-tabs button.active {
|
||||
border-color: #f39c12;
|
||||
background: #8a5b13;
|
||||
color: #fff;
|
||||
}
|
||||
.group-grid {
|
||||
display: block;
|
||||
overflow-x: auto;
|
||||
padding: 6px 0;
|
||||
}
|
||||
.group-grid table {
|
||||
display: none;
|
||||
min-width: 370px;
|
||||
}
|
||||
.group-grid table.mobile-active {
|
||||
display: table;
|
||||
}
|
||||
.group-grid th,
|
||||
.group-grid td {
|
||||
height: 31px;
|
||||
padding: 1px;
|
||||
font-size: 11px;
|
||||
}
|
||||
.group-grid th:first-child,
|
||||
.group-grid td:first-child {
|
||||
width: 22px;
|
||||
}
|
||||
.group-grid th:nth-child(2),
|
||||
.group-grid td:nth-child(2) {
|
||||
width: 108px;
|
||||
}
|
||||
.tournament-guide {
|
||||
padding: 10px;
|
||||
font-size: 11px;
|
||||
line-height: 16px;
|
||||
}
|
||||
.tournament-footer {
|
||||
padding: 10px 0 0;
|
||||
}
|
||||
.tournament-footer small {
|
||||
white-space: normal;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -3,7 +3,12 @@ import { describe, it } from 'node:test';
|
||||
|
||||
import { buildTournamentBracket } from '../src/utils/tournamentBracket.ts';
|
||||
|
||||
const participants = Array.from({ length: 16 }, (_, index) => ({ id: index + 1, name: `장수${index + 1}` }));
|
||||
const participants = Array.from({ length: 16 }, (_, index) => ({
|
||||
id: index + 1,
|
||||
name: `장수${index + 1}`,
|
||||
picture: `${index + 1}.jpg`,
|
||||
imageServer: index % 2,
|
||||
}));
|
||||
const matches = [
|
||||
...Array.from({ length: 8 }, (_, index) => ({
|
||||
id: index + 1,
|
||||
@@ -37,6 +42,8 @@ void describe('tournament bracket', () => {
|
||||
const bracket = buildTournamentBracket(participants, matches, 1);
|
||||
|
||||
assert.equal(bracket.champion.name, '장수1');
|
||||
assert.equal(bracket.champion.picture, '1.jpg');
|
||||
assert.equal(bracket.top16.slots[1]?.imageServer, 1);
|
||||
assert.deepEqual(
|
||||
bracket.top16.slots.map((slot) => slot.name),
|
||||
participants.map((participant) => participant.name)
|
||||
@@ -52,10 +59,16 @@ void describe('tournament bracket', () => {
|
||||
});
|
||||
|
||||
void it('renders missing future rounds as stable empty slots without inventing generals', () => {
|
||||
const bracket = buildTournamentBracket(participants, matches.filter((match) => match.stage === 7));
|
||||
const bracket = buildTournamentBracket(
|
||||
participants,
|
||||
matches.filter((match) => match.stage === 7)
|
||||
);
|
||||
|
||||
assert.equal(bracket.champion.name, '-');
|
||||
assert.deepEqual(bracket.final.slots.map((slot) => slot.name), ['-', '-']);
|
||||
assert.deepEqual(
|
||||
bracket.final.slots.map((slot) => slot.name),
|
||||
['-', '-']
|
||||
);
|
||||
assert.equal(bracket.top16.slots[0]?.name, '장수1');
|
||||
assert.equal(bracket.top16.slots[15]?.name, '장수16');
|
||||
});
|
||||
|
||||
@@ -1280,7 +1280,10 @@ export const adminRouter = router({
|
||||
sourceRef = resolved;
|
||||
}
|
||||
const scenarios = await listScenarioPreviews({ gitRef: resolved });
|
||||
if (!scenarios.some((scenario) => String(scenario.id) === profile.scenario)) {
|
||||
if (
|
||||
profile.currentScenario === null ||
|
||||
!scenarios.some((scenario) => String(scenario.id) === profile.currentScenario)
|
||||
) {
|
||||
throw new Error('Current scenario is not available at source.');
|
||||
}
|
||||
} catch {
|
||||
@@ -1579,6 +1582,8 @@ export const adminRouter = router({
|
||||
.map((profile) => ({
|
||||
profileName: profile.profileName,
|
||||
profile: profile.profile,
|
||||
instanceKey: profile.instanceKey,
|
||||
currentScenario: profile.currentScenario,
|
||||
meta: {
|
||||
...(typeof profile.meta.korName === 'string' ? { korName: profile.meta.korName } : {}),
|
||||
},
|
||||
@@ -1661,7 +1666,8 @@ export const adminRouter = router({
|
||||
);
|
||||
const profile = await ctx.profiles.getProfile(input.profileName);
|
||||
if (!profile) throw new TRPCError({ code: 'NOT_FOUND', message: 'Profile not found.' });
|
||||
const parsedScenarioId = Number(profile.scenario);
|
||||
const parsedScenarioId =
|
||||
profile.currentScenario === null ? Number.NaN : Number(profile.currentScenario);
|
||||
currentScenarioId = Number.isInteger(parsedScenarioId) ? parsedScenarioId : null;
|
||||
gitRef = profile.buildCommitSha?.trim();
|
||||
if (!gitRef) {
|
||||
@@ -1692,8 +1698,13 @@ export const adminRouter = router({
|
||||
upsert: profileAdminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
profile: z.string().min(1).max(32),
|
||||
scenario: z.string().min(1).max(64),
|
||||
profile: z.string().regex(/^[a-z0-9-]{1,32}$/),
|
||||
instanceKey: z
|
||||
.string()
|
||||
.regex(/^[a-z0-9-]{1,64}$/)
|
||||
.optional(),
|
||||
currentScenario: z.string().min(1).max(64).nullable().optional(),
|
||||
scenario: z.string().min(1).max(64).optional(),
|
||||
apiPort: z.number().int().min(1).max(65535),
|
||||
status: zProfileStatus.optional(),
|
||||
preopenAt: z.string().datetime().optional(),
|
||||
@@ -1706,6 +1717,8 @@ export const adminRouter = router({
|
||||
const status = input.status ?? 'STOPPED';
|
||||
return ctx.profiles.upsertProfile({
|
||||
profile: input.profile,
|
||||
instanceKey: input.instanceKey,
|
||||
currentScenario: input.currentScenario,
|
||||
scenario: input.scenario,
|
||||
apiPort: input.apiPort,
|
||||
status,
|
||||
|
||||
@@ -21,6 +21,9 @@ export type LobbyGeneralStatus = {
|
||||
export type LobbyProfileStatus = {
|
||||
profileName: string;
|
||||
profile: string;
|
||||
instanceKey: string;
|
||||
currentScenario: string | null;
|
||||
/** @deprecated Rollback-compatible mirror of currentScenario. */
|
||||
scenario: string;
|
||||
status: GatewayProfileStatus;
|
||||
apiPort: number;
|
||||
@@ -87,6 +90,8 @@ export class RepositoryProfileStatusService implements GatewayProfileStatusServi
|
||||
return {
|
||||
profileName: row.profileName,
|
||||
profile: row.profile,
|
||||
instanceKey: row.instanceKey,
|
||||
currentScenario: row.currentScenario,
|
||||
scenario: row.scenario,
|
||||
status: row.status,
|
||||
apiPort: row.apiPort,
|
||||
|
||||
@@ -396,7 +396,7 @@ export const buildProcessDefinitions = (
|
||||
...baseEnv,
|
||||
GAME_API_ROLE: 'server',
|
||||
PROFILE: profile.profile,
|
||||
SCENARIO: profile.scenario,
|
||||
SCENARIO: profile.currentScenario ?? 'default',
|
||||
GAME_PROFILE_NAME: profile.profileName,
|
||||
GAME_API_PORT: String(profile.apiPort),
|
||||
GAME_TRPC_PATH: `/${profile.profile}/api/trpc`,
|
||||
@@ -411,7 +411,7 @@ export const buildProcessDefinitions = (
|
||||
GAME_ENGINE_ROLE: 'turn-daemon',
|
||||
TURN_PROFILE: profile.profile,
|
||||
PROFILE: profile.profile,
|
||||
SCENARIO: profile.scenario,
|
||||
SCENARIO: profile.currentScenario ?? 'default',
|
||||
TURN_PROFILE_NAME: profile.profileName,
|
||||
};
|
||||
return {
|
||||
@@ -1422,8 +1422,8 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
|
||||
} = parseInstallOptions(action);
|
||||
const tickOverride =
|
||||
installOptions?.turnTermMinutes !== undefined ? installOptions.turnTermMinutes * 60 : undefined;
|
||||
const scenarioId = installScenarioId ?? parseScenarioId(profile.scenario);
|
||||
if (!scenarioId) {
|
||||
const scenarioId = installScenarioId ?? parseScenarioId(profile.currentScenario);
|
||||
if (scenarioId === null) {
|
||||
return { status: 'FAILED', detail: 'scenarioId is missing' };
|
||||
}
|
||||
const profileDatabaseUrl = this.resolveProfileDatabaseUrl(profile);
|
||||
@@ -1547,7 +1547,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
|
||||
const desiredStatus = shouldPreopen ? 'PREOPEN' : 'RUNNING';
|
||||
const publishedProfile = await updateClaimedProfile(
|
||||
{
|
||||
scenario: String(scenarioId),
|
||||
currentScenario: String(scenarioId),
|
||||
status: desiredStatus,
|
||||
buildStatus: 'SUCCEEDED',
|
||||
buildWorkspace: workspace.root,
|
||||
@@ -1564,8 +1564,8 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
|
||||
completedAt,
|
||||
error: null,
|
||||
});
|
||||
if (String(scenarioId) !== profile.scenario) {
|
||||
await this.repository.updateScenario(profile.profileName, String(scenarioId));
|
||||
if (String(scenarioId) !== profile.currentScenario) {
|
||||
await this.repository.updateCurrentScenario(profile.profileName, String(scenarioId));
|
||||
}
|
||||
return this.repository.updateStatus(profile.profileName, desiredStatus, {
|
||||
preopenAt: preopenAt ? preopenAt.toISOString() : openAt ? openAt.toISOString() : null,
|
||||
@@ -1577,6 +1577,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
|
||||
releasePrepared = true;
|
||||
const builtProfile = publishedProfile ?? {
|
||||
...profile,
|
||||
currentScenario: String(scenarioId),
|
||||
scenario: String(scenarioId),
|
||||
status: desiredStatus,
|
||||
buildWorkspace: workspace.root,
|
||||
@@ -1642,7 +1643,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
|
||||
meta: Record<string, unknown>;
|
||||
}> {
|
||||
const databaseUrl = databaseUrlOverride ?? this.resolveProfileDatabaseUrl(profile);
|
||||
let scenarioId = overrides?.scenarioId ?? parseScenarioId(profile.scenario);
|
||||
let scenarioId = overrides?.scenarioId ?? parseScenarioId(profile.currentScenario);
|
||||
let tickSeconds: number | undefined = overrides?.tickSeconds;
|
||||
let meta: Record<string, unknown> = {};
|
||||
const connector = createGamePostgresConnector({ url: databaseUrl });
|
||||
|
||||
@@ -78,6 +78,9 @@ export interface GatewayOperationLogInput {
|
||||
export interface GatewayProfileRecord {
|
||||
profileName: string;
|
||||
profile: string;
|
||||
instanceKey: string;
|
||||
currentScenario: string | null;
|
||||
/** @deprecated Rollback-compatible mirror of currentScenario. */
|
||||
scenario: string;
|
||||
apiPort: number;
|
||||
status: GatewayProfileStatus;
|
||||
@@ -100,7 +103,10 @@ export interface GatewayProfileRecord {
|
||||
|
||||
export interface GatewayProfileUpsertInput {
|
||||
profile: string;
|
||||
scenario: string;
|
||||
instanceKey?: string;
|
||||
currentScenario?: string | null;
|
||||
/** @deprecated Accepted while older bootstrap clients are still supported. */
|
||||
scenario?: string;
|
||||
apiPort: number;
|
||||
status?: GatewayProfileStatus;
|
||||
preopenAt?: string;
|
||||
@@ -111,7 +117,7 @@ export interface GatewayProfileUpsertInput {
|
||||
}
|
||||
|
||||
export interface GatewayClaimedProfileUpdate {
|
||||
scenario?: string;
|
||||
currentScenario?: string | null;
|
||||
status?: GatewayProfileStatus;
|
||||
buildStatus?: GatewayBuildStatus;
|
||||
buildCommitSha?: string | null;
|
||||
@@ -131,7 +137,7 @@ export interface GatewayProfileRepository {
|
||||
listProfiles(): Promise<GatewayProfileRecord[]>;
|
||||
getProfile(profileName: string): Promise<GatewayProfileRecord | null>;
|
||||
upsertProfile(input: GatewayProfileUpsertInput): Promise<GatewayProfileRecord>;
|
||||
updateScenario(profileName: string, scenario: string): Promise<GatewayProfileRecord | null>;
|
||||
updateCurrentScenario(profileName: string, scenario: string | null): Promise<GatewayProfileRecord | null>;
|
||||
updateStatus(
|
||||
profileName: string,
|
||||
status: GatewayProfileStatus,
|
||||
@@ -219,6 +225,8 @@ export const buildRetryOperationSource = (previous: {
|
||||
type GatewayProfileRow = {
|
||||
profileName: string;
|
||||
profile: string;
|
||||
instanceKey: string;
|
||||
currentScenario: string | null;
|
||||
scenario: string;
|
||||
apiPort: number;
|
||||
status: GatewayProfileStatus;
|
||||
@@ -265,6 +273,8 @@ type GatewayOperationRow = {
|
||||
const mapProfile = (row: GatewayProfileRow): GatewayProfileRecord => ({
|
||||
profileName: row.profileName,
|
||||
profile: row.profile,
|
||||
instanceKey: row.instanceKey,
|
||||
currentScenario: row.currentScenario,
|
||||
scenario: row.scenario,
|
||||
apiPort: row.apiPort,
|
||||
status: row.status,
|
||||
@@ -285,7 +295,24 @@ const mapProfile = (row: GatewayProfileRow): GatewayProfileRecord => ({
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
});
|
||||
|
||||
const buildProfileName = (profile: string, scenario: string): string => `${profile}:${scenario}`;
|
||||
export const buildGatewayProfileName = (profile: string, instanceKey: string): string => `${profile}:${instanceKey}`;
|
||||
|
||||
export const resolveGatewayProfileIdentity = (
|
||||
input: GatewayProfileUpsertInput
|
||||
): {
|
||||
instanceKey: string;
|
||||
currentScenario: string | null;
|
||||
shouldUpdateCurrentScenario: boolean;
|
||||
} => {
|
||||
const instanceKey = input.instanceKey ?? input.scenario ?? 'default';
|
||||
if (input.currentScenario !== undefined) {
|
||||
return { instanceKey, currentScenario: input.currentScenario, shouldUpdateCurrentScenario: true };
|
||||
}
|
||||
if (input.instanceKey === undefined && input.scenario !== undefined && input.scenario !== 'default') {
|
||||
return { instanceKey, currentScenario: input.scenario, shouldUpdateCurrentScenario: true };
|
||||
}
|
||||
return { instanceKey, currentScenario: null, shouldUpdateCurrentScenario: false };
|
||||
};
|
||||
|
||||
const mapOperation = (row: GatewayOperationRow): GatewayOperationRecord => ({
|
||||
id: row.id,
|
||||
@@ -331,7 +358,7 @@ const mapOperationLog = (row: {
|
||||
export const createGatewayProfileRepository = (prisma: GatewayPrismaClient): GatewayProfileRepository => ({
|
||||
async listProfiles(): Promise<GatewayProfileRecord[]> {
|
||||
const rows = await prisma.gatewayProfile.findMany({
|
||||
orderBy: [{ profile: 'asc' }, { scenario: 'asc' }],
|
||||
orderBy: [{ profile: 'asc' }, { instanceKey: 'asc' }],
|
||||
});
|
||||
return rows.map(mapProfile);
|
||||
},
|
||||
@@ -342,13 +369,16 @@ export const createGatewayProfileRepository = (prisma: GatewayPrismaClient): Gat
|
||||
return row ? mapProfile(row) : null;
|
||||
},
|
||||
async upsertProfile(input: GatewayProfileUpsertInput): Promise<GatewayProfileRecord> {
|
||||
const profileName = buildProfileName(input.profile, input.scenario);
|
||||
const { instanceKey, currentScenario, shouldUpdateCurrentScenario } = resolveGatewayProfileIdentity(input);
|
||||
const profileName = buildGatewayProfileName(input.profile, instanceKey);
|
||||
const row = await prisma.gatewayProfile.upsert({
|
||||
where: { profileName },
|
||||
create: {
|
||||
profileName,
|
||||
profile: input.profile,
|
||||
scenario: input.scenario,
|
||||
instanceKey,
|
||||
currentScenario,
|
||||
scenario: currentScenario ?? 'default',
|
||||
apiPort: input.apiPort,
|
||||
status: input.status ?? 'STOPPED',
|
||||
preopenAt: input.preopenAt ? new Date(input.preopenAt) : null,
|
||||
@@ -358,6 +388,8 @@ export const createGatewayProfileRepository = (prisma: GatewayPrismaClient): Gat
|
||||
meta: (input.meta ?? {}) as GatewayPrisma.JsonObject,
|
||||
},
|
||||
update: {
|
||||
currentScenario: shouldUpdateCurrentScenario ? currentScenario : undefined,
|
||||
scenario: shouldUpdateCurrentScenario ? (currentScenario ?? 'default') : undefined,
|
||||
apiPort: input.apiPort,
|
||||
status: input.status,
|
||||
preopenAt: input.preopenAt ? new Date(input.preopenAt) : input.preopenAt === null ? null : undefined,
|
||||
@@ -373,11 +405,12 @@ export const createGatewayProfileRepository = (prisma: GatewayPrismaClient): Gat
|
||||
});
|
||||
return mapProfile(row);
|
||||
},
|
||||
async updateScenario(profileName: string, scenario: string): Promise<GatewayProfileRecord | null> {
|
||||
async updateCurrentScenario(profileName: string, scenario: string | null): Promise<GatewayProfileRecord | null> {
|
||||
const row = await prisma.gatewayProfile.update({
|
||||
where: { profileName },
|
||||
data: {
|
||||
scenario,
|
||||
currentScenario: scenario,
|
||||
scenario: scenario ?? 'default',
|
||||
},
|
||||
});
|
||||
return row ? mapProfile(row) : null;
|
||||
@@ -700,7 +733,8 @@ export const createGatewayProfileRepository = (prisma: GatewayPrismaClient): Gat
|
||||
return tx.gatewayProfile.update({
|
||||
where: { profileName },
|
||||
data: {
|
||||
scenario: patch.scenario,
|
||||
currentScenario: patch.currentScenario,
|
||||
scenario: patch.currentScenario === undefined ? undefined : (patch.currentScenario ?? 'default'),
|
||||
status: patch.status,
|
||||
buildStatus: patch.buildStatus,
|
||||
buildCommitSha: patch.buildCommitSha,
|
||||
|
||||
@@ -3,8 +3,8 @@ export const GATEWAY_PROFILE_ORDER = ['che', 'kwe', 'pwe', 'twe', 'nya', 'pya',
|
||||
const gatewayProfileOrder = new Map<string, number>(GATEWAY_PROFILE_ORDER.map((profile, index) => [profile, index]));
|
||||
|
||||
export const compareGatewayProfiles = (
|
||||
left: { profile: string; scenario: string },
|
||||
right: { profile: string; scenario: string }
|
||||
left: { profile: string; instanceKey: string },
|
||||
right: { profile: string; instanceKey: string }
|
||||
): number => {
|
||||
const unknownRank = GATEWAY_PROFILE_ORDER.length;
|
||||
const profileOrder =
|
||||
@@ -14,8 +14,8 @@ export const compareGatewayProfiles = (
|
||||
|
||||
const profileNameOrder = left.profile.localeCompare(right.profile);
|
||||
if (profileNameOrder !== 0) return profileNameOrder;
|
||||
return left.scenario.localeCompare(right.scenario);
|
||||
return left.instanceKey.localeCompare(right.instanceKey);
|
||||
};
|
||||
|
||||
export const orderGatewayProfiles = <T extends { profile: string; scenario: string }>(profiles: readonly T[]): T[] =>
|
||||
export const orderGatewayProfiles = <T extends { profile: string; instanceKey: string }>(profiles: readonly T[]): T[] =>
|
||||
[...profiles].sort(compareGatewayProfiles);
|
||||
|
||||
@@ -85,6 +85,8 @@ const buildCaller = async (
|
||||
const profile = {
|
||||
profileName: 'che:2',
|
||||
profile: 'che',
|
||||
instanceKey: '2',
|
||||
currentScenario: options.profileScenario ?? '2',
|
||||
scenario: options.profileScenario ?? '2',
|
||||
apiPort: 15003,
|
||||
status: options.initialProfileStatus ?? ('STOPPED' as const),
|
||||
@@ -98,7 +100,7 @@ const buildCaller = async (
|
||||
listProfiles: async () => [profile],
|
||||
getProfile: async () => profile,
|
||||
upsertProfile: async () => profile,
|
||||
updateScenario: async () => profile,
|
||||
updateCurrentScenario: async () => profile,
|
||||
updateStatus: async (_profileName, status) => {
|
||||
updatedStatuses.push(status);
|
||||
return { ...profile, status };
|
||||
@@ -362,6 +364,8 @@ describe('admin profile navigation API', () => {
|
||||
{
|
||||
profileName: 'che:2',
|
||||
profile: 'che',
|
||||
instanceKey: '2',
|
||||
currentScenario: '2',
|
||||
meta: {},
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -15,6 +15,8 @@ import { appRouter } from '../src/router.js';
|
||||
const profile = {
|
||||
profileName: 'che:default',
|
||||
profile: 'che',
|
||||
instanceKey: 'default',
|
||||
currentScenario: null,
|
||||
scenario: 'default',
|
||||
apiPort: 15003,
|
||||
status: 'RUNNING' as const,
|
||||
@@ -28,7 +30,7 @@ const profiles: GatewayProfileRepository = {
|
||||
listProfiles: async () => [profile],
|
||||
getProfile: async (profileName) => (profileName === profile.profileName ? profile : null),
|
||||
upsertProfile: async () => profile,
|
||||
updateScenario: async () => profile,
|
||||
updateCurrentScenario: async () => profile,
|
||||
updateStatus: async () => profile,
|
||||
updateBuildStatus: async () => profile,
|
||||
updateMeta: async () => profile,
|
||||
|
||||
@@ -92,6 +92,8 @@ const buildCaller = (
|
||||
{
|
||||
profileName: 'che:default',
|
||||
profile: 'che',
|
||||
instanceKey: 'default',
|
||||
currentScenario: null,
|
||||
scenario: 'default',
|
||||
apiPort: 15003,
|
||||
status: 'RUNNING' as const,
|
||||
@@ -103,6 +105,8 @@ const buildCaller = (
|
||||
{
|
||||
profileName: 'hwe:default',
|
||||
profile: 'hwe',
|
||||
instanceKey: 'default',
|
||||
currentScenario: null,
|
||||
scenario: 'default',
|
||||
apiPort: 15015,
|
||||
status: 'RUNNING' as const,
|
||||
@@ -119,7 +123,7 @@ const buildCaller = (
|
||||
upsertProfile: async () => {
|
||||
throw new Error('not used');
|
||||
},
|
||||
updateScenario: async () => null,
|
||||
updateCurrentScenario: async () => null,
|
||||
updateStatus: async () => null,
|
||||
updateBuildStatus: async () => null,
|
||||
updateMeta: async () => null,
|
||||
@@ -167,6 +171,8 @@ const buildCaller = (
|
||||
profileRows.map((profile) => ({
|
||||
profileName: profile.profileName,
|
||||
profile: profile.profile,
|
||||
instanceKey: profile.instanceKey,
|
||||
currentScenario: profile.currentScenario,
|
||||
scenario: profile.scenario,
|
||||
status: profile.status,
|
||||
apiPort: profile.apiPort,
|
||||
|
||||
@@ -13,6 +13,8 @@ import { GitWorkspaceManager } from '../src/orchestrator/workspaceManager.js';
|
||||
const profile: GatewayProfileRecord = {
|
||||
profileName: 'che:2',
|
||||
profile: 'che',
|
||||
instanceKey: '2',
|
||||
currentScenario: '2',
|
||||
scenario: '2',
|
||||
apiPort: 15003,
|
||||
status: 'STOPPED',
|
||||
@@ -58,7 +60,7 @@ const createHarness = (
|
||||
listProfiles: async () => [profile],
|
||||
getProfile: async () => profile,
|
||||
upsertProfile: async () => profile,
|
||||
updateScenario: async () => profile,
|
||||
updateCurrentScenario: async () => profile,
|
||||
updateStatus: async (_profileName, status) => {
|
||||
statuses.push(status);
|
||||
return { ...profile, status };
|
||||
|
||||
@@ -14,6 +14,8 @@ import type { GatewayProfileRecord } from '../src/orchestrator/profileRepository
|
||||
const buildProfile = (buildWorkspace?: string): GatewayProfileRecord => ({
|
||||
profileName: 'che:2',
|
||||
profile: 'che',
|
||||
instanceKey: '2',
|
||||
currentScenario: '2',
|
||||
scenario: '2',
|
||||
apiPort: 15003,
|
||||
status: 'RUNNING',
|
||||
@@ -172,6 +174,39 @@ describe('buildProcessDefinitions', () => {
|
||||
expect(definitions.tournament.cwd).toBe(path.join(processConfig.workspaceRoot, 'app', 'game-api'));
|
||||
});
|
||||
|
||||
it('keeps the instance identity stable while passing the mutable current scenario', () => {
|
||||
const definitions = buildProcessDefinitions(
|
||||
{
|
||||
...buildProfile(),
|
||||
profileName: 'che:default',
|
||||
instanceKey: 'default',
|
||||
currentScenario: '1010',
|
||||
scenario: '1010',
|
||||
},
|
||||
processConfig
|
||||
);
|
||||
|
||||
expect(definitions.api.name).toBe('sammo:che:default:game-api');
|
||||
expect(definitions.api.env).toMatchObject({
|
||||
GAME_PROFILE_NAME: 'che:default',
|
||||
SCENARIO: '1010',
|
||||
});
|
||||
expect(definitions.daemon.env).toMatchObject({
|
||||
TURN_PROFILE_NAME: 'che:default',
|
||||
SCENARIO: '1010',
|
||||
});
|
||||
});
|
||||
|
||||
it('uses the legacy default scenario marker only for an uninitialized instance runtime', () => {
|
||||
const definitions = buildProcessDefinitions(
|
||||
{ ...buildProfile(), currentScenario: null, scenario: 'default' },
|
||||
processConfig
|
||||
);
|
||||
|
||||
expect(definitions.api.env.SCENARIO).toBe('default');
|
||||
expect(definitions.daemon.env.SCENARIO).toBe('default');
|
||||
});
|
||||
|
||||
it('does not forward PM2 identity or parent runtime roles to profile processes', () => {
|
||||
const definitions = buildProcessDefinitions(buildProfile(), {
|
||||
...processConfig,
|
||||
|
||||
@@ -14,6 +14,8 @@ const makeProfile = (
|
||||
): GatewayProfileRecord => ({
|
||||
profileName,
|
||||
profile: profileName.split(':')[0] ?? 'che',
|
||||
instanceKey: profileName.split(':')[1] ?? 'default',
|
||||
currentScenario: null,
|
||||
scenario: profileName.split(':')[1] ?? 'default',
|
||||
apiPort: 15_003,
|
||||
status: 'RUNNING',
|
||||
|
||||
@@ -50,6 +50,8 @@ describe('profile DEPLOY operation', () => {
|
||||
const profile: GatewayProfileRecord = {
|
||||
profileName: 'che:1010',
|
||||
profile: 'che',
|
||||
instanceKey: '1010',
|
||||
currentScenario: '1010',
|
||||
scenario: '1010',
|
||||
apiPort: 15003,
|
||||
status: 'RUNNING',
|
||||
@@ -80,7 +82,7 @@ describe('profile DEPLOY operation', () => {
|
||||
listProfiles: async () => [profile],
|
||||
getProfile: async () => profile,
|
||||
upsertProfile: async () => profile,
|
||||
updateScenario: async () => profile,
|
||||
updateCurrentScenario: async () => profile,
|
||||
updateStatus: async () => profile,
|
||||
updateBuildStatus: async () => profile,
|
||||
updateMeta: async () => profile,
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
buildGatewayProfileName,
|
||||
resolveGatewayProfileIdentity,
|
||||
type GatewayProfileUpsertInput,
|
||||
} from '../src/orchestrator/profileRepository.js';
|
||||
|
||||
const resolve = (input: Partial<GatewayProfileUpsertInput>) =>
|
||||
resolveGatewayProfileIdentity({
|
||||
profile: 'che',
|
||||
apiPort: 15003,
|
||||
...input,
|
||||
});
|
||||
|
||||
describe('Gateway profile identity', () => {
|
||||
it('builds the immutable technical id from profile and instance key', () => {
|
||||
expect(buildGatewayProfileName('che', 'default')).toBe('che:default');
|
||||
});
|
||||
|
||||
it('does not treat a new default instance as an initialized scenario', () => {
|
||||
expect(resolve({ instanceKey: 'default' })).toEqual({
|
||||
instanceKey: 'default',
|
||||
currentScenario: null,
|
||||
shouldUpdateCurrentScenario: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts the old bootstrap default marker without clearing an existing scenario on upsert', () => {
|
||||
expect(resolve({ scenario: 'default' })).toEqual({
|
||||
instanceKey: 'default',
|
||||
currentScenario: null,
|
||||
shouldUpdateCurrentScenario: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('maps a legacy non-default scenario to both identity and current state', () => {
|
||||
expect(resolve({ scenario: '2' })).toEqual({
|
||||
instanceKey: '2',
|
||||
currentScenario: '2',
|
||||
shouldUpdateCurrentScenario: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps a default instance stable when its current scenario changes', () => {
|
||||
expect(resolve({ instanceKey: 'default', currentScenario: '1010' })).toEqual({
|
||||
instanceKey: 'default',
|
||||
currentScenario: '1010',
|
||||
shouldUpdateCurrentScenario: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -6,25 +6,25 @@ describe('orderGatewayProfiles', () => {
|
||||
it('uses the public server order instead of alphabetical profile order', () => {
|
||||
const profiles = ['hwe', 'pya', 'che', 'nya', 'twe', 'pwe', 'kwe'].map((profile) => ({
|
||||
profile,
|
||||
scenario: 'default',
|
||||
instanceKey: 'default',
|
||||
}));
|
||||
|
||||
expect(orderGatewayProfiles(profiles).map(({ profile }) => profile)).toEqual(GATEWAY_PROFILE_ORDER);
|
||||
});
|
||||
|
||||
it('orders scenarios within a profile and places unknown profiles afterward', () => {
|
||||
it('orders instance keys within a profile and places unknown profiles afterward', () => {
|
||||
const profiles = [
|
||||
{ profile: 'zeta', scenario: 'default' },
|
||||
{ profile: 'che', scenario: '20' },
|
||||
{ profile: 'alpha', scenario: 'default' },
|
||||
{ profile: 'che', scenario: '10' },
|
||||
{ profile: 'zeta', instanceKey: 'default' },
|
||||
{ profile: 'che', instanceKey: '20' },
|
||||
{ profile: 'alpha', instanceKey: 'default' },
|
||||
{ profile: 'che', instanceKey: '10' },
|
||||
];
|
||||
|
||||
expect(orderGatewayProfiles(profiles)).toEqual([
|
||||
{ profile: 'che', scenario: '10' },
|
||||
{ profile: 'che', scenario: '20' },
|
||||
{ profile: 'alpha', scenario: 'default' },
|
||||
{ profile: 'zeta', scenario: 'default' },
|
||||
{ profile: 'che', instanceKey: '10' },
|
||||
{ profile: 'che', instanceKey: '20' },
|
||||
{ profile: 'alpha', instanceKey: 'default' },
|
||||
{ profile: 'zeta', instanceKey: 'default' },
|
||||
]);
|
||||
expect(profiles[0]?.profile).toBe('zeta');
|
||||
});
|
||||
|
||||
@@ -38,7 +38,7 @@ describe('readReleaseManifest', () => {
|
||||
|
||||
await expect(readReleaseManifest(workspaceRoot)).resolves.toMatchObject({
|
||||
controllerProtocol: RELEASE_CONTROLLER_PROTOCOL,
|
||||
gatewaySchemaHead: '20260811000000_add_gateway_operation_logs',
|
||||
gatewaySchemaHead: '20260813000000_split_gateway_profile_identity',
|
||||
gameSchemaHead: '20260803000000_add_logical_game_clock',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -150,6 +150,8 @@ const installFixture = async (
|
||||
{
|
||||
profileName: 'hwe:default',
|
||||
profile: 'hwe',
|
||||
instanceKey: 'default',
|
||||
currentScenario: '1010',
|
||||
meta: {},
|
||||
},
|
||||
]);
|
||||
@@ -160,6 +162,8 @@ const installFixture = async (
|
||||
{
|
||||
profileName: 'hwe:default',
|
||||
profile: 'hwe',
|
||||
instanceKey: 'default',
|
||||
currentScenario: '1010',
|
||||
scenario: '1010',
|
||||
apiPort: 15015,
|
||||
status: 'RUNNING',
|
||||
@@ -352,7 +356,9 @@ test('directs profile deployment to the selected server version tab', async ({ p
|
||||
await expect(versionTab).toBeFocused();
|
||||
const tabAndHeaderGeometry = await Promise.all([
|
||||
tabs.evaluate((element) => element.getBoundingClientRect().top),
|
||||
page.getByText('hwe:default (hwe)', { exact: true }).evaluate((element) => element.getBoundingClientRect().top),
|
||||
page
|
||||
.getByText('서버 ID: hwe:default · 인스턴스: default', { exact: true })
|
||||
.evaluate((element) => element.getBoundingClientRect().top),
|
||||
]);
|
||||
expect(tabAndHeaderGeometry[0]).toBeLessThan(tabAndHeaderGeometry[1]);
|
||||
await page.screenshot({ path: testInfo.outputPath('status-tabs-desktop.png'), fullPage: true });
|
||||
|
||||
@@ -41,6 +41,8 @@ const installGatewayFixture = async (page: Page, roles: string[]) => {
|
||||
{
|
||||
profileName: 'hwe:2',
|
||||
profile: 'hwe',
|
||||
instanceKey: '2',
|
||||
currentScenario: '1010',
|
||||
scenario: '1010',
|
||||
status: 'RUNNING',
|
||||
buildStatus: 'SUCCEEDED',
|
||||
@@ -59,6 +61,8 @@ const installGatewayFixture = async (page: Page, roles: string[]) => {
|
||||
{
|
||||
profileName: 'hwe:2',
|
||||
profile: 'hwe',
|
||||
instanceKey: '2',
|
||||
currentScenario: '1010',
|
||||
meta: { korName: '환상서버' },
|
||||
},
|
||||
]
|
||||
@@ -209,7 +213,7 @@ test('scoped administrators see the same navigation while ordinary users do not'
|
||||
await expect(scopedPage.getByRole('link', { name: '관리자 페이지' })).toBeVisible();
|
||||
await scopedPage.getByRole('link', { name: '관리자 페이지' }).click();
|
||||
const scopedNavigation = scopedPage.getByRole('navigation', { name: '관리자 메뉴' });
|
||||
await expect(scopedNavigation.getByRole('link', { name: '환상서버 (hwe:2)' })).toBeVisible();
|
||||
await expect(scopedNavigation.getByRole('link', { name: '환상서버 [2]' })).toBeVisible();
|
||||
await expect(scopedNavigation.getByRole('link', { name: 'Gateway 릴리스' })).toHaveCount(0);
|
||||
await expect(scopedNavigation.getByRole('link', { name: '사용자 관리' })).toHaveCount(0);
|
||||
await scopedContext.close();
|
||||
|
||||
@@ -55,8 +55,10 @@ type FixtureState = {
|
||||
};
|
||||
|
||||
const profile = (runtimeRunning: boolean, resetDefaults?: Record<string, unknown>) => ({
|
||||
profileName: 'che:2',
|
||||
profileName: 'che:default',
|
||||
profile: 'che',
|
||||
instanceKey: 'default',
|
||||
currentScenario: '2',
|
||||
scenario: '2',
|
||||
apiPort: 15003,
|
||||
status: runtimeRunning ? 'RUNNING' : 'STOPPED',
|
||||
@@ -69,7 +71,7 @@ const profile = (runtimeRunning: boolean, resetDefaults?: Record<string, unknown
|
||||
activeOperation: null,
|
||||
runtimeActions: [],
|
||||
runtime: {
|
||||
profileName: 'che:2',
|
||||
profileName: 'che:default',
|
||||
frontendRunning: runtimeRunning,
|
||||
apiRunning: runtimeRunning,
|
||||
daemonRunning: runtimeRunning,
|
||||
@@ -145,10 +147,7 @@ const installFixture = async (page: Page, state: FixtureState) => {
|
||||
await route.abort('failed');
|
||||
return;
|
||||
}
|
||||
if (
|
||||
names.includes('admin.releases.gatewayState') &&
|
||||
(state.gatewayStateFailuresRemaining ?? 0) > 0
|
||||
) {
|
||||
if (names.includes('admin.releases.gatewayState') && (state.gatewayStateFailuresRemaining ?? 0) > 0) {
|
||||
state.gatewayStateFailuresRemaining = (state.gatewayStateFailuresRemaining ?? 0) - 1;
|
||||
state.gatewayStateFailureCount = (state.gatewayStateFailureCount ?? 0) + 1;
|
||||
await route.fulfill({
|
||||
@@ -171,8 +170,10 @@ const installFixture = async (page: Page, state: FixtureState) => {
|
||||
if (name === 'admin.profiles.listNavigation') {
|
||||
return response([
|
||||
{
|
||||
profileName: 'che:2',
|
||||
profileName: 'che:default',
|
||||
profile: 'che',
|
||||
instanceKey: 'default',
|
||||
currentScenario: '2',
|
||||
meta: { korName: '천하서버' },
|
||||
},
|
||||
]);
|
||||
@@ -314,7 +315,7 @@ const installFixture = async (page: Page, state: FixtureState) => {
|
||||
if (name === 'admin.operations.requestReset') {
|
||||
const operation: Operation = {
|
||||
id: '11111111-1111-4111-8111-111111111111',
|
||||
profileName: 'che:2',
|
||||
profileName: 'che:default',
|
||||
type: 'RESET',
|
||||
status: 'QUEUED',
|
||||
sourceMode: 'COMMIT',
|
||||
@@ -330,7 +331,7 @@ const installFixture = async (page: Page, state: FixtureState) => {
|
||||
if (name === 'admin.operations.requestDeploy') {
|
||||
const operation: Operation = {
|
||||
id: '66666666-6666-4666-8666-666666666666',
|
||||
profileName: 'che:2',
|
||||
profileName: 'che:default',
|
||||
type: 'DEPLOY',
|
||||
status: 'QUEUED',
|
||||
sourceMode: 'BRANCH',
|
||||
@@ -368,7 +369,7 @@ const installFixture = async (page: Page, state: FixtureState) => {
|
||||
type === 'START'
|
||||
? '22222222-2222-4222-8222-222222222222'
|
||||
: '33333333-3333-4333-8333-333333333333',
|
||||
profileName: 'che:2',
|
||||
profileName: 'che:default',
|
||||
type,
|
||||
status: 'SUCCEEDED',
|
||||
payload: {},
|
||||
@@ -382,7 +383,7 @@ const installFixture = async (page: Page, state: FixtureState) => {
|
||||
if (name === 'admin.operations.retry') {
|
||||
const operation: Operation = {
|
||||
id: '44444444-4444-4444-8444-444444444444',
|
||||
profileName: 'che:2',
|
||||
profileName: 'che:default',
|
||||
type: 'RESET',
|
||||
status: 'QUEUED',
|
||||
sourceMode: 'COMMIT',
|
||||
@@ -420,9 +421,9 @@ test('separates branch and commit semantics and submits a reset from the dedicat
|
||||
await installFixture(page, state);
|
||||
page.on('dialog', (dialog) => dialog.accept());
|
||||
|
||||
await page.goto('admin/servers/che%3A2/scenario');
|
||||
await page.goto('admin/servers/che%3Adefault/scenario');
|
||||
await expect(page.getByTestId('server-operations-page')).toBeVisible();
|
||||
await expect(page).toHaveURL(/\/gateway\/admin\/servers\/che%3A2\/scenario$/);
|
||||
await expect(page).toHaveURL(/\/gateway\/admin\/servers\/che%3Adefault\/scenario$/);
|
||||
await expect(page.getByTestId('source-current')).toBeChecked();
|
||||
await expect(page.getByTestId('source-help')).toContainText('현재 서버에 배포된 커밋');
|
||||
await expect(page.getByTestId('scenario-select')).toHaveValue('2');
|
||||
@@ -497,7 +498,7 @@ test('separates branch and commit semantics and submits a reset from the dedicat
|
||||
await expect(page.getByText('초기화 작업을 등록했습니다.').first()).toBeVisible();
|
||||
await expect(page.getByTestId('operations-table')).toContainText('RESET');
|
||||
await expect(page.getByTestId('profile-operation-log-panel')).toBeVisible();
|
||||
await expect(page.getByTestId('profile-operation-log')).toContainText('che:2 구성 요소를 빌드합니다.');
|
||||
await expect(page.getByTestId('profile-operation-log')).toContainText('che:default 구성 요소를 빌드합니다.');
|
||||
await expect(page.getByTestId('profile-operation-log')).toContainText('시나리오 초기 데이터 생성을 완료했습니다.');
|
||||
await expect(page.getByTestId('profile-operation-log-status')).toContainText('SUCCEEDED');
|
||||
const operationTableGeometry = await page.getByTestId('operations-table').evaluate((table) => {
|
||||
@@ -614,7 +615,7 @@ test('separates DB-preserving profile deployment from DB reset', async ({ page }
|
||||
await installFixture(page, state);
|
||||
page.on('dialog', (dialog) => dialog.accept());
|
||||
|
||||
await page.goto('admin/servers/che%3A2/version');
|
||||
await page.goto('admin/servers/che%3Adefault/version');
|
||||
await expect(page.getByRole('heading', { name: 'DB 보존 버전 업데이트' })).toBeVisible();
|
||||
await expect(page.getByText('운영 프로필', { exact: true })).toHaveCount(0);
|
||||
await expect(page.getByRole('link', { name: '버전 업데이트', exact: true })).toHaveAttribute(
|
||||
@@ -626,7 +627,7 @@ test('separates DB-preserving profile deployment from DB reset', async ({ page }
|
||||
await expect(page.getByText('DB 보존 배포 작업을 등록했습니다.').first()).toBeVisible();
|
||||
await expect(page.getByTestId('operations-table')).toContainText('DEPLOY');
|
||||
await expect(page.getByTestId('profile-operation-log-panel')).toBeVisible();
|
||||
await expect(page.getByTestId('profile-operation-log')).toContainText('che:2 구성 요소를 빌드합니다.');
|
||||
await expect(page.getByTestId('profile-operation-log')).toContainText('che:default 구성 요소를 빌드합니다.');
|
||||
await expect(page.getByTestId('profile-operation-log')).toContainText('game-frontend build complete');
|
||||
await expect(page.getByTestId('profile-operation-log-status')).toContainText('SUCCEEDED');
|
||||
expect(state.requestBodies.some((entry) => entry.operation === 'admin.operations.requestDeploy')).toBe(true);
|
||||
@@ -655,7 +656,7 @@ test('loads server metadata defaults into the reset form and submits them', asyn
|
||||
await installFixture(page, state);
|
||||
page.on('dialog', (dialog) => dialog.accept());
|
||||
|
||||
await page.goto('admin/servers/che%3A2/scenario');
|
||||
await page.goto('admin/servers/che%3Adefault/scenario');
|
||||
await expect(page.getByTestId('reset-turn-term')).toHaveValue('20');
|
||||
await page.getByText('고급 시나리오 옵션').click();
|
||||
await expect(page.getByTestId('reset-defaults-source')).toContainText('서버의 메타');
|
||||
@@ -679,7 +680,7 @@ test('edits server reset defaults through profile metadata settings', async ({ p
|
||||
const state: FixtureState = { operations: [], gatewayOperations: [], runtimeRunning: true, requestBodies: [] };
|
||||
await installFixture(page, state);
|
||||
|
||||
await page.goto('admin/servers/che%3A2');
|
||||
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-npc-mode').selectOption('2');
|
||||
@@ -742,7 +743,7 @@ test('shows a dismissible error toast when profile metadata persistence fails',
|
||||
};
|
||||
await installFixture(page, state);
|
||||
|
||||
await page.goto('admin/servers/che%3A2');
|
||||
await page.goto('admin/servers/che%3Adefault');
|
||||
await page.getByPlaceholder('변경 사유 (필수)').fill('exercise persistence error');
|
||||
await page.getByRole('button', { name: '메타 저장' }).click();
|
||||
|
||||
@@ -765,7 +766,7 @@ test('renders the fixed-profile version form without waiting for the server list
|
||||
};
|
||||
await installFixture(page, state);
|
||||
|
||||
await page.goto('admin/servers/che%3A2/version');
|
||||
await page.goto('admin/servers/che%3Adefault/version');
|
||||
await expect(page.getByTestId('request-deploy')).toBeVisible({ timeout: 900 });
|
||||
expect(state.profileNavigationResolved).toBe(false);
|
||||
await expect.poll(() => state.profileNavigationResolved).toBe(true);
|
||||
@@ -782,7 +783,7 @@ test('recovers the current-version scenario catalog after the initial request fa
|
||||
};
|
||||
await installFixture(page, state);
|
||||
|
||||
await page.goto('admin/servers/che%3A2/scenario');
|
||||
await page.goto('admin/servers/che%3Adefault/scenario');
|
||||
await expect(page.getByTestId('scenario-select')).toContainText('선택할 수 있는 시나리오가 없습니다.');
|
||||
await expect(page.getByTestId('request-reset')).toBeDisabled();
|
||||
await page.getByTestId('load-scenarios').click();
|
||||
@@ -790,7 +791,9 @@ test('recovers the current-version scenario catalog after the initial request fa
|
||||
await expect(page.getByTestId('request-reset')).toBeEnabled();
|
||||
});
|
||||
|
||||
test('renders the server navigation before the detailed runtime profile request resolves', async ({ page }) => {
|
||||
test('renders the stable server identity without exposing the default suffix as the display name', async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const state: FixtureState = {
|
||||
operations: [],
|
||||
gatewayOperations: [],
|
||||
@@ -800,10 +803,42 @@ test('renders the server navigation before the detailed runtime profile request
|
||||
};
|
||||
await installFixture(page, state);
|
||||
|
||||
await page.goto('admin/servers/che%3A2');
|
||||
await page.goto('admin/servers/che%3Adefault');
|
||||
const navigation = page.getByRole('navigation', { name: '관리자 메뉴' });
|
||||
await expect(navigation.getByRole('link', { name: '천하서버 (che:2)' })).toBeVisible({ timeout: 900 });
|
||||
const profileLink = navigation.getByRole('link', { name: '천하서버' });
|
||||
await expect(profileLink).toBeVisible({ timeout: 900 });
|
||||
await expect(profileLink).toHaveAttribute('title', '서버 ID: che:default');
|
||||
await expect(navigation).not.toContainText('천하서버 (che:default)');
|
||||
await expect(navigation.getByRole('link', { name: 'Gateway 릴리스' })).toBeVisible({ timeout: 900 });
|
||||
await expect(page.getByText('서버 ID: che:default · 인스턴스: default')).toBeVisible();
|
||||
await expect(page.getByText('현재 시나리오: 2')).toBeVisible();
|
||||
await profileLink.focus();
|
||||
const desktop = await profileLink.evaluate((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
const style = getComputedStyle(element);
|
||||
return {
|
||||
x: rect.x,
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
overflow: element.scrollWidth - element.clientWidth,
|
||||
backgroundColor: style.backgroundColor,
|
||||
color: style.color,
|
||||
};
|
||||
});
|
||||
expect(desktop.width).toBeGreaterThan(100);
|
||||
expect(desktop.height).toBeGreaterThan(30);
|
||||
expect(desktop.overflow).toBeLessThanOrEqual(0);
|
||||
expect(desktop.backgroundColor).toBe('rgb(45, 27, 8)');
|
||||
expect(desktop.color).toBe('rgb(253, 230, 138)');
|
||||
await page.screenshot({ path: testInfo.outputPath('profile-identity-desktop.png'), fullPage: true });
|
||||
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.getByRole('button', { name: '관리자 메뉴' }).click();
|
||||
await expect(profileLink).toBeVisible();
|
||||
expect(
|
||||
await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth)
|
||||
).toBeLessThanOrEqual(0);
|
||||
await page.screenshot({ path: testInfo.outputPath('profile-identity-mobile.png'), fullPage: true });
|
||||
expect(state.profileNavigationRequests).toBe(1);
|
||||
});
|
||||
|
||||
@@ -813,12 +848,12 @@ test('scenario-only operator resets the current version without Git or Gateway c
|
||||
gatewayOperations: [],
|
||||
runtimeRunning: true,
|
||||
requestBodies: [],
|
||||
capabilities: [{ permission: 'admin.scenarios.reset', scope: 'PROFILE', scopes: ['che:2'] }],
|
||||
capabilities: [{ permission: 'admin.scenarios.reset', scope: 'PROFILE', scopes: ['che:default'] }],
|
||||
};
|
||||
await installFixture(page, state);
|
||||
page.on('dialog', (dialog) => dialog.accept());
|
||||
|
||||
await page.goto('admin/servers/che%3A2/scenario');
|
||||
await page.goto('admin/servers/che%3Adefault/scenario');
|
||||
await expect(page.getByTestId('source-current')).toBeChecked();
|
||||
await expect(page.getByTestId('source-branch')).toHaveCount(0);
|
||||
await expect(page.getByTestId('source-commit')).toHaveCount(0);
|
||||
@@ -996,9 +1031,7 @@ test('moves long Gateway release errors out of the table column into an expandab
|
||||
expect(mobileGeometry.detailWidth).toBeGreaterThanOrEqual(mobileGeometry.tableWidth - 1);
|
||||
expect(mobileGeometry.scrollerScrollWidth).toBeLessThanOrEqual(mobileGeometry.scrollerWidth + 1);
|
||||
expect(mobileGeometry.scrollerX).toBeGreaterThanOrEqual(0);
|
||||
expect(mobileGeometry.scrollerX + mobileGeometry.scrollerWidth).toBeLessThanOrEqual(
|
||||
mobileGeometry.viewportWidth
|
||||
);
|
||||
expect(mobileGeometry.scrollerX + mobileGeometry.scrollerWidth).toBeLessThanOrEqual(mobileGeometry.viewportWidth);
|
||||
expect(mobileGeometry.documentScrollWidth).toBeLessThanOrEqual(mobileGeometry.viewportWidth);
|
||||
await page.screenshot({ path: testInfo.outputPath('gateway-release-error-expanded-mobile.png'), fullPage: true });
|
||||
|
||||
@@ -1043,7 +1076,7 @@ test('renders a failed reset, retries it as a new operation, and reaches success
|
||||
operations: [
|
||||
{
|
||||
id: '55555555-5555-4555-8555-555555555555',
|
||||
profileName: 'che:2',
|
||||
profileName: 'che:default',
|
||||
type: 'RESET',
|
||||
status: 'FAILED',
|
||||
sourceMode: 'COMMIT',
|
||||
@@ -1064,7 +1097,7 @@ test('renders a failed reset, retries it as a new operation, and reaches success
|
||||
await installFixture(page, state);
|
||||
page.on('dialog', (dialog) => dialog.accept());
|
||||
|
||||
await page.goto('admin/servers/che%3A2/scenario');
|
||||
await page.goto('admin/servers/che%3Adefault/scenario');
|
||||
await expect(page.getByTestId('operations-table').getByText('FAILED', { exact: true })).toBeVisible();
|
||||
await expect(page.getByRole('cell', { name: 'fedcba987654', exact: true })).toBeVisible();
|
||||
const failure = page.getByTestId('operations-table').getByText(longError);
|
||||
|
||||
@@ -16,12 +16,28 @@ const adminNavigationClient = directTrpc.admin as unknown as {
|
||||
capabilities: { list: { query: () => Promise<Array<{ permission: string; scopes?: string[] }>> } };
|
||||
profiles: {
|
||||
listNavigation: {
|
||||
query: () => Promise<Array<{ profileName: string; profile: string; meta?: Record<string, unknown> }>>;
|
||||
query: () => Promise<
|
||||
Array<{
|
||||
profileName: string;
|
||||
profile: string;
|
||||
instanceKey: string;
|
||||
currentScenario: string | null;
|
||||
meta?: Record<string, unknown>;
|
||||
}>
|
||||
>;
|
||||
};
|
||||
};
|
||||
};
|
||||
const capabilities = ref<Array<{ permission: string; scopes?: string[] }>>([]);
|
||||
const profiles = ref<Array<{ profileName: string; profile: string; meta?: Record<string, unknown> }>>([]);
|
||||
const profiles = ref<
|
||||
Array<{
|
||||
profileName: string;
|
||||
profile: string;
|
||||
instanceKey: string;
|
||||
currentScenario: string | null;
|
||||
meta?: Record<string, unknown>;
|
||||
}>
|
||||
>([]);
|
||||
|
||||
const isRootAdmin = computed(() =>
|
||||
(auth.user?.roles ?? []).some((role) => role === 'superuser' || role === 'admin' || role === 'admin.superuser')
|
||||
@@ -38,7 +54,8 @@ const hasAnyProfileCapability = computed(() =>
|
||||
|
||||
const profileLabel = (profile: (typeof profiles.value)[number]): string => {
|
||||
const korName = profile.meta?.korName;
|
||||
return typeof korName === 'string' && korName.trim() ? `${korName} (${profile.profileName})` : profile.profileName;
|
||||
const displayName = typeof korName === 'string' && korName.trim() ? korName.trim() : profile.profile;
|
||||
return profile.instanceKey === 'default' ? displayName : `${displayName} [${profile.instanceKey}]`;
|
||||
};
|
||||
|
||||
const navigation = computed(() => [
|
||||
@@ -70,6 +87,7 @@ const navigation = computed(() => [
|
||||
...profiles.value.map((profile) => ({
|
||||
to: `/admin/servers/${encodeURIComponent(profile.profileName)}`,
|
||||
label: profileLabel(profile),
|
||||
title: `서버 ID: ${profile.profileName}`,
|
||||
icon: '└',
|
||||
exact: false,
|
||||
visible: true,
|
||||
@@ -156,6 +174,7 @@ onMounted(async () => {
|
||||
:class="{ child: item.child }"
|
||||
:active-class="item.exact ? '' : 'active'"
|
||||
:exact-active-class="item.exact ? 'active' : ''"
|
||||
:title="'title' in item ? item.title : undefined"
|
||||
@click="menuOpen = false"
|
||||
>
|
||||
<span class="admin-nav-icon" aria-hidden="true">{{ item.icon }}</span>
|
||||
|
||||
@@ -176,6 +176,9 @@ type AdminPublicUser = {
|
||||
type AdminProfile = {
|
||||
profileName: string;
|
||||
profile: string;
|
||||
instanceKey: string;
|
||||
currentScenario: string | null;
|
||||
/** @deprecated Rollback-compatible mirror of currentScenario. */
|
||||
scenario: string;
|
||||
status: string;
|
||||
apiPort: number;
|
||||
@@ -2058,9 +2061,14 @@ onMounted(() => {
|
||||
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-2">
|
||||
<div>
|
||||
<div class="text-base font-semibold">
|
||||
{{ profile.profileName }} ({{ profile.profile }})
|
||||
{{ profile.meta.korName ?? profile.profile }}
|
||||
</div>
|
||||
<div class="text-xs text-zinc-500">
|
||||
서버 ID: {{ profile.profileName }} · 인스턴스: {{ profile.instanceKey }}
|
||||
</div>
|
||||
<div class="text-xs text-zinc-500">
|
||||
현재 시나리오: {{ profile.currentScenario ?? '미설정' }}
|
||||
</div>
|
||||
<div class="text-xs text-zinc-500">시나리오: {{ profile.scenario }}</div>
|
||||
</div>
|
||||
<div class="text-xs text-zinc-400">
|
||||
상태: {{ profile.status }} / API: {{ profile.runtime.apiRunning ? 'ON' : 'OFF' }} /
|
||||
|
||||
@@ -31,6 +31,10 @@ Gateway 관리자 콘솔은 `/gateway/admin`에서 시작합니다. 공개 로
|
||||
- 서버 관리는 profile별 하위 트리입니다. 상태·설정, DB 보존 버전 업데이트와
|
||||
시나리오 초기화가 같은 서버 아래의 상단 탭으로 노출됩니다. 현재 탭은 색상과
|
||||
`aria-current`로 구분하며 desktop과 mobile에서 본문보다 먼저 표시합니다.
|
||||
- `profileName`은 `${profile}:${instanceKey}` 형식의 불변 기술 ID입니다.
|
||||
`che:default`의 `default`는 현재 시나리오가 아니라 기본 인스턴스 키입니다.
|
||||
좌측 메뉴는 기본 인스턴스의 suffix를 숨기고 표시명만 보여 주며, 상태 상세에서
|
||||
기술 ID·인스턴스 키·nullable 현재 시나리오를 분리해 확인할 수 있습니다.
|
||||
- 버전 업데이트와 시나리오 초기화 route는 URL의 `profileName`으로 대상 서버가
|
||||
이미 고정됩니다. 따라서 작업 화면에서 전체 profile 목록이나 중복 실행 상태를
|
||||
기다리지 않고 작업 form과 해당 서버의 operation 이력을 먼저 표시합니다. 상세
|
||||
@@ -45,7 +49,7 @@ Gateway 관리자 콘솔은 `/gateway/admin`에서 시작합니다. 공개 로
|
||||
업데이트가 필요하지 않습니다. 새 branch/commit과 함께 초기화하려면 초기화
|
||||
권한과 버전 배포 권한이 모두 필요합니다.
|
||||
- 현재 배포 버전의 시나리오 catalog는 capability·operation polling batch와
|
||||
분리된 요청으로 읽습니다. API가 profile의 현재 scenario를 표시하며 화면은
|
||||
분리된 요청으로 읽습니다. API가 profile의 `currentScenario`를 표시하며 화면은
|
||||
그 항목을 기본 선택합니다. scenario ID `0`도 유효한 값이고, 초기 요청이
|
||||
실패하면 현재 버전 모드에서 다시 확인할 수 있습니다.
|
||||
- 서버 상태의 `서버 리셋 기본 옵션`은 `GatewayProfile.meta.resetDefaults`에
|
||||
|
||||
@@ -131,6 +131,13 @@ Gateway는 자기 process를 직접 교체하지 않습니다. 관리자 화면
|
||||
확인합니다.
|
||||
6. 모두 준비된 경우에만 현재·이전 commit과 workspace를 게시합니다.
|
||||
|
||||
Gateway migration은 앱 rollback 때 자동으로 역방향 적용되지 않습니다. 따라서
|
||||
profile identity 분리의 첫 단계는 기존 `profile_name`과 legacy `scenario`를 유지한
|
||||
채 `instance_key`와 nullable `current_scenario`를 추가합니다. DB trigger가 구버전의
|
||||
`scenario` write와 신버전의 `current_scenario` write를 양방향 동기화하므로 migration
|
||||
적용 뒤 readiness가 실패해 직전 Gateway worktree로 돌아가도 기존 DB와 시즌을
|
||||
그대로 사용할 수 있습니다.
|
||||
|
||||
release-controller는 PM2 process 안에서 실행되므로 부모의 `args`, `pm_id`,
|
||||
`pm_exec_path`, `name`, `NODE_APP_INSTANCE`와 `axm_*` 같은 PM2 내부 값을 자식
|
||||
환경으로 전달하지 않습니다. 특히 부모의 `args=daemon`이 frontend의
|
||||
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
-- Keep profile_name stable because it is referenced by operations, runtime
|
||||
-- actions, permission scopes, process names, Redis namespaces, and routes.
|
||||
-- instance_key identifies the immutable slot while current_scenario records the
|
||||
-- mutable game selection. The legacy scenario column remains during the
|
||||
-- expansion phase so the previous Gateway release can still be restored.
|
||||
ALTER TABLE "gateway_profile"
|
||||
ADD COLUMN "instance_key" TEXT,
|
||||
ADD COLUMN "current_scenario" TEXT;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM "gateway_profile"
|
||||
WHERE left("profile_name", length("profile") + 1) <> "profile" || ':'
|
||||
) THEN
|
||||
RAISE EXCEPTION 'gateway_profile.profile_name must start with profile followed by a colon';
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
|
||||
UPDATE "gateway_profile"
|
||||
SET
|
||||
"instance_key" = substring("profile_name" FROM length("profile") + 2),
|
||||
"current_scenario" = NULLIF("scenario", 'default');
|
||||
|
||||
ALTER TABLE "gateway_profile"
|
||||
ALTER COLUMN "instance_key" SET NOT NULL;
|
||||
|
||||
DROP INDEX "gateway_profile_profile_scenario_key";
|
||||
|
||||
ALTER TABLE "gateway_profile"
|
||||
ADD CONSTRAINT "gateway_profile_profile_instance_key_key" UNIQUE ("profile", "instance_key"),
|
||||
ADD CONSTRAINT "gateway_profile_identity_check"
|
||||
CHECK (
|
||||
length("instance_key") > 0
|
||||
AND "profile_name" = "profile" || ':' || "instance_key"
|
||||
);
|
||||
|
||||
CREATE FUNCTION "sync_gateway_profile_scenario_compat"()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
BEGIN
|
||||
IF NEW."instance_key" IS NULL THEN
|
||||
IF left(NEW."profile_name", length(NEW."profile") + 1) <> NEW."profile" || ':' THEN
|
||||
RAISE EXCEPTION 'gateway_profile.profile_name must start with profile followed by a colon';
|
||||
END IF;
|
||||
NEW."instance_key" := substring(NEW."profile_name" FROM length(NEW."profile") + 2);
|
||||
END IF;
|
||||
|
||||
IF TG_OP = 'INSERT' THEN
|
||||
IF NEW."current_scenario" IS NULL THEN
|
||||
NEW."current_scenario" := NULLIF(NEW."scenario", 'default');
|
||||
ELSE
|
||||
NEW."scenario" := NEW."current_scenario";
|
||||
END IF;
|
||||
ELSIF NEW."current_scenario" IS DISTINCT FROM OLD."current_scenario" THEN
|
||||
NEW."scenario" := COALESCE(NEW."current_scenario", 'default');
|
||||
ELSIF NEW."scenario" IS DISTINCT FROM OLD."scenario" THEN
|
||||
NEW."current_scenario" := NULLIF(NEW."scenario", 'default');
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END
|
||||
$$;
|
||||
|
||||
CREATE TRIGGER "gateway_profile_scenario_compat"
|
||||
BEFORE INSERT OR UPDATE ON "gateway_profile"
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION "sync_gateway_profile_scenario_compat"();
|
||||
@@ -208,6 +208,10 @@ model LegacyRootKeyValue {
|
||||
model GatewayProfile {
|
||||
profileName String @id @map("profile_name")
|
||||
profile String
|
||||
instanceKey String @map("instance_key")
|
||||
currentScenario String? @map("current_scenario")
|
||||
/// Legacy compatibility mirror. The database trigger keeps this synchronized
|
||||
/// with currentScenario while the previous Gateway release remains rollbackable.
|
||||
scenario String
|
||||
apiPort Int @map("api_port")
|
||||
status GatewayProfileStatus
|
||||
@@ -229,7 +233,7 @@ model GatewayProfile {
|
||||
operations GatewayOperation[]
|
||||
runtimeActions GatewayRuntimeAction[]
|
||||
|
||||
@@unique([profile, scenario])
|
||||
@@unique([profile, instanceKey])
|
||||
@@map("gateway_profile")
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"formatVersion": 1,
|
||||
"controllerProtocol": 2,
|
||||
"gatewaySchemaHead": "20260811000000_add_gateway_operation_logs",
|
||||
"gatewaySchemaHead": "20260813000000_split_gateway_profile_identity",
|
||||
"gameSchemaHead": "20260803000000_add_logical_game_clock",
|
||||
"components": ["gateway-api", "gateway-frontend", "release-controller", "game-api", "game-engine", "game-frontend"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user