Merge remote-tracking branch 'origin/main' into feature/nation-general-controls-20260813

This commit is contained in:
2026-08-13 16:13:25 +00:00
44 changed files with 1718 additions and 574 deletions
+30 -2
View File
@@ -137,7 +137,24 @@ export const tournamentRouter = router({
store.getMatches(), store.getMatches(),
store.getBettingEntries(), 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 }) => { getRankings: authedProcedure.query(async ({ ctx }) => {
await getMyGeneral(ctx); await getMyGeneral(ctx);
@@ -177,7 +194,16 @@ export const tournamentRouter = router({
} }
const generals = await ctx.db.general.findMany({ const generals = await ctx.db.general.findMany({
where: { id: { in: [...rankMap.keys()] } }, 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) => { return tournamentRankTypes.map((prefix) => {
@@ -201,6 +227,8 @@ export const tournamentRouter = router({
generalId: general.id, generalId: general.id,
name: general.name, name: general.name,
npcState: general.npcState, npcState: general.npcState,
picture: general.picture,
imageServer: general.imageServer,
stat, stat,
games: win + draw + lose, games: win + draw + lose,
win, win,
@@ -84,6 +84,8 @@ const buildGeneral = (id: number, userId: string, gold = 2_000): GeneralRow =>
id, id,
userId, userId,
name: `장수${id}`, name: `장수${id}`,
picture: `${id}.jpg`,
imageServer: id % 2,
leadership: 70 + id, leadership: 70 + id,
strength: 60 + id, strength: 60 + id,
intel: 50 + id, intel: 50 + id,
@@ -296,6 +298,10 @@ describe('tournament router permissions and mutations', () => {
const sections = await ownerCaller.tournament.getRankings(); const sections = await ownerCaller.tournament.getRankings();
expect(sections).toHaveLength(4); expect(sections).toHaveLength(4);
expect(sections[0]?.entries.map((entry) => entry.generalId)).toEqual([second.id, first.id]); 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( const generalLessCaller = appRouter.createCaller(
buildContext({ redis, transport, generals: [first, second], userId: 'user-3', rankRows }) 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' }); 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 () => { it('refunds gold when the tournament bet rank update fails', async () => {
const redis = new MemoryRedis(); const redis = new MemoryRedis();
const transport = new TournamentTransport(); const transport = new TournamentTransport();
@@ -50,6 +50,8 @@ integration('gateway runtime action consumer', () => {
create: { create: {
profileName, profileName,
profile: 'runtime', profile: 'runtime',
instanceKey: 'consumer-integration',
currentScenario: 'consumer-integration',
scenario: 'consumer-integration', scenario: 'consumer-integration',
apiPort: 15998, apiPort: 15998,
status: 'RUNNING', status: 'RUNNING',
+62 -13
View File
@@ -1,6 +1,6 @@
import { mkdir, readFile, writeFile } from 'node:fs/promises'; import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { basename, resolve } from 'node:path'; 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'; import { gameBasePath, gameProfile, gameTrpcRoute } from './gameTestPaths.js';
const response = (data: unknown) => ({ result: { data } }); 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 = { type FixtureState = {
permission: 'head' | 'member'; permission: 'head' | 'member';
myset: number; myset: number;
@@ -545,9 +562,15 @@ test('재야 메인은 국가 틀과 성격·특기 표기명을 Chromium에 표
const generalCard = page.locator('.general-card'); 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).toContainText('내정특기상재');
await expect(generalCard).not.toContainText('che_'); 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 geometry = await nationCard.evaluate((element) => {
const rect = element.getBoundingClientRect(); const rect = element.getBoundingClientRect();
@@ -587,7 +610,7 @@ test('메인 카드의 국가·수도·관직·계급·병종은 Ref 출력명
await expect(nationCard).toContainText('국가 등급주자사'); await expect(nationCard).toContainText('국가 등급주자사');
const generalCard = page.locator('.general-card'); 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('병종보병');
await expect(generalCard).toContainText('계급 29품관'); await expect(generalCard).toContainText('계급 29품관');
@@ -646,11 +669,19 @@ test('접속량정보 keeps the legacy public 1016px chart geometry', async ({ p
expect(mobileWidth).toBe(1016); 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: [] }; const state: FixtureState = { permission: 'head', myset: 3, settingMutations: [], accessPages: [] };
await install(page, state); await install(page, state);
await page.setViewportSize({ width: 1000, height: 900 }); await page.setViewportSize({ width: 1000, height: 900 });
await page.goto('my-page'); 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('계급 29품관');
await expect(page.locator('.legacy-general-details')).toContainText('병종 보병'); await expect(page.locator('.legacy-general-details')).toContainText('병종 보병');
await expect(page.locator('.item-group')).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.width).toBe(1000);
expect(desktop.minWidth).toBe('500px'); expect(desktop.minWidth).toBe('0px');
expect(desktop.fontSize).toBe('14px'); expect(desktop.fontSize).toBe('14px');
expect(desktop.columns.split(' ')).toHaveLength(2); expect(desktop.columns.split(' ')).toHaveLength(2);
expect(desktop.titleHeight).toBeCloseTo(54, 0); 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'); 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(); await page.reload();
const mobile = await page.locator('#container').evaluate((element) => { const mobile = await page.locator('#container').evaluate((element) => {
const rect = element.getBoundingClientRect(); const rect = element.getBoundingClientRect();
const settings = element.querySelector<HTMLElement>('.settings-column')!.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 { return {
width: rect.width, width: rect.width,
scrollWidth: document.documentElement.scrollWidth, scrollWidth: document.documentElement.scrollWidth,
columns: getComputedStyle(element.querySelector('.top-grid')!).gridTemplateColumns, columns: getComputedStyle(element.querySelector('.top-grid')!).gridTemplateColumns,
settingsOffset: settings.x - rect.x, settingsOffset: settings.x - rect.x,
settingsWidth: settings.width, 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({ expect(mobile).toMatchObject({
width: 500, width: 390,
scrollWidth: 500, scrollWidth: 390,
columns: '500px', columns: '390px',
settingsOffset: 0, 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); 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 expect(page.locator('.selector-row select').nth(1)).toHaveValue('8');
await page.getByRole('button', { name: '다음 ▶' }).click(); await page.getByRole('button', { name: '다음 ▶' }).click();
await expect(page.locator('.selector-row select').nth(1)).toHaveValue('7'); 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('계급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')).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 [role="progressbar"]')).toHaveCount(14);
await expect(page.locator('.battle-general-card [aria-label*="1,275,975 (EX+)"]')).toHaveCount(5); await expect(page.locator('.battle-general-card [aria-label*="1,275,975 (EX+)"]')).toHaveCount(5);
expect( expect(
+50 -1
View File
@@ -514,7 +514,7 @@ const installRealtimeHarness = async (page: Page) => {
const waitForMain = async (page: Page) => { const waitForMain = async (page: Page) => {
await page.goto('./'); 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-global-menu').first()).toBeVisible();
await expect(page.locator('.main-nation-menu')).toBeVisible(); await expect(page.locator('.main-nation-menu')).toBeVisible();
await expect(page.locator('[data-navigation-id="npc-list"]')).toHaveCount(3); 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'), globalPopup: describe('#mobile-global-menu'),
nationPopup: describe('#mobile-nation-menu'), nationPopup: describe('#mobile-nation-menu'),
quickPopup: describe('#mobile-quick-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([ await Promise.all([
page.screenshot({ path: resolve(target, `${name}.png`), fullPage: true }), page.screenshot({ path: resolve(target, `${name}.png`), fullPage: true }),
writeFile(resolve(target, `${name}.json`), `${JSON.stringify(geometry, null, 2)}\n`), 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(); await tenthTurnButton.click();
const quickPicker = page.getByTestId('command-picker'); const quickPicker = page.getByTestId('command-picker');
await expect(quickPicker).toBeVisible(); 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 quickPickerAlignment = await quickPicker.evaluate((element) => {
const row = element const row = element
.closest('.reserved-command-editor') .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.rangeTop).toBe(advancedControlGeometry.recentTop);
expect(advancedControlGeometry.advancedTop).toBeGreaterThan(advancedControlGeometry.rangeTop); expect(advancedControlGeometry.advancedTop).toBeGreaterThan(advancedControlGeometry.rangeTop);
expect(advancedControlGeometry.advancedBottom).toBeLessThanOrEqual(advancedControlGeometry.queueTop); 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(); await page.locator('[data-main-target="commands"] .select-command').click();
const picker = page.getByTestId('command-picker'); const picker = page.getByTestId('command-picker');
await expect(picker).toBeVisible(); 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(mobileGeometry.controlBoxes).toHaveLength(3);
expect(new Set(mobileGeometry.controlBoxes.map(({ y }) => y)).size).toBe(1); expect(new Set(mobileGeometry.controlBoxes.map(({ y }) => y)).size).toBe(1);
await expect(page.locator('[data-main-target="commands"] .edit-column button')).toHaveCount(30); 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'); await captureProgress('mobile-500');
}); });
+130 -6
View File
@@ -1,15 +1,22 @@
import { expect, test, type Page, type Route } from '@playwright/test'; 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 { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { gameProfile, gameTrpcRoute } from './gameTestPaths.js'; import { gameProfile, gameTrpcRoute } from './gameTestPaths.js';
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../..'); const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../..');
const responsiveArtifactDir = process.env.TOURNAMENT_RESPONSIVE_ARTIFACT_DIR;
const imageRoots = [ const imageRoots = [
...(process.env.FRONTEND_PARITY_IMAGE_ROOT ? [resolve(process.env.FRONTEND_PARITY_IMAGE_ROOT, 'game')] : []), ...(process.env.FRONTEND_PARITY_IMAGE_ROOT ? [resolve(process.env.FRONTEND_PARITY_IMAGE_ROOT, 'game')] : []),
resolve(repositoryRoot, '../image/game'), resolve(repositoryRoot, '../image/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 = [ const names = [
'관우', '관우',
'장료', '장료',
@@ -35,6 +42,8 @@ const participants = names.map((name, index) => ({
strength: 80, strength: 80,
intel: 80, intel: 80,
level: 10, level: 10,
picture: 'default.jpg',
imageServer: 0,
groupId: 10 + (index % 8), groupId: 10 + (index % 8),
groupNo: Math.floor(index / 8), groupNo: Math.floor(index / 8),
win: 3 - (index % 2), win: 3 - (index % 2),
@@ -88,6 +97,26 @@ const readReferenceImage = async (filename: string): Promise<Buffer> => {
throw new Error(`Reference image not found: ${filename}`); 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) => { const installFixture = async (page: Page) => {
await page.addInitScript((profile) => { await page.addInitScript((profile) => {
window.localStorage.setItem('sammo-game-token', 'ga_tournament_bracket_playwright'); 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 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) => { await page.route(gameTrpcRoute, async (route) => {
const results = operationNames(route).map((operation) => { const results = operationNames(route).map((operation) => {
if (operation === 'auth.status') return response({ ok: true }); if (operation === 'auth.status') return response({ ok: true });
@@ -125,12 +157,43 @@ const installFixture = async (page: Page) => {
} }
if (operation === 'tournament.getBettingSummary') { if (operation === 'tournament.getBettingSummary') {
return response({ 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: {}, myTotals: {},
totalAmount: 2800, totalAmount: 2800,
myAmount: 0, 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); return response(null);
}); });
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(results) }); 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, connectorCenter: firstConnector.x + firstConnector.width / 2,
championCenter: champion.x + champion.width / 2, championCenter: champion.x + champion.width / 2,
finalistCenters: finalists.map((rect) => rect.x + rect.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(Math.abs(geometry.connectorCenter - geometry.championCenter)).toBeLessThan(1);
expect(geometry.finalistCenters).toHaveLength(2); expect(geometry.finalistCenters).toHaveLength(2);
expect(Math.abs(geometry.finalistCenters[0]! - geometry.connectorQuarters[0]!)).toBeLessThan(1); expect(Math.abs(geometry.finalistCenters[0]! - geometry.connectorQuarters[0]!)).toBeLessThan(1);
expect(Math.abs(geometry.finalistCenters[1]! - geometry.connectorQuarters[1]!)).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) => { 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.width).toBe(390);
expect(bounds.minX).toBeGreaterThanOrEqual(0); expect(bounds.minX).toBeGreaterThanOrEqual(0);
expect(bounds.maxX).toBeLessThanOrEqual(390); 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'));
}); });
+7
View File
@@ -39,6 +39,13 @@ body {
min-width: 500px; 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(.battle-page),
body:has(.chief-page), body:has(.chief-page),
body:has(.global-page), body:has(.global-page),
@@ -149,6 +149,14 @@ const closePicker = () => {
quickTarget.value = null; quickTarget.value = null;
selectedCommand.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 selectCommand = (commandKey: string) => {
const command = props.commandTable?.[props.scope] const command = props.commandTable?.[props.scope]
.flatMap((group) => group.values) .flatMap((group) => group.values)
@@ -305,6 +313,7 @@ const clickOutsideMenu = (event: Event) => {
> >
짝수턴 짝수턴
</button> </button>
<hr class="menu-divider" />
<template v-for="step in [3, 4, 5, 6, 7]" :key="step"> <template v-for="step in [3, 4, 5, 6, 7]" :key="step">
<small>{{ step }} 간격</small> <small>{{ step }} 간격</small>
<div class="step-buttons"> <div class="step-buttons">
@@ -429,6 +438,7 @@ const clickOutsideMenu = (event: Event) => {
> >
붙여넣기 붙여넣기
</button> </button>
<hr class="menu-divider" />
<button <button
@click=" @click="
textCopy(); textCopy();
@@ -437,6 +447,7 @@ const clickOutsideMenu = (event: Event) => {
> >
텍스트 복사 텍스트 복사
</button> </button>
<hr class="menu-divider" />
<button <button
@click=" @click="
saveTemplate(); saveTemplate();
@@ -453,6 +464,7 @@ const clickOutsideMenu = (event: Event) => {
> >
반복하기 반복하기
</button> </button>
<hr class="menu-divider" />
<button <button
@click=" @click="
clearSelection(); clearSelection();
@@ -479,7 +491,7 @@ const clickOutsideMenu = (event: Event) => {
</button> </button>
</div> </div>
</details> </details>
<button type="button" class="select-command" @click="openPicker()">명령 선택 </button> <button type="button" class="select-command" @click="togglePicker()">명령 선택 </button>
</div> </div>
<div class="queue-area"> <div class="queue-area">
@@ -542,7 +554,7 @@ const clickOutsideMenu = (event: Event) => {
:key="row.index" :key="row.index"
type="button" type="button"
:aria-label="`${row.index + 1} 명령 입력`" :aria-label="`${row.index + 1} 명령 입력`"
@click="openPicker(row.index)" @click="togglePicker(row.index)"
> >
</button> </button>
@@ -708,6 +720,14 @@ const clickOutsideMenu = (event: Event) => {
padding: 5px 8px; padding: 5px 8px;
color: #bbb; color: #bbb;
} }
.menu-divider {
width: 100%;
height: 0;
margin: 4px 0;
border: 0;
border-top: 1px solid #444;
opacity: 1;
}
.step-buttons, .step-buttons,
.template-row { .template-row {
display: flex; display: flex;
@@ -1,9 +1,12 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue'; import { computed } from 'vue';
import SkeletonLines from '../ui/SkeletonLines.vue'; import SkeletonLines from '../ui/SkeletonLines.vue';
import LegacyProgressBar from '../ui/LegacyProgressBar.vue'; import LegacyProgressBar from '../ui/LegacyProgressBar.vue';
import { formatSeoulHourMinute } from '../../utils/legacyDateTime'; import { formatSeoulHourMinute } from '../../utils/legacyDateTime';
import { legacyExperiencePercent, ratioPercent } from '../../utils/legacyProgress'; import { legacyExperiencePercent, ratioPercent } from '../../utils/legacyProgress';
import { DEFAULT_GENERAL_ICON_URL, resolveGeneralIconBackgroundImage } from '../../utils/generalIcon';
import { configuredGameAssetUrl } from '../../utils/imageAssets';
interface GeneralStats { interface GeneralStats {
leadership: number; leadership: number;
@@ -19,9 +22,18 @@ interface GeneralProgression {
statUpgradeLimit?: number; statUpgradeLimit?: number;
} }
interface ItemDisplayNames {
horse?: string | null;
weapon?: string | null;
book?: string | null;
item?: string | null;
}
interface GeneralInfo { interface GeneralInfo {
id: number; id: number;
name: string; name: string;
picture?: string | null;
imageServer?: number | null;
npcState: number; npcState: number;
officerLevel: number; officerLevel: number;
officerLevelText: string; officerLevelText: string;
@@ -35,17 +47,36 @@ interface GeneralInfo {
experience: number; experience: number;
dedication: number; dedication: number;
age?: number; age?: number;
turnTime?: string; turnTime?: string | null;
troopId?: number;
crewTypeId?: number; crewTypeId?: number;
crewTypeName?: string; crewTypeName?: string;
traits?: { personal: string; specialWar: string; specialDomestic: string }; traits?: { personal: string; specialWar: string; specialDomestic: string };
progression?: GeneralProgression; progression?: GeneralProgression;
itemNames?: ItemDisplayNames;
equipmentNames?: ItemDisplayNames;
} }
const props = defineProps<{ const props = withDefaults(
defineProps<{
general: GeneralInfo | null; general: GeneralInfo | null;
loading: boolean; 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 statRows = computed(() => {
const general = props.general; const general = props.general;
@@ -72,137 +103,320 @@ const statRows = computed(() => {
const experiencePercent = computed(() => const experiencePercent = computed(() =>
legacyExperiencePercent(props.general?.experience ?? 0, props.general?.progression?.experienceLevel ?? 0) 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> </script>
<template> <template>
<div class="general-card"> <div class="general-card" data-general-basic-card>
<div v-if="props.loading"> <div v-if="props.loading" class="general-loading">
<SkeletonLines :lines="5" /> <SkeletonLines :lines="5" />
</div> </div>
<div v-else-if="!props.general" class="empty">장수 정보를 불러오지 못했습니다.</div> <div v-else-if="!props.general" class="empty">장수 정보를 불러오지 못했습니다.</div>
<div v-else class="general-body"> <template v-else>
<div class="general-title"> <div class="general-basic-grid general-body">
{{ props.general.name }} · {{ props.general.officerLevelText }} · {{ props.general.age ?? '-' }} · <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) : '-' }} {{ props.general.turnTime ? formatSeoulHourMinute(props.general.turnTime) : '-' }}
</div> </div>
<div class="stat-progress-grid">
<template v-for="stat of statRows" :key="stat.key"> <template v-for="stat of statRows" :key="stat.key">
<span class="cell-label">{{ stat.label }}</span> <span class="cell-label">{{ stat.label }}</span>
<strong>{{ stat.value }}</strong> <strong class="stat-value">
<div class="bar-cell" :data-stat-progress="stat.key"> <span>{{ stat.value }}</span>
<span class="bar-cell" :data-stat-progress="stat.key">
<LegacyProgressBar <LegacyProgressBar
:percent="stat.percent" :percent="stat.percent"
:label="`${stat.label} 성장 ${stat.accumulated} / ${stat.limit}`" :label="`${stat.label} 성장 ${stat.accumulated} / ${stat.limit}`"
/> />
</div> </span>
</strong>
</template> </template>
</div>
<div class="legacy-grid"> <span class="cell-label">명마</span><strong>{{ itemNames.horse ?? '-' }}</strong>
<span>자금</span><strong>{{ props.general.gold.toLocaleString() }}</strong> <span>군량</span <span class="cell-label">무기</span><strong>{{ itemNames.weapon ?? '-' }}</strong>
><strong>{{ props.general.rice.toLocaleString() }}</strong> <span>병력</span <span class="cell-label">서적</span><strong>{{ itemNames.book ?? '-' }}</strong>
><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>
<div class="experience-row"> <span
<span class="cell-label">Lv</span> class="general-image general-crew-type-icon"
<strong>{{ props.general.progression?.experienceLevel ?? 0 }}</strong> role="img"
<div class="bar-cell" data-experience-progress> :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 <LegacyProgressBar
:percent="experiencePercent" :percent="experiencePercent"
:label="`경험 레벨 진행 ${experiencePercent.toFixed(1)}%`" :label="`경험 레벨 진행 ${experiencePercent.toFixed(1)}%`"
/> />
</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>
<span class="experience-total">명성 {{ props.general.experience.toLocaleString() }}</span> <slot name="details" />
</div> </template>
</div>
</div> </div>
</template> </template>
<style scoped> <style scoped>
.general-title { .general-card {
box-sizing: border-box; box-sizing: border-box;
height: 20px; width: 100%;
min-height: 20px; min-width: 0;
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;
overflow: hidden; 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; white-space: nowrap;
} }
.cell-label, .general-basic-grid > strong {
.legacy-grid > span { font-weight: 500;
background: rgb(20 75 42 / 70%);
text-align: center; text-align: center;
} }
.stat-progress-grid > strong, .cell-label {
.legacy-grid > strong { background-color: rgb(20 75 42 / 70%);
text-align: right;
font-weight: 400;
} }
.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; display: grid;
align-content: center; align-content: center;
padding: 0 1px; padding: 0 1px;
} }
.legacy-grid { .general-crew-type-icon {
display: grid; grid-column: 1;
grid-template-columns: repeat(6, minmax(0, 1fr)); grid-row: 4 / 7;
grid-auto-rows: 21px;
font-size: 12px;
} }
.experience-row { .level-label {
display: grid; grid-column: 1;
box-sizing: border-box; grid-row: 7;
grid-template-columns: 32px 38px minmax(120px, 1fr) 112px;
height: 20px;
min-height: 20px;
border-bottom: 1px solid #666;
font-size: 12px;
} }
.experience-row > * { .level-value {
display: grid; grid-column: 2;
align-content: center; grid-row: 7;
box-sizing: border-box; }
border-right: 1px solid #666;
padding: 1px 4px; .experience-bar {
text-align: center; 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 { .empty {
@@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue'; import { computed } from 'vue';
import GeneralIdentity from '../ui/GeneralIdentity.vue';
import { import {
buildTournamentBracket, buildTournamentBracket,
type TournamentBracketMatch, type TournamentBracketMatch,
@@ -89,7 +90,12 @@ const odds = (id: number | null) => {
:class="{ advanced: bracket.champion.advanced }" :class="{ advanced: bracket.champion.advanced }"
:data-general-id="bracket.champion.id ?? undefined" :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> </span>
</div> </div>
@@ -110,7 +116,12 @@ const odds = (id: number | null) => {
:class="{ advanced: slot.advanced }" :class="{ advanced: slot.advanced }"
:data-general-id="slot.id ?? undefined" :data-general-id="slot.id ?? undefined"
> >
{{ slot.name }} <GeneralIdentity
:name="slot.name"
:picture="slot.picture"
:image-server="slot.imageServer"
:icon-size="22"
/>
</span> </span>
</div> </div>
<div class="connector-row" :style="{ '--connector-count': round.slots.length }"> <div class="connector-row" :style="{ '--connector-count': round.slots.length }">
@@ -140,7 +151,12 @@ const odds = (id: number | null) => {
:class="{ advanced: slot.advanced }" :class="{ advanced: slot.advanced }"
:data-general-id="slot.id ?? undefined" :data-general-id="slot.id ?? undefined"
> >
{{ slot.name }} <GeneralIdentity
:name="slot.name"
:picture="slot.picture"
:image-server="slot.imageServer"
:icon-size="20"
/>
</span> </span>
</div> </div>
<div class="bracket-round bracket-odds" :style="roundStyle(bracket.top16)"> <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}`" :key="`mobile-${columnIndex}-${slot.id ?? 'empty'}-${slotIndex}`"
class="mobile-bracket-name" class="mobile-bracket-name"
:class="{ advanced: slot.advanced }" :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> </span>
</template> </template>
</div> </div>
@@ -210,21 +234,23 @@ const odds = (id: number | null) => {
white-space: nowrap; white-space: nowrap;
} }
.bracket-canvas { .bracket-canvas {
width: 2000px; width: 100%;
min-width: 2000px; min-width: 1000px;
max-width: 1200px;
margin: 0 auto; margin: 0 auto;
} }
.mobile-bracket { .mobile-bracket {
position: relative; position: relative;
display: none; display: none;
width: 390px; width: 100%;
max-width: 390px;
height: 544px; height: 544px;
margin: 0 auto; margin: 0 auto;
} }
.mobile-bracket svg { .mobile-bracket svg {
position: absolute; position: absolute;
inset: 0; inset: 0;
width: 390px; width: 100%;
height: 544px; height: 544px;
} }
.mobile-connector { .mobile-connector {
@@ -239,14 +265,16 @@ const odds = (id: number | null) => {
.mobile-bracket-name { .mobile-bracket-name {
position: absolute; position: absolute;
z-index: 1; z-index: 1;
width: 64px; width: clamp(58px, 18vw, 72px);
overflow: hidden; overflow: hidden;
transform: translate(-50%, -50%); transform: translate(-50%, -50%);
border: 1px solid #555; border: 1px solid #555;
background: rgb(58 33 24 / 92%); background: rgb(58 33 24 / 92%);
color: #fff; color: #fff;
font-size: 12px; min-height: 26px;
line-height: 22px; padding: 2px;
font-size: 11px;
line-height: 20px;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
@@ -265,7 +293,7 @@ const odds = (id: number | null) => {
} }
.bracket-name { .bracket-name {
overflow: hidden; overflow: hidden;
padding: 0 3px; padding: 2px 3px;
color: #fff; color: #fff;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
@@ -321,8 +349,8 @@ const odds = (id: number | null) => {
} }
@media (max-width: 800px) { @media (max-width: 800px) {
.tournament-bracket { .tournament-bracket {
width: 100vw; width: 100%;
max-width: 100vw; max-width: 100%;
overflow-x: hidden; overflow-x: hidden;
} }
.bracket-canvas { .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(() => const statRows = computed(() =>
[ [
@@ -50,7 +52,7 @@ const experiencePercent = computed(() =>
<template> <template>
<div class="legacy-general-progress"> <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"> <template v-for="stat of statRows" :key="stat.key">
<span class="cell-label">{{ stat.label }}</span> <span class="cell-label">{{ stat.label }}</span>
<strong>{{ stat.value }}</strong> <strong>{{ stat.value }}</strong>
@@ -60,7 +62,7 @@ const experiencePercent = computed(() =>
/> />
</template> </template>
</div> </div>
<div class="experience-row"> <div v-if="props.showPrimary" class="experience-row">
<span class="cell-label">Lv</span> <span class="cell-label">Lv</span>
<strong>{{ props.general.progression.experienceLevel }}</strong> <strong>{{ props.general.progression.experienceLevel }}</strong>
<LegacyProgressBar <LegacyProgressBar
@@ -1,6 +1,8 @@
export interface TournamentBracketParticipant { export interface TournamentBracketParticipant {
id: number; id: number;
name: string; name: string;
picture?: string | null;
imageServer?: number | null;
} }
export interface TournamentBracketMatch { export interface TournamentBracketMatch {
@@ -15,6 +17,8 @@ export interface TournamentBracketMatch {
export interface TournamentBracketSlot { export interface TournamentBracketSlot {
id: number | null; id: number | null;
name: string; name: string;
picture: string | null;
imageServer: number;
advanced: boolean; advanced: boolean;
} }
@@ -31,7 +35,13 @@ export interface TournamentBracketModel {
top16: TournamentBracketRound; 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 = ( export const buildTournamentBracket = (
participants: TournamentBracketParticipant[], participants: TournamentBracketParticipant[],
@@ -39,19 +49,24 @@ export const buildTournamentBracket = (
winnerId?: number winnerId?: number
): TournamentBracketModel => { ): TournamentBracketModel => {
const participantsById = new Map(participants.map((participant) => [participant.id, participant])); const participantsById = new Map(participants.map((participant) => [participant.id, participant]));
const nameOf = (id: number | null): string => const participantOf = (id: number | null): TournamentBracketParticipant | null =>
id === null ? '-' : (participantsById.get(id)?.name ?? `#${id}`); id === null ? null : (participantsById.get(id) ?? { id, name: `#${id}` });
const buildRound = (stage: number, slotCount: number): TournamentBracketRound => { const buildRound = (stage: number, slotCount: number): TournamentBracketRound => {
const roundMatches = matches const roundMatches = matches
.filter((match) => match.stage === stage) .filter((match) => match.stage === stage)
.sort((lhs, rhs) => lhs.roundIndex - rhs.roundIndex || lhs.id - rhs.id); .sort((lhs, rhs) => lhs.roundIndex - rhs.roundIndex || lhs.id - rhs.id);
const slots: TournamentBracketSlot[] = roundMatches.flatMap((match) => const slots: TournamentBracketSlot[] = roundMatches.flatMap((match) =>
[match.attackerId, match.defenderId].map((id) => ({ [match.attackerId, match.defenderId].map((id) => {
const participant = participantOf(id);
return {
id, id,
name: nameOf(id), name: participant?.name ?? '-',
picture: participant?.picture ?? null,
imageServer: participant?.imageServer ?? 0,
advanced: match.winnerId === id, advanced: match.winnerId === id,
})) };
})
); );
while (slots.length < slotCount) { while (slots.length < slotCount) {
slots.push(emptySlot()); slots.push(emptySlot());
@@ -65,7 +80,9 @@ export const buildTournamentBracket = (
return { return {
champion: { champion: {
id: resolvedWinnerId, id: resolvedWinnerId,
name: nameOf(resolvedWinnerId), name: participantOf(resolvedWinnerId)?.name ?? '-',
picture: participantOf(resolvedWinnerId)?.picture ?? null,
imageServer: participantOf(resolvedWinnerId)?.imageServer ?? 0,
advanced: resolvedWinnerId !== null, advanced: resolvedWinnerId !== null,
}, },
final, final,
@@ -5,10 +5,10 @@ import { useRoute } from 'vue-router';
import PanelCard from '../components/ui/PanelCard.vue'; import PanelCard from '../components/ui/PanelCard.vue';
import SkeletonLines from '../components/ui/SkeletonLines.vue'; import SkeletonLines from '../components/ui/SkeletonLines.vue';
import LegacyGeneralProgress from '../components/ui/LegacyGeneralProgress.vue'; import LegacyGeneralProgress from '../components/ui/LegacyGeneralProgress.vue';
import GeneralBasicCard from '../components/main/GeneralBasicCard.vue';
import { trpc } from '../utils/trpc'; import { trpc } from '../utils/trpc';
import { getNpcColor } from '../utils/npcColor'; import { getNpcColor } from '../utils/npcColor';
import { formatLog } from '../utils/formatLog'; import { formatLog } from '../utils/formatLog';
import { resolveGeneralIconUrl } from '../utils/generalIcon';
type BattleCenterResponse = Awaited<ReturnType<typeof trpc.nation.getBattleCenter.query>>; type BattleCenterResponse = Awaited<ReturnType<typeof trpc.nation.getBattleCenter.query>>;
type GeneralEntry = BattleCenterResponse['generals'][number]; type GeneralEntry = BattleCenterResponse['generals'][number];
@@ -137,8 +137,6 @@ const formatGeneralLabel = (general: GeneralEntry): string => {
return `${name} (${time})`; return `${name} (${time})`;
}; };
const generalImageUrl = (general: GeneralEntry): string => resolveGeneralIconUrl(general);
let logRequestId = 0; let logRequestId = 0;
const loadLogs = async (generalId: number) => { const loadLogs = async (generalId: number) => {
@@ -270,47 +268,29 @@ onMounted(() => {
</PanelCard> </PanelCard>
<PanelCard title="장수 정보"> <PanelCard title="장수 정보">
<SkeletonLines v-if="loading" :lines="5" /> <GeneralBasicCard
<div v-else-if="selectedGeneral" class="battle-general-card"> class="battle-general-card"
<div class="battle-general-name"> :general="selectedGeneral"
{{ selectedGeneral.name }} ({{ selectedGeneral.officerLevelText }}) :loading="loading"
</div> :nation-color="data?.nation.color"
<span >
class="battle-general-portrait" <template v-if="selectedGeneral" #details>
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"> <div class="battle-general-extra">
<span>명성</span><strong>{{ selectedGeneral.experience.toLocaleString('ko-KR') }}</strong> <span>명성</span
><strong>{{ selectedGeneral.experience.toLocaleString('ko-KR') }}</strong>
<span>계급</span><strong>{{ selectedGeneral.progression.dedicationText }}</strong> <span>계급</span><strong>{{ selectedGeneral.progression.dedicationText }}</strong>
<span>나이</span><strong>{{ selectedGeneral.age }}</strong> <span>병종</span <span>전투</span><strong>{{ selectedGeneral.warnum }}</strong> <span>승리</span
><strong>{{ selectedGeneral.crewTypeName }}</strong> <span>승리</span
><strong>{{ selectedGeneral.battleStats.kills }}</strong> <span>패배</span ><strong>{{ selectedGeneral.battleStats.kills }}</strong> <span>패배</span
><strong>{{ selectedGeneral.battleStats.deaths }}</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> ><strong>{{ selectedGeneral.battleStats.killCrew.toLocaleString('ko-KR') }}</strong>
<span>피살</span <span>피살</span
><strong>{{ selectedGeneral.battleStats.deathCrew.toLocaleString('ko-KR') }}</strong> ><strong>{{ selectedGeneral.battleStats.deathCrew.toLocaleString('ko-KR') }}</strong>
<span>전투 특기</span><strong>{{ selectedGeneral.traits.specialWar }}</strong> <span>최근 전투</span><strong>{{ selectedGeneral.recentWar || '-' }}</strong>
<span>내정 특기</span><strong>{{ selectedGeneral.traits.specialDomestic }}</strong>
<span>성격</span><strong>{{ selectedGeneral.traits.personal }}</strong>
</div>
<LegacyGeneralProgress :general="selectedGeneral" />
</div> </div>
<LegacyGeneralProgress :general="selectedGeneral" :show-primary="false" />
</template>
</GeneralBasicCard>
<div v-if="selectedGeneral" class="general-meta"> <div v-if="selectedGeneral" class="general-meta">
<div> <div>
최근 : 최근 :
@@ -384,39 +364,7 @@ onMounted(() => {
gap: 4px; 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 { .battle-general-extra {
clear: both;
display: grid; display: grid;
grid-template-columns: repeat(6, 1fr); grid-template-columns: repeat(6, 1fr);
} }
@@ -442,24 +390,6 @@ onMounted(() => {
white-space: nowrap; 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 { .log-grid {
display: contents; display: contents;
} }
+179 -57
View File
@@ -2,6 +2,7 @@
import { formatServerDateTime } from '@sammo-ts/common'; import { formatServerDateTime } from '@sammo-ts/common';
import { computed, onMounted, ref } from 'vue'; import { computed, onMounted, ref } from 'vue';
import TournamentBracket from '../components/tournament/TournamentBracket.vue'; import TournamentBracket from '../components/tournament/TournamentBracket.vue';
import GeneralIdentity from '../components/ui/GeneralIdentity.vue';
import { trpc } from '../utils/trpc'; import { trpc } from '../utils/trpc';
type Snapshot = Awaited<ReturnType<typeof trpc.tournament.getSnapshot.query>>; type Snapshot = Awaited<ReturnType<typeof trpc.tournament.getSnapshot.query>>;
@@ -13,6 +14,7 @@ const loading = ref(false);
const error = ref<string | null>(null); const error = ref<string | null>(null);
const message = ref<string | null>(null); const message = ref<string | null>(null);
const amounts = ref<Record<number, number>>({}); const amounts = ref<Record<number, number>>({});
const activeRankingPrefix = ref('tt');
const typeNames = ['전력전', '통솔전', '일기토', '설전']; const typeNames = ['전력전', '통솔전', '일기토', '설전'];
const stageNames = [ const stageNames = [
'경기 없음', '경기 없음',
@@ -58,7 +60,13 @@ const final16Ids = computed(() =>
const candidates = computed(() => const candidates = computed(() =>
Array.from({ length: 16 }, (_, index) => { Array.from({ length: 16 }, (_, index) => {
const id = final16Ids.value[index] ?? 0; 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); const totalAmount = computed(() => summary.value?.totalAmount ?? 0);
@@ -132,33 +140,26 @@ const placeBet = async (targetId: number) => {
:bet-totals="betTotals" :bet-totals="betTotals"
:total-bet="totalAmount" :total-bet="totalAmount"
:show-legend="false" :show-legend="false"
force-desktop
/> />
<section class="candidate-table bg0"> <section class="candidate-table bg0">
<div class="candidate-row names"> <div class="candidate-grid">
<span v-for="candidate in candidates" :key="candidate.id || candidate.name">{{ candidate.name }}</span> <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>
<div class="candidate-row ratios"> <div v-if="bettingOpen" class="candidate-actions">
<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 <select
v-for="candidate in candidates"
:key="candidate.id || candidate.name"
v-model.number="amounts[candidate.id]" v-model.number="amounts[candidate.id]"
:aria-label="`${candidate.name} 베팅 금액`" :aria-label="`${candidate.name} 베팅 금액`"
:disabled="!candidate.id" :disabled="!candidate.id"
@@ -171,19 +172,11 @@ const placeBet = async (targetId: number) => {
<option :value="500">금500</option> <option :value="500">금500</option>
<option :value="1000">최대</option> <option :value="1000">최대</option>
</select> </select>
<button type="button" :disabled="!candidate.id" @click="placeBet(candidate.id)">베팅</button>
</div> </div>
<div v-if="bettingOpen" class="candidate-row buttons"> </article>
<button
v-for="candidate in candidates"
:key="candidate.id || candidate.name"
type="button"
:disabled="!candidate.id"
@click="placeBet(candidate.id)"
>
베팅!
</button>
</div> </div>
<p> <p class="candidate-help">
<span class="ratio-color">배당률</span> × <span class="gold-color">베팅금</span> = <span class="ratio-color">배당률</span> × <span class="gold-color">베팅금</span> =
<span class="return-color">적중시 환수금</span><br /> <span class="return-color">적중시 환수금</span><br />
<span class="ratio-color">( 베팅후 500 이하일땐 베팅이 불가능합니다. )</span> <span class="ratio-color">( 베팅후 500 이하일땐 베팅이 불가능합니다. )</span>
@@ -204,8 +197,26 @@ const placeBet = async (targetId: number) => {
<section class="ranking-placeholder bg0"> <section class="ranking-placeholder bg0">
순위 / 장수명 / 능력치 / 경기수 / 승리 / 무승부 / 패배 / 집계점수 / 우승횟수 순위 / 장수명 / 능력치 / 경기수 / 승리 / 무승부 / 패배 / 집계점수 / 우승횟수
</section> </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"> <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> <thead>
<tr> <tr>
<th colspan="9">{{ section.title }}</th> <th colspan="9">{{ section.title }}</th>
@@ -225,7 +236,14 @@ const placeBet = async (targetId: number) => {
<tbody> <tbody>
<tr v-for="entry in section.entries" :key="entry.generalId"> <tr v-for="entry in section.entries" :key="entry.generalId">
<td>{{ entry.rank }}</td> <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.stat }}</td>
<td>{{ entry.games }}</td> <td>{{ entry.games }}</td>
<td>{{ entry.win }}</td> <td>{{ entry.win }}</td>
@@ -259,9 +277,10 @@ const placeBet = async (targetId: number) => {
<style scoped> <style scoped>
.betting-page { .betting-page {
width: 1125px; width: 100%;
height: 1346px; max-width: 1200px;
overflow: hidden; min-width: 0;
min-height: 100vh;
margin: 0 auto; margin: 0 auto;
color: #fff; color: #fff;
font-family: var(--sammo-font-sans); font-family: var(--sammo-font-sans);
@@ -270,8 +289,8 @@ const placeBet = async (targetId: number) => {
text-align: center; text-align: center;
} }
.betting-bracket :deep(.bracket-canvas) { .betting-bracket :deep(.bracket-canvas) {
width: 1125px; width: 100%;
min-width: 1125px; min-width: 1000px;
} }
.betting-bracket :deep(.bracket-round), .betting-bracket :deep(.bracket-round),
.betting-bracket :deep(.connector-row) { .betting-bracket :deep(.connector-row) {
@@ -353,20 +372,34 @@ const placeBet = async (targetId: number) => {
} }
.candidate-table { .candidate-table {
border: 1px solid gray; border: 1px solid gray;
padding: 10px 0; padding: 10px;
font-size: 10px; font-size: 12px;
} }
.candidate-row { .candidate-grid {
display: grid; display: grid;
grid-template-columns: repeat(16, 70px); grid-template-columns: repeat(4, minmax(0, 1fr));
align-items: center; gap: 8px;
min-height: 10px;
line-height: 10px;
} }
.names { .candidate-card {
min-height: 14px; 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 { .ratio-color {
color: skyblue; color: skyblue;
} }
@@ -378,7 +411,7 @@ const placeBet = async (targetId: number) => {
color: orange; color: orange;
} }
select, select,
.buttons button { .candidate-actions button {
width: 100%; width: 100%;
min-height: 27px; min-height: 27px;
padding: 2px 1px; padding: 2px 1px;
@@ -412,7 +445,7 @@ select:disabled {
cursor: not-allowed; cursor: not-allowed;
opacity: 0.5; opacity: 0.5;
} }
.candidate-table p { .candidate-help {
min-height: 20px; min-height: 20px;
margin: 8px 0 0; margin: 8px 0 0;
font-size: 18px; font-size: 18px;
@@ -431,11 +464,13 @@ select:disabled {
} }
.ranking-grid { .ranking-grid {
display: grid; display: grid;
grid-template-columns: repeat(4, 280px); grid-template-columns: repeat(2, minmax(0, 1fr));
align-items: start; align-items: start;
gap: 8px;
padding: 8px;
} }
.ranking-table { .ranking-table {
width: 280px; width: 100%;
border-collapse: collapse; border-collapse: collapse;
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
font-size: 12px; font-size: 12px;
@@ -443,7 +478,7 @@ select:disabled {
} }
.ranking-table th, .ranking-table th,
.ranking-table td { .ranking-table td {
height: 14px; height: 28px;
padding: 1px; padding: 1px;
border: 1px solid #555; border: 1px solid #555;
} }
@@ -457,12 +492,20 @@ select:disabled {
.ranking-table .bg1 { .ranking-table .bg1 {
background: #213b52; background: #213b52;
} }
.ranking-table th:nth-child(2),
.ranking-table td:nth-child(2) { .ranking-table td:nth-child(2) {
max-width: 80px; width: 130px;
max-width: 130px;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
.ranking-general {
text-align: left;
}
.ranking-tabs {
display: none;
}
.guide { .guide {
padding: 10px; padding: 10px;
text-align: left; text-align: left;
@@ -470,4 +513,83 @@ select:disabled {
.error { .error {
color: #ff8080; 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> </style>
+14 -2
View File
@@ -232,7 +232,12 @@ watch(
<NationBasicCard :nation="nation" :loading="loading" /> <NationBasicCard :nation="nation" :loading="loading" />
</PanelCard> </PanelCard>
<PanelCard title="장수 스탯" data-main-target="general"> <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>
<PanelCard title="도시 정보" data-main-target="city"> <PanelCard title="도시 정보" data-main-target="city">
<CityBasicCard :city="city" :loading="loading" /> <CityBasicCard :city="city" :loading="loading" />
@@ -347,7 +352,12 @@ watch(
<NationBasicCard :nation="nation" :loading="loading" /> <NationBasicCard :nation="nation" :loading="loading" />
</PanelCard> </PanelCard>
<PanelCard title="장수 스탯" data-main-target="general"> <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>
<MainNationMenu <MainNationMenu
class="nation-menu-middle" class="nation-menu-middle"
@@ -596,12 +606,14 @@ button {
.layout-desktop > [data-main-target='nation'] { .layout-desktop > [data-main-target='nation'] {
grid-column: 1 / 6; grid-column: 1 / 6;
grid-row: 3; grid-row: 3;
align-self: stretch;
min-height: 193px; min-height: 193px;
} }
.layout-desktop > [data-main-target='general'] { .layout-desktop > [data-main-target='general'] {
grid-column: 6 / 11; grid-column: 6 / 11;
grid-row: 3; grid-row: 3;
align-self: stretch;
min-height: 193px; min-height: 193px;
} }
+79 -115
View File
@@ -2,11 +2,12 @@
import { computed, onMounted, reactive, ref, watch } from 'vue'; import { computed, onMounted, reactive, ref, watch } from 'vue';
import { trpc } from '../utils/trpc'; import { trpc } from '../utils/trpc';
import { formatLog } from '../utils/formatLog'; import { formatLog } from '../utils/formatLog';
import { formatSeoulDateTime, formatSeoulHourMinute } from '../utils/legacyDateTime'; import { formatSeoulDateTime } from '../utils/legacyDateTime';
import { isDefenceTrainPenaltyWaivedByScenarioEffect } from '@sammo-ts/logic'; import { isDefenceTrainPenaltyWaivedByScenarioEffect } from '@sammo-ts/logic';
import { useSessionStore } from '../stores/session'; import { useSessionStore } from '../stores/session';
import { resolveGeneralIconUrl, useDefaultGeneralIcon } from '../utils/generalIcon'; import { resolveGeneralIconUrl, useDefaultGeneralIcon } from '../utils/generalIcon';
import LegacyGeneralProgress from '../components/ui/LegacyGeneralProgress.vue'; import LegacyGeneralProgress from '../components/ui/LegacyGeneralProgress.vue';
import GeneralBasicCard from '../components/main/GeneralBasicCard.vue';
import { useGameFeedback } from '../composables/useGameFeedback'; import { useGameFeedback } from '../composables/useGameFeedback';
const SCREEN_MODE_KEY = 'sam.screenMode'; 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 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 autorunUser = computed(() => asRecord(world.value?.meta.autorun_user));
const showAutoNationTurn = computed(() => asRecord(autorunUser.value.options).chief !== false); const showAutoNationTurn = computed(() => asRecord(autorunUser.value.options).chief !== false);
@@ -360,7 +362,7 @@ onMounted(() => {
</script> </script>
<template> <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"> <div class="title-row">
<span> </span> <span> </span>
<RouterLink class="legacy-button" to="/past-plays">지난 플레이</RouterLink> <RouterLink class="legacy-button" to="/past-plays">지난 플레이</RouterLink>
@@ -374,70 +376,17 @@ onMounted(() => {
<section class="top-grid"> <section class="top-grid">
<div class="general-column"> <div class="general-column">
<div class="section-title sky">장수 정보</div> <div class="section-title sky">장수 정보</div>
<div v-if="loading || !data" class="loading">불러오는 중...</div> <GeneralBasicCard
<div v-else class="general-table"> class="general-table"
<div class="portrait-cell"> :general="data?.general ?? null"
<span :loading="loading"
class="portrait-image" :nation-color="data?.nation?.color"
role="img" :defence-text="form.defence_train === 999 ? '수비 안함' : `수비 (훈사${form.defence_train})`"
:style="{ backgroundImage: `url(${resolveGeneralIconUrl(data.general)})` }" :troop-text="data?.general.troopId ? String(data.general.troopId) : '-'"
></span> :penalty-text="penalties.length || '-'"
<strong>{{ data.general.name }}</strong> >
</div> <template v-if="data" #details>
<dl> <div class="legacy-general-details">
<div>
<dt>통솔</dt>
<dd>{{ data.general.stats.leadership }}</dd>
</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> <div>
명망 명망
<strong <strong
@@ -456,12 +405,14 @@ onMounted(() => {
<div>승률 0% · 승리 0 · 패배 0</div> <div>승률 0% · 승리 0 · 패배 0</div>
<div>살상률 0% · 사살 0 · 피살 0</div> <div>살상률 0% · 사살 0 · 피살 0</div>
<div> <div>
병종 {{ data.general.crewTypeName ?? '-' }} · 내정특기 소속 {{ data.nation?.name ?? '재야' }} · 도시 {{ data.city?.name ?? '-' }} · 병종
{{ data.general.traits?.specialDomestic ?? '-' }} · 부상 {{ data.general.injury }} · 부대 {{ data.general.crewTypeName ?? '-' }} · 내정특기
{{ data.general.troopId || '-' }} · 벌점 {{ penalties.length || '-' }} {{ data.general.traits?.specialDomestic ?? '-' }} · 부상 {{ data.general.injury }}
</div> </div>
<LegacyGeneralProgress :general="data.general" /> <LegacyGeneralProgress :general="data.general" :show-primary="false" />
</div> </div>
</template>
</GeneralBasicCard>
</div> </div>
<div class="settings-column"> <div class="settings-column">
@@ -544,6 +495,16 @@ onMounted(() => {
<span v-if="data.iconChangeAvailableAt" class="hint"> <span v-if="data.iconChangeAvailableAt" class="hint">
다음 변경 가능: {{ formatSeoulDateTime(data.iconChangeAvailableAt) }} 다음 변경 가능: {{ formatSeoulDateTime(data.iconChangeAvailableAt) }}
</span> </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="장수 전용 아이콘 선택"> <div class="general-icon-list" role="radiogroup" aria-label="장수 전용 아이콘 선택">
<label v-for="icon in iconChoices" :key="icon.id" class="general-icon-choice"> <label v-for="icon in iconChoices" :key="icon.id" class="general-icon-choice">
<input v-model="selectedIconId" type="radio" :value="icon.id" /> <input v-model="selectedIconId" type="radio" :value="icon.id" />
@@ -678,8 +639,7 @@ onMounted(() => {
.legacy-page { .legacy-page {
width: 100%; width: 100%;
max-width: 1000px; max-width: 1000px;
min-width: 500px; min-width: 0;
height: 1257.5px;
min-height: 0; min-height: 0;
margin: 0 auto; margin: 0 auto;
padding: 0; padding: 0;
@@ -789,28 +749,6 @@ button:disabled {
.sky { .sky {
color: skyblue; 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 { .legacy-general-info-compat {
display: none; display: none;
} }
@@ -827,23 +765,6 @@ button:disabled {
overflow: hidden; overflow: hidden;
white-space: nowrap; 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 { .settings-column {
padding: 10px 18px; padding: 10px 18px;
} }
@@ -945,6 +866,21 @@ dt {
gap: 6px; gap: 6px;
margin: 6px 0; 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 { .general-icon-choice {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -952,16 +888,44 @@ dt {
} }
@media (max-width: 991px) { @media (max-width: 991px) {
.legacy-page { .legacy-page {
width: 500px; width: 100%;
height: 1798.34px; max-width: 100%;
} }
.my-page-mobile-scroll-spacer { .my-page-mobile-scroll-spacer {
display: block; display: none;
height: 100px;
} }
.top-grid, .top-grid,
.log-grid { .log-grid {
grid-template-columns: 1fr; 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> </style>
+186 -38
View File
@@ -2,6 +2,7 @@
import { formatServerDateTime } from '@sammo-ts/common'; import { formatServerDateTime } from '@sammo-ts/common';
import { computed, onMounted, ref } from 'vue'; import { computed, onMounted, ref } from 'vue';
import TournamentBracket from '../components/tournament/TournamentBracket.vue'; import TournamentBracket from '../components/tournament/TournamentBracket.vue';
import GeneralIdentity from '../components/ui/GeneralIdentity.vue';
import { trpc } from '../utils/trpc'; import { trpc } from '../utils/trpc';
import { resolveTournamentStageName } from '../utils/tournamentStatus'; import { resolveTournamentStageName } from '../utils/tournamentStatus';
@@ -14,8 +15,11 @@ const loading = ref(false);
const error = ref<string | null>(null); const error = ref<string | null>(null);
const actionMessage = ref<string | null>(null); const actionMessage = ref<string | null>(null);
const adminEnabled = ref(false); const adminEnabled = ref(false);
const activeFinalGroup = ref(0);
const activePreliminaryGroup = ref(0);
const typeNames = ['전력전', '통솔전', '일기토', '설전']; const typeNames = ['전력전', '통솔전', '일기토', '설전'];
const typeStatNames = ['종합', '통솔', '무력', '지력'];
const errorText = (value: unknown) => (value instanceof Error ? value.message : String(value)); const errorText = (value: unknown) => (value instanceof Error ? value.message : String(value));
const load = async () => { 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)) .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 currentMatch = computed(() => {
const state = snapshot.value?.state; const state = snapshot.value?.state;
if (!state || state.stage < 7 || state.stage > 10) return null; if (!state || state.stage < 7 || state.stage > 10) return null;
@@ -154,7 +178,6 @@ const start = async () => {
:winner-id="snapshot?.state?.winnerId" :winner-id="snapshot?.state?.winnerId"
:bet-totals="betTotals" :bet-totals="betTotals"
:total-bet="totalBet" :total-bet="totalBet"
force-desktop
/> />
<section v-if="currentMatch" class="fight bg0"> <section v-if="currentMatch" class="fight bg0">
@@ -163,18 +186,35 @@ const start = async () => {
</section> </section>
<section class="section-title groups-title bg2">조별 본선 순위</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"> <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> <caption>
{{ {{
['一', '二', '三', '四', '五', '六', '七', '八'][groupIndex] groupNames[groupIndex]
}} }}
</caption> </caption>
<thead> <thead>
<tr> <tr>
<th></th> <th></th>
<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> <th></th>
<th></th> <th></th>
@@ -186,26 +226,21 @@ const start = async () => {
<tbody> <tbody>
<tr v-for="rowIndex in 4" :key="rowIndex"> <tr v-for="rowIndex in 4" :key="rowIndex">
<td>{{ rowIndex }}</td> <td>{{ rowIndex }}</td>
<td>{{ group[rowIndex - 1]?.name ?? '' }}</td> <td class="general-cell">
<td> <GeneralIdentity
{{ v-if="group[rowIndex - 1]"
group[rowIndex - 1] :name="group[rowIndex - 1]!.name"
? (group[rowIndex - 1]!.win ?? 0) + :picture="group[rowIndex - 1]!.picture"
(group[rowIndex - 1]!.draw ?? 0) + :image-server="group[rowIndex - 1]!.imageServer"
(group[rowIndex - 1]!.lose ?? 0) :icon-size="24"
: '' />
}}
</td> </td>
<td>{{ statOf(group[rowIndex - 1]) }}</td>
<td>{{ gamesOf(group[rowIndex - 1]) }}</td>
<td>{{ group[rowIndex - 1]?.win ?? '' }}</td> <td>{{ group[rowIndex - 1]?.win ?? '' }}</td>
<td>{{ group[rowIndex - 1]?.draw ?? '' }}</td> <td>{{ group[rowIndex - 1]?.draw ?? '' }}</td>
<td>{{ group[rowIndex - 1]?.lose ?? '' }}</td> <td>{{ group[rowIndex - 1]?.lose ?? '' }}</td>
<td> <td>{{ pointsOf(group[rowIndex - 1]) }}</td>
{{
group[rowIndex - 1]
? (group[rowIndex - 1]!.win ?? 0) * 3 + (group[rowIndex - 1]!.draw ?? 0)
: ''
}}
</td>
<td>{{ group[rowIndex - 1]?.gl ?? '' }}</td> <td>{{ group[rowIndex - 1]?.gl ?? '' }}</td>
</tr> </tr>
</tbody> </tbody>
@@ -213,18 +248,35 @@ const start = async () => {
</section> </section>
<section class="section-title groups-title bg2">조별 예선 순위</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"> <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> <caption>
{{ {{
['一', '二', '三', '四', '五', '六', '七', '八'][groupIndex - 1] groupNames[groupIndex]
}} }}
</caption> </caption>
<thead> <thead>
<tr> <tr>
<th></th> <th></th>
<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> <th></th>
<th></th> <th></th>
@@ -236,14 +288,22 @@ const start = async () => {
<tbody> <tbody>
<tr v-for="rowIndex in 8" :key="rowIndex"> <tr v-for="rowIndex in 8" :key="rowIndex">
<td>{{ rowIndex }}</td> <td>{{ rowIndex }}</td>
<td></td> <td class="general-cell">
<td></td> <GeneralIdentity
<td></td> v-if="group[rowIndex - 1]"
<td></td> :name="group[rowIndex - 1]!.name"
<td></td> :picture="group[rowIndex - 1]!.picture"
<td></td> :image-server="group[rowIndex - 1]!.imageServer"
<td></td> :icon-size="24"
<td></td> />
</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> </tr>
</tbody> </tbody>
</table> </table>
@@ -291,9 +351,10 @@ const start = async () => {
<style scoped> <style scoped>
.legacy-page { .legacy-page {
width: 2009px; width: 100%;
height: 1059px; max-width: 1200px;
overflow: hidden; min-width: 0;
min-height: 100vh;
margin: 0 auto; margin: 0 auto;
color: #fff; color: #fff;
font-family: var(--sammo-font-sans); font-family: var(--sammo-font-sans);
@@ -420,13 +481,15 @@ button:focus-visible {
} }
.group-grid { .group-grid {
display: grid; display: grid;
grid-template-columns: repeat(8, 250px); grid-template-columns: repeat(4, minmax(0, 1fr));
align-items: start; align-items: start;
gap: 8px;
padding: 8px;
} }
table { table {
width: 250px; width: 100%;
border-collapse: collapse; border-collapse: collapse;
table-layout: auto; table-layout: fixed;
} }
caption { caption {
padding: 3px; padding: 3px;
@@ -439,14 +502,99 @@ th {
} }
th, th,
td { td {
height: 17px; height: 30px;
border: 1px solid #555; border: 1px solid #555;
padding: 1px 3px; 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 { .admin-row {
text-align: left; text-align: left;
} }
.error-row { .error-row {
color: #ff8080; 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> </style>
@@ -3,7 +3,12 @@ import { describe, it } from 'node:test';
import { buildTournamentBracket } from '../src/utils/tournamentBracket.ts'; 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 = [ const matches = [
...Array.from({ length: 8 }, (_, index) => ({ ...Array.from({ length: 8 }, (_, index) => ({
id: index + 1, id: index + 1,
@@ -37,6 +42,8 @@ void describe('tournament bracket', () => {
const bracket = buildTournamentBracket(participants, matches, 1); const bracket = buildTournamentBracket(participants, matches, 1);
assert.equal(bracket.champion.name, '장수1'); assert.equal(bracket.champion.name, '장수1');
assert.equal(bracket.champion.picture, '1.jpg');
assert.equal(bracket.top16.slots[1]?.imageServer, 1);
assert.deepEqual( assert.deepEqual(
bracket.top16.slots.map((slot) => slot.name), bracket.top16.slots.map((slot) => slot.name),
participants.map((participant) => participant.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', () => { 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.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[0]?.name, '장수1');
assert.equal(bracket.top16.slots[15]?.name, '장수16'); assert.equal(bracket.top16.slots[15]?.name, '장수16');
}); });
+17 -4
View File
@@ -1280,7 +1280,10 @@ export const adminRouter = router({
sourceRef = resolved; sourceRef = resolved;
} }
const scenarios = await listScenarioPreviews({ gitRef: 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.'); throw new Error('Current scenario is not available at source.');
} }
} catch { } catch {
@@ -1579,6 +1582,8 @@ export const adminRouter = router({
.map((profile) => ({ .map((profile) => ({
profileName: profile.profileName, profileName: profile.profileName,
profile: profile.profile, profile: profile.profile,
instanceKey: profile.instanceKey,
currentScenario: profile.currentScenario,
meta: { meta: {
...(typeof profile.meta.korName === 'string' ? { korName: profile.meta.korName } : {}), ...(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); const profile = await ctx.profiles.getProfile(input.profileName);
if (!profile) throw new TRPCError({ code: 'NOT_FOUND', message: 'Profile not found.' }); 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; currentScenarioId = Number.isInteger(parsedScenarioId) ? parsedScenarioId : null;
gitRef = profile.buildCommitSha?.trim(); gitRef = profile.buildCommitSha?.trim();
if (!gitRef) { if (!gitRef) {
@@ -1692,8 +1698,13 @@ export const adminRouter = router({
upsert: profileAdminProcedure upsert: profileAdminProcedure
.input( .input(
z.object({ z.object({
profile: z.string().min(1).max(32), profile: z.string().regex(/^[a-z0-9-]{1,32}$/),
scenario: z.string().min(1).max(64), 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), apiPort: z.number().int().min(1).max(65535),
status: zProfileStatus.optional(), status: zProfileStatus.optional(),
preopenAt: z.string().datetime().optional(), preopenAt: z.string().datetime().optional(),
@@ -1706,6 +1717,8 @@ export const adminRouter = router({
const status = input.status ?? 'STOPPED'; const status = input.status ?? 'STOPPED';
return ctx.profiles.upsertProfile({ return ctx.profiles.upsertProfile({
profile: input.profile, profile: input.profile,
instanceKey: input.instanceKey,
currentScenario: input.currentScenario,
scenario: input.scenario, scenario: input.scenario,
apiPort: input.apiPort, apiPort: input.apiPort,
status, status,
@@ -21,6 +21,9 @@ export type LobbyGeneralStatus = {
export type LobbyProfileStatus = { export type LobbyProfileStatus = {
profileName: string; profileName: string;
profile: string; profile: string;
instanceKey: string;
currentScenario: string | null;
/** @deprecated Rollback-compatible mirror of currentScenario. */
scenario: string; scenario: string;
status: GatewayProfileStatus; status: GatewayProfileStatus;
apiPort: number; apiPort: number;
@@ -87,6 +90,8 @@ export class RepositoryProfileStatusService implements GatewayProfileStatusServi
return { return {
profileName: row.profileName, profileName: row.profileName,
profile: row.profile, profile: row.profile,
instanceKey: row.instanceKey,
currentScenario: row.currentScenario,
scenario: row.scenario, scenario: row.scenario,
status: row.status, status: row.status,
apiPort: row.apiPort, apiPort: row.apiPort,
@@ -396,7 +396,7 @@ export const buildProcessDefinitions = (
...baseEnv, ...baseEnv,
GAME_API_ROLE: 'server', GAME_API_ROLE: 'server',
PROFILE: profile.profile, PROFILE: profile.profile,
SCENARIO: profile.scenario, SCENARIO: profile.currentScenario ?? 'default',
GAME_PROFILE_NAME: profile.profileName, GAME_PROFILE_NAME: profile.profileName,
GAME_API_PORT: String(profile.apiPort), GAME_API_PORT: String(profile.apiPort),
GAME_TRPC_PATH: `/${profile.profile}/api/trpc`, GAME_TRPC_PATH: `/${profile.profile}/api/trpc`,
@@ -411,7 +411,7 @@ export const buildProcessDefinitions = (
GAME_ENGINE_ROLE: 'turn-daemon', GAME_ENGINE_ROLE: 'turn-daemon',
TURN_PROFILE: profile.profile, TURN_PROFILE: profile.profile,
PROFILE: profile.profile, PROFILE: profile.profile,
SCENARIO: profile.scenario, SCENARIO: profile.currentScenario ?? 'default',
TURN_PROFILE_NAME: profile.profileName, TURN_PROFILE_NAME: profile.profileName,
}; };
return { return {
@@ -1422,8 +1422,8 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
} = parseInstallOptions(action); } = parseInstallOptions(action);
const tickOverride = const tickOverride =
installOptions?.turnTermMinutes !== undefined ? installOptions.turnTermMinutes * 60 : undefined; installOptions?.turnTermMinutes !== undefined ? installOptions.turnTermMinutes * 60 : undefined;
const scenarioId = installScenarioId ?? parseScenarioId(profile.scenario); const scenarioId = installScenarioId ?? parseScenarioId(profile.currentScenario);
if (!scenarioId) { if (scenarioId === null) {
return { status: 'FAILED', detail: 'scenarioId is missing' }; return { status: 'FAILED', detail: 'scenarioId is missing' };
} }
const profileDatabaseUrl = this.resolveProfileDatabaseUrl(profile); const profileDatabaseUrl = this.resolveProfileDatabaseUrl(profile);
@@ -1547,7 +1547,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
const desiredStatus = shouldPreopen ? 'PREOPEN' : 'RUNNING'; const desiredStatus = shouldPreopen ? 'PREOPEN' : 'RUNNING';
const publishedProfile = await updateClaimedProfile( const publishedProfile = await updateClaimedProfile(
{ {
scenario: String(scenarioId), currentScenario: String(scenarioId),
status: desiredStatus, status: desiredStatus,
buildStatus: 'SUCCEEDED', buildStatus: 'SUCCEEDED',
buildWorkspace: workspace.root, buildWorkspace: workspace.root,
@@ -1564,8 +1564,8 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
completedAt, completedAt,
error: null, error: null,
}); });
if (String(scenarioId) !== profile.scenario) { if (String(scenarioId) !== profile.currentScenario) {
await this.repository.updateScenario(profile.profileName, String(scenarioId)); await this.repository.updateCurrentScenario(profile.profileName, String(scenarioId));
} }
return this.repository.updateStatus(profile.profileName, desiredStatus, { return this.repository.updateStatus(profile.profileName, desiredStatus, {
preopenAt: preopenAt ? preopenAt.toISOString() : openAt ? openAt.toISOString() : null, preopenAt: preopenAt ? preopenAt.toISOString() : openAt ? openAt.toISOString() : null,
@@ -1577,6 +1577,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
releasePrepared = true; releasePrepared = true;
const builtProfile = publishedProfile ?? { const builtProfile = publishedProfile ?? {
...profile, ...profile,
currentScenario: String(scenarioId),
scenario: String(scenarioId), scenario: String(scenarioId),
status: desiredStatus, status: desiredStatus,
buildWorkspace: workspace.root, buildWorkspace: workspace.root,
@@ -1642,7 +1643,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
meta: Record<string, unknown>; meta: Record<string, unknown>;
}> { }> {
const databaseUrl = databaseUrlOverride ?? this.resolveProfileDatabaseUrl(profile); 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 tickSeconds: number | undefined = overrides?.tickSeconds;
let meta: Record<string, unknown> = {}; let meta: Record<string, unknown> = {};
const connector = createGamePostgresConnector({ url: databaseUrl }); const connector = createGamePostgresConnector({ url: databaseUrl });
@@ -78,6 +78,9 @@ export interface GatewayOperationLogInput {
export interface GatewayProfileRecord { export interface GatewayProfileRecord {
profileName: string; profileName: string;
profile: string; profile: string;
instanceKey: string;
currentScenario: string | null;
/** @deprecated Rollback-compatible mirror of currentScenario. */
scenario: string; scenario: string;
apiPort: number; apiPort: number;
status: GatewayProfileStatus; status: GatewayProfileStatus;
@@ -100,7 +103,10 @@ export interface GatewayProfileRecord {
export interface GatewayProfileUpsertInput { export interface GatewayProfileUpsertInput {
profile: string; profile: string;
scenario: string; instanceKey?: string;
currentScenario?: string | null;
/** @deprecated Accepted while older bootstrap clients are still supported. */
scenario?: string;
apiPort: number; apiPort: number;
status?: GatewayProfileStatus; status?: GatewayProfileStatus;
preopenAt?: string; preopenAt?: string;
@@ -111,7 +117,7 @@ export interface GatewayProfileUpsertInput {
} }
export interface GatewayClaimedProfileUpdate { export interface GatewayClaimedProfileUpdate {
scenario?: string; currentScenario?: string | null;
status?: GatewayProfileStatus; status?: GatewayProfileStatus;
buildStatus?: GatewayBuildStatus; buildStatus?: GatewayBuildStatus;
buildCommitSha?: string | null; buildCommitSha?: string | null;
@@ -131,7 +137,7 @@ export interface GatewayProfileRepository {
listProfiles(): Promise<GatewayProfileRecord[]>; listProfiles(): Promise<GatewayProfileRecord[]>;
getProfile(profileName: string): Promise<GatewayProfileRecord | null>; getProfile(profileName: string): Promise<GatewayProfileRecord | null>;
upsertProfile(input: GatewayProfileUpsertInput): Promise<GatewayProfileRecord>; upsertProfile(input: GatewayProfileUpsertInput): Promise<GatewayProfileRecord>;
updateScenario(profileName: string, scenario: string): Promise<GatewayProfileRecord | null>; updateCurrentScenario(profileName: string, scenario: string | null): Promise<GatewayProfileRecord | null>;
updateStatus( updateStatus(
profileName: string, profileName: string,
status: GatewayProfileStatus, status: GatewayProfileStatus,
@@ -219,6 +225,8 @@ export const buildRetryOperationSource = (previous: {
type GatewayProfileRow = { type GatewayProfileRow = {
profileName: string; profileName: string;
profile: string; profile: string;
instanceKey: string;
currentScenario: string | null;
scenario: string; scenario: string;
apiPort: number; apiPort: number;
status: GatewayProfileStatus; status: GatewayProfileStatus;
@@ -265,6 +273,8 @@ type GatewayOperationRow = {
const mapProfile = (row: GatewayProfileRow): GatewayProfileRecord => ({ const mapProfile = (row: GatewayProfileRow): GatewayProfileRecord => ({
profileName: row.profileName, profileName: row.profileName,
profile: row.profile, profile: row.profile,
instanceKey: row.instanceKey,
currentScenario: row.currentScenario,
scenario: row.scenario, scenario: row.scenario,
apiPort: row.apiPort, apiPort: row.apiPort,
status: row.status, status: row.status,
@@ -285,7 +295,24 @@ const mapProfile = (row: GatewayProfileRow): GatewayProfileRecord => ({
updatedAt: row.updatedAt.toISOString(), 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 => ({ const mapOperation = (row: GatewayOperationRow): GatewayOperationRecord => ({
id: row.id, id: row.id,
@@ -331,7 +358,7 @@ const mapOperationLog = (row: {
export const createGatewayProfileRepository = (prisma: GatewayPrismaClient): GatewayProfileRepository => ({ export const createGatewayProfileRepository = (prisma: GatewayPrismaClient): GatewayProfileRepository => ({
async listProfiles(): Promise<GatewayProfileRecord[]> { async listProfiles(): Promise<GatewayProfileRecord[]> {
const rows = await prisma.gatewayProfile.findMany({ const rows = await prisma.gatewayProfile.findMany({
orderBy: [{ profile: 'asc' }, { scenario: 'asc' }], orderBy: [{ profile: 'asc' }, { instanceKey: 'asc' }],
}); });
return rows.map(mapProfile); return rows.map(mapProfile);
}, },
@@ -342,13 +369,16 @@ export const createGatewayProfileRepository = (prisma: GatewayPrismaClient): Gat
return row ? mapProfile(row) : null; return row ? mapProfile(row) : null;
}, },
async upsertProfile(input: GatewayProfileUpsertInput): Promise<GatewayProfileRecord> { 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({ const row = await prisma.gatewayProfile.upsert({
where: { profileName }, where: { profileName },
create: { create: {
profileName, profileName,
profile: input.profile, profile: input.profile,
scenario: input.scenario, instanceKey,
currentScenario,
scenario: currentScenario ?? 'default',
apiPort: input.apiPort, apiPort: input.apiPort,
status: input.status ?? 'STOPPED', status: input.status ?? 'STOPPED',
preopenAt: input.preopenAt ? new Date(input.preopenAt) : null, preopenAt: input.preopenAt ? new Date(input.preopenAt) : null,
@@ -358,6 +388,8 @@ export const createGatewayProfileRepository = (prisma: GatewayPrismaClient): Gat
meta: (input.meta ?? {}) as GatewayPrisma.JsonObject, meta: (input.meta ?? {}) as GatewayPrisma.JsonObject,
}, },
update: { update: {
currentScenario: shouldUpdateCurrentScenario ? currentScenario : undefined,
scenario: shouldUpdateCurrentScenario ? (currentScenario ?? 'default') : undefined,
apiPort: input.apiPort, apiPort: input.apiPort,
status: input.status, status: input.status,
preopenAt: input.preopenAt ? new Date(input.preopenAt) : input.preopenAt === null ? null : undefined, 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); 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({ const row = await prisma.gatewayProfile.update({
where: { profileName }, where: { profileName },
data: { data: {
scenario, currentScenario: scenario,
scenario: scenario ?? 'default',
}, },
}); });
return row ? mapProfile(row) : null; return row ? mapProfile(row) : null;
@@ -700,7 +733,8 @@ export const createGatewayProfileRepository = (prisma: GatewayPrismaClient): Gat
return tx.gatewayProfile.update({ return tx.gatewayProfile.update({
where: { profileName }, where: { profileName },
data: { data: {
scenario: patch.scenario, currentScenario: patch.currentScenario,
scenario: patch.currentScenario === undefined ? undefined : (patch.currentScenario ?? 'default'),
status: patch.status, status: patch.status,
buildStatus: patch.buildStatus, buildStatus: patch.buildStatus,
buildCommitSha: patch.buildCommitSha, buildCommitSha: patch.buildCommitSha,
+4 -4
View File
@@ -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])); const gatewayProfileOrder = new Map<string, number>(GATEWAY_PROFILE_ORDER.map((profile, index) => [profile, index]));
export const compareGatewayProfiles = ( export const compareGatewayProfiles = (
left: { profile: string; scenario: string }, left: { profile: string; instanceKey: string },
right: { profile: string; scenario: string } right: { profile: string; instanceKey: string }
): number => { ): number => {
const unknownRank = GATEWAY_PROFILE_ORDER.length; const unknownRank = GATEWAY_PROFILE_ORDER.length;
const profileOrder = const profileOrder =
@@ -14,8 +14,8 @@ export const compareGatewayProfiles = (
const profileNameOrder = left.profile.localeCompare(right.profile); const profileNameOrder = left.profile.localeCompare(right.profile);
if (profileNameOrder !== 0) return profileNameOrder; 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); [...profiles].sort(compareGatewayProfiles);
+5 -1
View File
@@ -85,6 +85,8 @@ const buildCaller = async (
const profile = { const profile = {
profileName: 'che:2', profileName: 'che:2',
profile: 'che', profile: 'che',
instanceKey: '2',
currentScenario: options.profileScenario ?? '2',
scenario: options.profileScenario ?? '2', scenario: options.profileScenario ?? '2',
apiPort: 15003, apiPort: 15003,
status: options.initialProfileStatus ?? ('STOPPED' as const), status: options.initialProfileStatus ?? ('STOPPED' as const),
@@ -98,7 +100,7 @@ const buildCaller = async (
listProfiles: async () => [profile], listProfiles: async () => [profile],
getProfile: async () => profile, getProfile: async () => profile,
upsertProfile: async () => profile, upsertProfile: async () => profile,
updateScenario: async () => profile, updateCurrentScenario: async () => profile,
updateStatus: async (_profileName, status) => { updateStatus: async (_profileName, status) => {
updatedStatuses.push(status); updatedStatuses.push(status);
return { ...profile, status }; return { ...profile, status };
@@ -362,6 +364,8 @@ describe('admin profile navigation API', () => {
{ {
profileName: 'che:2', profileName: 'che:2',
profile: 'che', profile: 'che',
instanceKey: '2',
currentScenario: '2',
meta: {}, meta: {},
}, },
]); ]);
@@ -15,6 +15,8 @@ import { appRouter } from '../src/router.js';
const profile = { const profile = {
profileName: 'che:default', profileName: 'che:default',
profile: 'che', profile: 'che',
instanceKey: 'default',
currentScenario: null,
scenario: 'default', scenario: 'default',
apiPort: 15003, apiPort: 15003,
status: 'RUNNING' as const, status: 'RUNNING' as const,
@@ -28,7 +30,7 @@ const profiles: GatewayProfileRepository = {
listProfiles: async () => [profile], listProfiles: async () => [profile],
getProfile: async (profileName) => (profileName === profile.profileName ? profile : null), getProfile: async (profileName) => (profileName === profile.profileName ? profile : null),
upsertProfile: async () => profile, upsertProfile: async () => profile,
updateScenario: async () => profile, updateCurrentScenario: async () => profile,
updateStatus: async () => profile, updateStatus: async () => profile,
updateBuildStatus: async () => profile, updateBuildStatus: async () => profile,
updateMeta: async () => profile, updateMeta: async () => profile,
+7 -1
View File
@@ -92,6 +92,8 @@ const buildCaller = (
{ {
profileName: 'che:default', profileName: 'che:default',
profile: 'che', profile: 'che',
instanceKey: 'default',
currentScenario: null,
scenario: 'default', scenario: 'default',
apiPort: 15003, apiPort: 15003,
status: 'RUNNING' as const, status: 'RUNNING' as const,
@@ -103,6 +105,8 @@ const buildCaller = (
{ {
profileName: 'hwe:default', profileName: 'hwe:default',
profile: 'hwe', profile: 'hwe',
instanceKey: 'default',
currentScenario: null,
scenario: 'default', scenario: 'default',
apiPort: 15015, apiPort: 15015,
status: 'RUNNING' as const, status: 'RUNNING' as const,
@@ -119,7 +123,7 @@ const buildCaller = (
upsertProfile: async () => { upsertProfile: async () => {
throw new Error('not used'); throw new Error('not used');
}, },
updateScenario: async () => null, updateCurrentScenario: async () => null,
updateStatus: async () => null, updateStatus: async () => null,
updateBuildStatus: async () => null, updateBuildStatus: async () => null,
updateMeta: async () => null, updateMeta: async () => null,
@@ -167,6 +171,8 @@ const buildCaller = (
profileRows.map((profile) => ({ profileRows.map((profile) => ({
profileName: profile.profileName, profileName: profile.profileName,
profile: profile.profile, profile: profile.profile,
instanceKey: profile.instanceKey,
currentScenario: profile.currentScenario,
scenario: profile.scenario, scenario: profile.scenario,
status: profile.status, status: profile.status,
apiPort: profile.apiPort, apiPort: profile.apiPort,
@@ -13,6 +13,8 @@ import { GitWorkspaceManager } from '../src/orchestrator/workspaceManager.js';
const profile: GatewayProfileRecord = { const profile: GatewayProfileRecord = {
profileName: 'che:2', profileName: 'che:2',
profile: 'che', profile: 'che',
instanceKey: '2',
currentScenario: '2',
scenario: '2', scenario: '2',
apiPort: 15003, apiPort: 15003,
status: 'STOPPED', status: 'STOPPED',
@@ -58,7 +60,7 @@ const createHarness = (
listProfiles: async () => [profile], listProfiles: async () => [profile],
getProfile: async () => profile, getProfile: async () => profile,
upsertProfile: async () => profile, upsertProfile: async () => profile,
updateScenario: async () => profile, updateCurrentScenario: async () => profile,
updateStatus: async (_profileName, status) => { updateStatus: async (_profileName, status) => {
statuses.push(status); statuses.push(status);
return { ...profile, status }; return { ...profile, status };
@@ -14,6 +14,8 @@ import type { GatewayProfileRecord } from '../src/orchestrator/profileRepository
const buildProfile = (buildWorkspace?: string): GatewayProfileRecord => ({ const buildProfile = (buildWorkspace?: string): GatewayProfileRecord => ({
profileName: 'che:2', profileName: 'che:2',
profile: 'che', profile: 'che',
instanceKey: '2',
currentScenario: '2',
scenario: '2', scenario: '2',
apiPort: 15003, apiPort: 15003,
status: 'RUNNING', status: 'RUNNING',
@@ -172,6 +174,39 @@ describe('buildProcessDefinitions', () => {
expect(definitions.tournament.cwd).toBe(path.join(processConfig.workspaceRoot, 'app', 'game-api')); 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', () => { it('does not forward PM2 identity or parent runtime roles to profile processes', () => {
const definitions = buildProcessDefinitions(buildProfile(), { const definitions = buildProcessDefinitions(buildProfile(), {
...processConfig, ...processConfig,
@@ -14,6 +14,8 @@ const makeProfile = (
): GatewayProfileRecord => ({ ): GatewayProfileRecord => ({
profileName, profileName,
profile: profileName.split(':')[0] ?? 'che', profile: profileName.split(':')[0] ?? 'che',
instanceKey: profileName.split(':')[1] ?? 'default',
currentScenario: null,
scenario: profileName.split(':')[1] ?? 'default', scenario: profileName.split(':')[1] ?? 'default',
apiPort: 15_003, apiPort: 15_003,
status: 'RUNNING', status: 'RUNNING',
@@ -50,6 +50,8 @@ describe('profile DEPLOY operation', () => {
const profile: GatewayProfileRecord = { const profile: GatewayProfileRecord = {
profileName: 'che:1010', profileName: 'che:1010',
profile: 'che', profile: 'che',
instanceKey: '1010',
currentScenario: '1010',
scenario: '1010', scenario: '1010',
apiPort: 15003, apiPort: 15003,
status: 'RUNNING', status: 'RUNNING',
@@ -80,7 +82,7 @@ describe('profile DEPLOY operation', () => {
listProfiles: async () => [profile], listProfiles: async () => [profile],
getProfile: async () => profile, getProfile: async () => profile,
upsertProfile: async () => profile, upsertProfile: async () => profile,
updateScenario: async () => profile, updateCurrentScenario: async () => profile,
updateStatus: async () => profile, updateStatus: async () => profile,
updateBuildStatus: async () => profile, updateBuildStatus: async () => profile,
updateMeta: 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,
});
});
});
+10 -10
View File
@@ -6,25 +6,25 @@ describe('orderGatewayProfiles', () => {
it('uses the public server order instead of alphabetical profile order', () => { it('uses the public server order instead of alphabetical profile order', () => {
const profiles = ['hwe', 'pya', 'che', 'nya', 'twe', 'pwe', 'kwe'].map((profile) => ({ const profiles = ['hwe', 'pya', 'che', 'nya', 'twe', 'pwe', 'kwe'].map((profile) => ({
profile, profile,
scenario: 'default', instanceKey: 'default',
})); }));
expect(orderGatewayProfiles(profiles).map(({ profile }) => profile)).toEqual(GATEWAY_PROFILE_ORDER); 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 = [ const profiles = [
{ profile: 'zeta', scenario: 'default' }, { profile: 'zeta', instanceKey: 'default' },
{ profile: 'che', scenario: '20' }, { profile: 'che', instanceKey: '20' },
{ profile: 'alpha', scenario: 'default' }, { profile: 'alpha', instanceKey: 'default' },
{ profile: 'che', scenario: '10' }, { profile: 'che', instanceKey: '10' },
]; ];
expect(orderGatewayProfiles(profiles)).toEqual([ expect(orderGatewayProfiles(profiles)).toEqual([
{ profile: 'che', scenario: '10' }, { profile: 'che', instanceKey: '10' },
{ profile: 'che', scenario: '20' }, { profile: 'che', instanceKey: '20' },
{ profile: 'alpha', scenario: 'default' }, { profile: 'alpha', instanceKey: 'default' },
{ profile: 'zeta', scenario: 'default' }, { profile: 'zeta', instanceKey: 'default' },
]); ]);
expect(profiles[0]?.profile).toBe('zeta'); expect(profiles[0]?.profile).toBe('zeta');
}); });
+1 -1
View File
@@ -38,7 +38,7 @@ describe('readReleaseManifest', () => {
await expect(readReleaseManifest(workspaceRoot)).resolves.toMatchObject({ await expect(readReleaseManifest(workspaceRoot)).resolves.toMatchObject({
controllerProtocol: RELEASE_CONTROLLER_PROTOCOL, controllerProtocol: RELEASE_CONTROLLER_PROTOCOL,
gatewaySchemaHead: '20260811000000_add_gateway_operation_logs', gatewaySchemaHead: '20260813000000_split_gateway_profile_identity',
gameSchemaHead: '20260803000000_add_logical_game_clock', gameSchemaHead: '20260803000000_add_logical_game_clock',
}); });
}); });
@@ -150,6 +150,8 @@ const installFixture = async (
{ {
profileName: 'hwe:default', profileName: 'hwe:default',
profile: 'hwe', profile: 'hwe',
instanceKey: 'default',
currentScenario: '1010',
meta: {}, meta: {},
}, },
]); ]);
@@ -160,6 +162,8 @@ const installFixture = async (
{ {
profileName: 'hwe:default', profileName: 'hwe:default',
profile: 'hwe', profile: 'hwe',
instanceKey: 'default',
currentScenario: '1010',
scenario: '1010', scenario: '1010',
apiPort: 15015, apiPort: 15015,
status: 'RUNNING', status: 'RUNNING',
@@ -352,7 +356,9 @@ test('directs profile deployment to the selected server version tab', async ({ p
await expect(versionTab).toBeFocused(); await expect(versionTab).toBeFocused();
const tabAndHeaderGeometry = await Promise.all([ const tabAndHeaderGeometry = await Promise.all([
tabs.evaluate((element) => element.getBoundingClientRect().top), 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]); expect(tabAndHeaderGeometry[0]).toBeLessThan(tabAndHeaderGeometry[1]);
await page.screenshot({ path: testInfo.outputPath('status-tabs-desktop.png'), fullPage: true }); 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', profileName: 'hwe:2',
profile: 'hwe', profile: 'hwe',
instanceKey: '2',
currentScenario: '1010',
scenario: '1010', scenario: '1010',
status: 'RUNNING', status: 'RUNNING',
buildStatus: 'SUCCEEDED', buildStatus: 'SUCCEEDED',
@@ -59,6 +61,8 @@ const installGatewayFixture = async (page: Page, roles: string[]) => {
{ {
profileName: 'hwe:2', profileName: 'hwe:2',
profile: 'hwe', profile: 'hwe',
instanceKey: '2',
currentScenario: '1010',
meta: { korName: '환상서버' }, 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 expect(scopedPage.getByRole('link', { name: '관리자 페이지' })).toBeVisible();
await scopedPage.getByRole('link', { name: '관리자 페이지' }).click(); await scopedPage.getByRole('link', { name: '관리자 페이지' }).click();
const scopedNavigation = scopedPage.getByRole('navigation', { name: '관리자 메뉴' }); 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: 'Gateway 릴리스' })).toHaveCount(0);
await expect(scopedNavigation.getByRole('link', { name: '사용자 관리' })).toHaveCount(0); await expect(scopedNavigation.getByRole('link', { name: '사용자 관리' })).toHaveCount(0);
await scopedContext.close(); await scopedContext.close();
@@ -55,8 +55,10 @@ type FixtureState = {
}; };
const profile = (runtimeRunning: boolean, resetDefaults?: Record<string, unknown>) => ({ const profile = (runtimeRunning: boolean, resetDefaults?: Record<string, unknown>) => ({
profileName: 'che:2', profileName: 'che:default',
profile: 'che', profile: 'che',
instanceKey: 'default',
currentScenario: '2',
scenario: '2', scenario: '2',
apiPort: 15003, apiPort: 15003,
status: runtimeRunning ? 'RUNNING' : 'STOPPED', status: runtimeRunning ? 'RUNNING' : 'STOPPED',
@@ -69,7 +71,7 @@ const profile = (runtimeRunning: boolean, resetDefaults?: Record<string, unknown
activeOperation: null, activeOperation: null,
runtimeActions: [], runtimeActions: [],
runtime: { runtime: {
profileName: 'che:2', profileName: 'che:default',
frontendRunning: runtimeRunning, frontendRunning: runtimeRunning,
apiRunning: runtimeRunning, apiRunning: runtimeRunning,
daemonRunning: runtimeRunning, daemonRunning: runtimeRunning,
@@ -145,10 +147,7 @@ const installFixture = async (page: Page, state: FixtureState) => {
await route.abort('failed'); await route.abort('failed');
return; return;
} }
if ( if (names.includes('admin.releases.gatewayState') && (state.gatewayStateFailuresRemaining ?? 0) > 0) {
names.includes('admin.releases.gatewayState') &&
(state.gatewayStateFailuresRemaining ?? 0) > 0
) {
state.gatewayStateFailuresRemaining = (state.gatewayStateFailuresRemaining ?? 0) - 1; state.gatewayStateFailuresRemaining = (state.gatewayStateFailuresRemaining ?? 0) - 1;
state.gatewayStateFailureCount = (state.gatewayStateFailureCount ?? 0) + 1; state.gatewayStateFailureCount = (state.gatewayStateFailureCount ?? 0) + 1;
await route.fulfill({ await route.fulfill({
@@ -171,8 +170,10 @@ const installFixture = async (page: Page, state: FixtureState) => {
if (name === 'admin.profiles.listNavigation') { if (name === 'admin.profiles.listNavigation') {
return response([ return response([
{ {
profileName: 'che:2', profileName: 'che:default',
profile: 'che', profile: 'che',
instanceKey: 'default',
currentScenario: '2',
meta: { korName: '천하서버' }, meta: { korName: '천하서버' },
}, },
]); ]);
@@ -314,7 +315,7 @@ const installFixture = async (page: Page, state: FixtureState) => {
if (name === 'admin.operations.requestReset') { if (name === 'admin.operations.requestReset') {
const operation: Operation = { const operation: Operation = {
id: '11111111-1111-4111-8111-111111111111', id: '11111111-1111-4111-8111-111111111111',
profileName: 'che:2', profileName: 'che:default',
type: 'RESET', type: 'RESET',
status: 'QUEUED', status: 'QUEUED',
sourceMode: 'COMMIT', sourceMode: 'COMMIT',
@@ -330,7 +331,7 @@ const installFixture = async (page: Page, state: FixtureState) => {
if (name === 'admin.operations.requestDeploy') { if (name === 'admin.operations.requestDeploy') {
const operation: Operation = { const operation: Operation = {
id: '66666666-6666-4666-8666-666666666666', id: '66666666-6666-4666-8666-666666666666',
profileName: 'che:2', profileName: 'che:default',
type: 'DEPLOY', type: 'DEPLOY',
status: 'QUEUED', status: 'QUEUED',
sourceMode: 'BRANCH', sourceMode: 'BRANCH',
@@ -368,7 +369,7 @@ const installFixture = async (page: Page, state: FixtureState) => {
type === 'START' type === 'START'
? '22222222-2222-4222-8222-222222222222' ? '22222222-2222-4222-8222-222222222222'
: '33333333-3333-4333-8333-333333333333', : '33333333-3333-4333-8333-333333333333',
profileName: 'che:2', profileName: 'che:default',
type, type,
status: 'SUCCEEDED', status: 'SUCCEEDED',
payload: {}, payload: {},
@@ -382,7 +383,7 @@ const installFixture = async (page: Page, state: FixtureState) => {
if (name === 'admin.operations.retry') { if (name === 'admin.operations.retry') {
const operation: Operation = { const operation: Operation = {
id: '44444444-4444-4444-8444-444444444444', id: '44444444-4444-4444-8444-444444444444',
profileName: 'che:2', profileName: 'che:default',
type: 'RESET', type: 'RESET',
status: 'QUEUED', status: 'QUEUED',
sourceMode: 'COMMIT', sourceMode: 'COMMIT',
@@ -420,9 +421,9 @@ test('separates branch and commit semantics and submits a reset from the dedicat
await installFixture(page, state); await installFixture(page, state);
page.on('dialog', (dialog) => dialog.accept()); 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.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-current')).toBeChecked();
await expect(page.getByTestId('source-help')).toContainText('현재 서버에 배포된 커밋'); await expect(page.getByTestId('source-help')).toContainText('현재 서버에 배포된 커밋');
await expect(page.getByTestId('scenario-select')).toHaveValue('2'); 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.getByText('초기화 작업을 등록했습니다.').first()).toBeVisible();
await expect(page.getByTestId('operations-table')).toContainText('RESET'); await expect(page.getByTestId('operations-table')).toContainText('RESET');
await expect(page.getByTestId('profile-operation-log-panel')).toBeVisible(); 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')).toContainText('시나리오 초기 데이터 생성을 완료했습니다.');
await expect(page.getByTestId('profile-operation-log-status')).toContainText('SUCCEEDED'); await expect(page.getByTestId('profile-operation-log-status')).toContainText('SUCCEEDED');
const operationTableGeometry = await page.getByTestId('operations-table').evaluate((table) => { 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); await installFixture(page, state);
page.on('dialog', (dialog) => dialog.accept()); 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.getByRole('heading', { name: 'DB 보존 버전 업데이트' })).toBeVisible();
await expect(page.getByText('운영 프로필', { exact: true })).toHaveCount(0); await expect(page.getByText('운영 프로필', { exact: true })).toHaveCount(0);
await expect(page.getByRole('link', { name: '버전 업데이트', exact: true })).toHaveAttribute( 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.getByText('DB 보존 배포 작업을 등록했습니다.').first()).toBeVisible();
await expect(page.getByTestId('operations-table')).toContainText('DEPLOY'); await expect(page.getByTestId('operations-table')).toContainText('DEPLOY');
await expect(page.getByTestId('profile-operation-log-panel')).toBeVisible(); 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')).toContainText('game-frontend build complete');
await expect(page.getByTestId('profile-operation-log-status')).toContainText('SUCCEEDED'); await expect(page.getByTestId('profile-operation-log-status')).toContainText('SUCCEEDED');
expect(state.requestBodies.some((entry) => entry.operation === 'admin.operations.requestDeploy')).toBe(true); 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); await installFixture(page, state);
page.on('dialog', (dialog) => dialog.accept()); 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 expect(page.getByTestId('reset-turn-term')).toHaveValue('20');
await page.getByText('고급 시나리오 옵션').click(); await page.getByText('고급 시나리오 옵션').click();
await expect(page.getByTestId('reset-defaults-source')).toContainText('서버의 메타'); 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: [] }; const state: FixtureState = { operations: [], gatewayOperations: [], runtimeRunning: true, requestBodies: [] };
await installFixture(page, state); await installFixture(page, state);
await page.goto('admin/servers/che%3A2'); await page.goto('admin/servers/che%3Adefault');
await page.getByText('서버 리셋 기본 옵션').click(); await page.getByText('서버 리셋 기본 옵션').click();
await page.getByTestId('meta-reset-turn-term').selectOption('10'); await page.getByTestId('meta-reset-turn-term').selectOption('10');
await page.getByTestId('meta-reset-npc-mode').selectOption('2'); 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 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.getByPlaceholder('변경 사유 (필수)').fill('exercise persistence error');
await page.getByRole('button', { name: '메타 저장' }).click(); 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 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 }); await expect(page.getByTestId('request-deploy')).toBeVisible({ timeout: 900 });
expect(state.profileNavigationResolved).toBe(false); expect(state.profileNavigationResolved).toBe(false);
await expect.poll(() => state.profileNavigationResolved).toBe(true); 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 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('scenario-select')).toContainText('선택할 수 있는 시나리오가 없습니다.');
await expect(page.getByTestId('request-reset')).toBeDisabled(); await expect(page.getByTestId('request-reset')).toBeDisabled();
await page.getByTestId('load-scenarios').click(); 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(); 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 = { const state: FixtureState = {
operations: [], operations: [],
gatewayOperations: [], gatewayOperations: [],
@@ -800,10 +803,42 @@ test('renders the server navigation before the detailed runtime profile request
}; };
await installFixture(page, state); await installFixture(page, state);
await page.goto('admin/servers/che%3A2'); await page.goto('admin/servers/che%3Adefault');
const navigation = page.getByRole('navigation', { name: '관리자 메뉴' }); 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(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); expect(state.profileNavigationRequests).toBe(1);
}); });
@@ -813,12 +848,12 @@ test('scenario-only operator resets the current version without Git or Gateway c
gatewayOperations: [], gatewayOperations: [],
runtimeRunning: true, runtimeRunning: true,
requestBodies: [], 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); await installFixture(page, state);
page.on('dialog', (dialog) => dialog.accept()); 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-current')).toBeChecked();
await expect(page.getByTestId('source-branch')).toHaveCount(0); await expect(page.getByTestId('source-branch')).toHaveCount(0);
await expect(page.getByTestId('source-commit')).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.detailWidth).toBeGreaterThanOrEqual(mobileGeometry.tableWidth - 1);
expect(mobileGeometry.scrollerScrollWidth).toBeLessThanOrEqual(mobileGeometry.scrollerWidth + 1); expect(mobileGeometry.scrollerScrollWidth).toBeLessThanOrEqual(mobileGeometry.scrollerWidth + 1);
expect(mobileGeometry.scrollerX).toBeGreaterThanOrEqual(0); expect(mobileGeometry.scrollerX).toBeGreaterThanOrEqual(0);
expect(mobileGeometry.scrollerX + mobileGeometry.scrollerWidth).toBeLessThanOrEqual( expect(mobileGeometry.scrollerX + mobileGeometry.scrollerWidth).toBeLessThanOrEqual(mobileGeometry.viewportWidth);
mobileGeometry.viewportWidth
);
expect(mobileGeometry.documentScrollWidth).toBeLessThanOrEqual(mobileGeometry.viewportWidth); expect(mobileGeometry.documentScrollWidth).toBeLessThanOrEqual(mobileGeometry.viewportWidth);
await page.screenshot({ path: testInfo.outputPath('gateway-release-error-expanded-mobile.png'), fullPage: true }); 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: [ operations: [
{ {
id: '55555555-5555-4555-8555-555555555555', id: '55555555-5555-4555-8555-555555555555',
profileName: 'che:2', profileName: 'che:default',
type: 'RESET', type: 'RESET',
status: 'FAILED', status: 'FAILED',
sourceMode: 'COMMIT', sourceMode: 'COMMIT',
@@ -1064,7 +1097,7 @@ test('renders a failed reset, retries it as a new operation, and reaches success
await installFixture(page, state); await installFixture(page, state);
page.on('dialog', (dialog) => dialog.accept()); 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.getByTestId('operations-table').getByText('FAILED', { exact: true })).toBeVisible();
await expect(page.getByRole('cell', { name: 'fedcba987654', exact: true })).toBeVisible(); await expect(page.getByRole('cell', { name: 'fedcba987654', exact: true })).toBeVisible();
const failure = page.getByTestId('operations-table').getByText(longError); 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[] }>> } }; capabilities: { list: { query: () => Promise<Array<{ permission: string; scopes?: string[] }>> } };
profiles: { profiles: {
listNavigation: { 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 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(() => const isRootAdmin = computed(() =>
(auth.user?.roles ?? []).some((role) => role === 'superuser' || role === 'admin' || role === 'admin.superuser') (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 profileLabel = (profile: (typeof profiles.value)[number]): string => {
const korName = profile.meta?.korName; 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(() => [ const navigation = computed(() => [
@@ -70,6 +87,7 @@ const navigation = computed(() => [
...profiles.value.map((profile) => ({ ...profiles.value.map((profile) => ({
to: `/admin/servers/${encodeURIComponent(profile.profileName)}`, to: `/admin/servers/${encodeURIComponent(profile.profileName)}`,
label: profileLabel(profile), label: profileLabel(profile),
title: `서버 ID: ${profile.profileName}`,
icon: '└', icon: '└',
exact: false, exact: false,
visible: true, visible: true,
@@ -156,6 +174,7 @@ onMounted(async () => {
:class="{ child: item.child }" :class="{ child: item.child }"
:active-class="item.exact ? '' : 'active'" :active-class="item.exact ? '' : 'active'"
:exact-active-class="item.exact ? 'active' : ''" :exact-active-class="item.exact ? 'active' : ''"
:title="'title' in item ? item.title : undefined"
@click="menuOpen = false" @click="menuOpen = false"
> >
<span class="admin-nav-icon" aria-hidden="true">{{ item.icon }}</span> <span class="admin-nav-icon" aria-hidden="true">{{ item.icon }}</span>
+10 -2
View File
@@ -176,6 +176,9 @@ type AdminPublicUser = {
type AdminProfile = { type AdminProfile = {
profileName: string; profileName: string;
profile: string; profile: string;
instanceKey: string;
currentScenario: string | null;
/** @deprecated Rollback-compatible mirror of currentScenario. */
scenario: string; scenario: string;
status: string; status: string;
apiPort: number; apiPort: number;
@@ -2058,9 +2061,14 @@ onMounted(() => {
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-2"> <div class="flex flex-col md:flex-row md:items-center md:justify-between gap-2">
<div> <div>
<div class="text-base font-semibold"> <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>
<div class="text-xs text-zinc-500">시나리오: {{ profile.scenario }}</div>
</div> </div>
<div class="text-xs text-zinc-400"> <div class="text-xs text-zinc-400">
상태: {{ profile.status }} / API: {{ profile.runtime.apiRunning ? 'ON' : 'OFF' }} / 상태: {{ profile.status }} / API: {{ profile.runtime.apiRunning ? 'ON' : 'OFF' }} /
+5 -1
View File
@@ -31,6 +31,10 @@ Gateway 관리자 콘솔은 `/gateway/admin`에서 시작합니다. 공개 로
- 서버 관리는 profile별 하위 트리입니다. 상태·설정, DB 보존 버전 업데이트와 - 서버 관리는 profile별 하위 트리입니다. 상태·설정, DB 보존 버전 업데이트와
시나리오 초기화가 같은 서버 아래의 상단 탭으로 노출됩니다. 현재 탭은 색상과 시나리오 초기화가 같은 서버 아래의 상단 탭으로 노출됩니다. 현재 탭은 색상과
`aria-current`로 구분하며 desktop과 mobile에서 본문보다 먼저 표시합니다. `aria-current`로 구분하며 desktop과 mobile에서 본문보다 먼저 표시합니다.
- `profileName``${profile}:${instanceKey}` 형식의 불변 기술 ID입니다.
`che:default``default`는 현재 시나리오가 아니라 기본 인스턴스 키입니다.
좌측 메뉴는 기본 인스턴스의 suffix를 숨기고 표시명만 보여 주며, 상태 상세에서
기술 ID·인스턴스 키·nullable 현재 시나리오를 분리해 확인할 수 있습니다.
- 버전 업데이트와 시나리오 초기화 route는 URL의 `profileName`으로 대상 서버가 - 버전 업데이트와 시나리오 초기화 route는 URL의 `profileName`으로 대상 서버가
이미 고정됩니다. 따라서 작업 화면에서 전체 profile 목록이나 중복 실행 상태를 이미 고정됩니다. 따라서 작업 화면에서 전체 profile 목록이나 중복 실행 상태를
기다리지 않고 작업 form과 해당 서버의 operation 이력을 먼저 표시합니다. 상세 기다리지 않고 작업 form과 해당 서버의 operation 이력을 먼저 표시합니다. 상세
@@ -45,7 +49,7 @@ Gateway 관리자 콘솔은 `/gateway/admin`에서 시작합니다. 공개 로
업데이트가 필요하지 않습니다. 새 branch/commit과 함께 초기화하려면 초기화 업데이트가 필요하지 않습니다. 새 branch/commit과 함께 초기화하려면 초기화
권한과 버전 배포 권한이 모두 필요합니다. 권한과 버전 배포 권한이 모두 필요합니다.
- 현재 배포 버전의 시나리오 catalog는 capability·operation polling batch와 - 현재 배포 버전의 시나리오 catalog는 capability·operation polling batch와
분리된 요청으로 읽습니다. API가 profile의 현재 scenario를 표시하며 화면은 분리된 요청으로 읽습니다. API가 profile의 `currentScenario`를 표시하며 화면은
그 항목을 기본 선택합니다. scenario ID `0`도 유효한 값이고, 초기 요청이 그 항목을 기본 선택합니다. scenario ID `0`도 유효한 값이고, 초기 요청이
실패하면 현재 버전 모드에서 다시 확인할 수 있습니다. 실패하면 현재 버전 모드에서 다시 확인할 수 있습니다.
- 서버 상태의 `서버 리셋 기본 옵션``GatewayProfile.meta.resetDefaults` - 서버 상태의 `서버 리셋 기본 옵션``GatewayProfile.meta.resetDefaults`
+7
View File
@@ -131,6 +131,13 @@ Gateway는 자기 process를 직접 교체하지 않습니다. 관리자 화면
확인합니다. 확인합니다.
6. 모두 준비된 경우에만 현재·이전 commit과 workspace를 게시합니다. 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`, release-controller는 PM2 process 안에서 실행되므로 부모의 `args`, `pm_id`,
`pm_exec_path`, `name`, `NODE_APP_INSTANCE``axm_*` 같은 PM2 내부 값을 자식 `pm_exec_path`, `name`, `NODE_APP_INSTANCE``axm_*` 같은 PM2 내부 값을 자식
환경으로 전달하지 않습니다. 특히 부모의 `args=daemon`이 frontend의 환경으로 전달하지 않습니다. 특히 부모의 `args=daemon`이 frontend의
@@ -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"();
+5 -1
View File
@@ -208,6 +208,10 @@ model LegacyRootKeyValue {
model GatewayProfile { model GatewayProfile {
profileName String @id @map("profile_name") profileName String @id @map("profile_name")
profile String 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 scenario String
apiPort Int @map("api_port") apiPort Int @map("api_port")
status GatewayProfileStatus status GatewayProfileStatus
@@ -229,7 +233,7 @@ model GatewayProfile {
operations GatewayOperation[] operations GatewayOperation[]
runtimeActions GatewayRuntimeAction[] runtimeActions GatewayRuntimeAction[]
@@unique([profile, scenario]) @@unique([profile, instanceKey])
@@map("gateway_profile") @@map("gateway_profile")
} }
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"formatVersion": 1, "formatVersion": 1,
"controllerProtocol": 2, "controllerProtocol": 2,
"gatewaySchemaHead": "20260811000000_add_gateway_operation_logs", "gatewaySchemaHead": "20260813000000_split_gateway_profile_identity",
"gameSchemaHead": "20260803000000_add_logical_game_clock", "gameSchemaHead": "20260803000000_add_logical_game_clock",
"components": ["gateway-api", "gateway-frontend", "release-controller", "game-api", "game-engine", "game-frontend"] "components": ["gateway-api", "gateway-frontend", "release-controller", "game-api", "game-engine", "game-frontend"]
} }