merge: 최신 main을 레거시 DB 재이관에 통합
# Conflicts: # app/gateway-api/test/releaseManifest.test.ts # release-manifest.json
This commit is contained in:
@@ -71,6 +71,13 @@ const zParticipant = z.object({
|
||||
gl: z.number().int().optional(),
|
||||
seedRank: z.number().int().optional(),
|
||||
finalRank: z.number().int().optional(),
|
||||
preliminaryGroupId: z.number().int().min(0).max(7).optional(),
|
||||
preliminaryGroupNo: z.number().int().min(0).max(7).optional(),
|
||||
preliminaryRank: z.number().int().min(1).max(8).optional(),
|
||||
preliminaryWin: z.number().int().min(0).optional(),
|
||||
preliminaryDraw: z.number().int().min(0).optional(),
|
||||
preliminaryLose: z.number().int().min(0).optional(),
|
||||
preliminaryGl: z.number().int().optional(),
|
||||
});
|
||||
|
||||
const zMatch = z.object({
|
||||
|
||||
@@ -34,6 +34,13 @@ export interface TournamentParticipantEntry {
|
||||
gl?: number;
|
||||
seedRank?: number;
|
||||
finalRank?: number;
|
||||
preliminaryGroupId?: number;
|
||||
preliminaryGroupNo?: number;
|
||||
preliminaryRank?: number;
|
||||
preliminaryWin?: number;
|
||||
preliminaryDraw?: number;
|
||||
preliminaryLose?: number;
|
||||
preliminaryGl?: number;
|
||||
}
|
||||
|
||||
export interface TournamentMatchEntry {
|
||||
|
||||
@@ -186,6 +186,8 @@ export const applyPreBattleStage = async (
|
||||
gl: 0,
|
||||
seedRank: 0,
|
||||
finalRank: 0,
|
||||
preliminaryGroupId: entry.groupId,
|
||||
preliminaryGroupNo: entry.groupNo,
|
||||
}));
|
||||
await store.setParticipants(grouped);
|
||||
const nextState: TournamentState = {
|
||||
@@ -244,10 +246,17 @@ export const applyPreBattleStage = async (
|
||||
for (let groupId = 0; groupId < 8; groupId += 1) {
|
||||
const groupEntries = ranked.filter((entry) => entry.groupId === groupId);
|
||||
const ordered = sortByRanking(groupEntries);
|
||||
ordered.slice(0, 4).forEach((entry, idx) => {
|
||||
ordered.forEach((entry, idx) => {
|
||||
const target = ranked.find((item) => item.id === entry.id);
|
||||
if (target) {
|
||||
target.seedRank = idx + 1;
|
||||
target.preliminaryGroupId = groupId;
|
||||
target.preliminaryGroupNo = entry.groupNo;
|
||||
target.preliminaryRank = idx + 1;
|
||||
target.preliminaryWin = entry.win ?? 0;
|
||||
target.preliminaryDraw = entry.draw ?? 0;
|
||||
target.preliminaryLose = entry.lose ?? 0;
|
||||
target.preliminaryGl = entry.gl ?? 0;
|
||||
target.seedRank = idx < 4 ? idx + 1 : 0;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -195,17 +195,20 @@ export const assignManualApplicantGroup = (options: {
|
||||
extraSeed: `manual-group:${options.current.map((entry) => entry.id).join('-')}:${openGroupIds.join('-')}`,
|
||||
});
|
||||
const groupId = rng.choice(openGroupIds);
|
||||
const groupNo = groupCounts[groupId] ?? 0;
|
||||
|
||||
return {
|
||||
...options.applicant,
|
||||
groupId,
|
||||
groupNo: groupCounts[groupId] ?? 0,
|
||||
groupNo,
|
||||
win: 0,
|
||||
draw: 0,
|
||||
lose: 0,
|
||||
gl: 0,
|
||||
seedRank: 0,
|
||||
finalRank: 0,
|
||||
preliminaryGroupId: groupId,
|
||||
preliminaryGroupNo: groupNo,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -249,6 +249,7 @@ describe('tournament router permissions and mutations', () => {
|
||||
expect(results.filter((result) => result.status === 'rejected')).toHaveLength(1);
|
||||
const summary = await caller.tournament.getBettingSummary();
|
||||
expect(summary.myAmount).toBe(600);
|
||||
expect(Object.values(summary.myTotals)).toEqual([600]);
|
||||
expect(summary.totalAmount).toBe(600);
|
||||
expect(transport.gold.get(general.id)).toBe(2_400);
|
||||
});
|
||||
|
||||
@@ -246,8 +246,9 @@ describe('tournament worker schedule compatibility', () => {
|
||||
groupNo,
|
||||
};
|
||||
});
|
||||
const groupCounts = Array.from({ length: 8 }, (_, groupId) =>
|
||||
current.filter((entry) => entry.groupId === groupId).length
|
||||
const groupCounts = Array.from(
|
||||
{ length: 8 },
|
||||
(_, groupId) => current.filter((entry) => entry.groupId === groupId).length
|
||||
);
|
||||
const openGroupId = groupCounts.findIndex((count) => count === 7);
|
||||
expect(openGroupId).toBeGreaterThanOrEqual(0);
|
||||
@@ -267,7 +268,16 @@ describe('tournament worker schedule compatibility', () => {
|
||||
},
|
||||
});
|
||||
|
||||
expect(applicant).toMatchObject({ groupId: openGroupId, groupNo: 7, win: 0, draw: 0, lose: 0, gl: 0 });
|
||||
expect(applicant).toMatchObject({
|
||||
groupId: openGroupId,
|
||||
groupNo: 7,
|
||||
preliminaryGroupId: openGroupId,
|
||||
preliminaryGroupNo: 7,
|
||||
win: 0,
|
||||
draw: 0,
|
||||
lose: 0,
|
||||
gl: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('catches up from the stored schedule instead of discarding elapsed legacy phases', () => {
|
||||
@@ -334,6 +344,7 @@ describe('tournament worker (in-memory)', () => {
|
||||
expect(entries.map((entry) => entry.groupNo).sort((a, b) => Number(a) - Number(b))).toEqual([
|
||||
0, 1, 2, 3, 4, 5, 6, 7,
|
||||
]);
|
||||
expect(entries.every((entry) => entry.preliminaryGroupId === groupId)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -648,14 +659,30 @@ describe('tournament worker (in-memory)', () => {
|
||||
expect(participants.find((entry) => entry.id === 1)).toMatchObject({ groupId: expect.any(Number) });
|
||||
expect(participants.find((entry) => entry.id === 1001)).toMatchObject({ groupId: expect.any(Number) });
|
||||
expect(
|
||||
Array.from({ length: 8 }, (_, groupId) =>
|
||||
participants.filter((entry) => entry.groupId === groupId).length
|
||||
)
|
||||
Array.from({ length: 8 }, (_, groupId) => participants.filter((entry) => entry.groupId === groupId).length)
|
||||
).toEqual(Array.from({ length: 8 }, () => 8));
|
||||
|
||||
await store.setState(afterJoin);
|
||||
const finalState = await runTournamentToCompletion({ store, prisma, baseSeed: 'seed' });
|
||||
const finalParticipants = await store.getParticipants();
|
||||
expect(finalState.stage).toBe(0);
|
||||
expect(finalState.winnerId).toBeDefined();
|
||||
for (let groupId = 0; groupId < 8; groupId += 1) {
|
||||
const preliminaryEntries = finalParticipants
|
||||
.filter((entry) => entry.preliminaryGroupId === groupId)
|
||||
.sort((lhs, rhs) => (lhs.preliminaryRank ?? 99) - (rhs.preliminaryRank ?? 99));
|
||||
expect(preliminaryEntries).toHaveLength(8);
|
||||
expect(preliminaryEntries.map((entry) => entry.preliminaryRank)).toEqual([1, 2, 3, 4, 5, 6, 7, 8]);
|
||||
expect(
|
||||
preliminaryEntries.every(
|
||||
(entry) =>
|
||||
entry.preliminaryGroupNo !== undefined &&
|
||||
entry.preliminaryWin !== undefined &&
|
||||
entry.preliminaryDraw !== undefined &&
|
||||
entry.preliminaryLose !== undefined &&
|
||||
entry.preliminaryGl !== undefined
|
||||
)
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -265,6 +265,71 @@ const commandTable = {
|
||||
],
|
||||
inputOptions,
|
||||
};
|
||||
const basicRecruitmentCrewTypes = [
|
||||
{
|
||||
id: 1100,
|
||||
armType: 1,
|
||||
name: '보병',
|
||||
attack: 100,
|
||||
defence: 150,
|
||||
speed: 7,
|
||||
avoid: 10,
|
||||
baseCost: 9,
|
||||
baseRice: 9,
|
||||
info: ['표준적인 보병입니다.'],
|
||||
},
|
||||
{
|
||||
id: 1200,
|
||||
armType: 2,
|
||||
name: '궁병',
|
||||
attack: 100,
|
||||
defence: 100,
|
||||
speed: 7,
|
||||
avoid: 20,
|
||||
baseCost: 10,
|
||||
baseRice: 10,
|
||||
info: ['표준적인 궁병입니다.'],
|
||||
},
|
||||
{
|
||||
id: 1300,
|
||||
armType: 3,
|
||||
name: '기병',
|
||||
attack: 150,
|
||||
defence: 100,
|
||||
speed: 7,
|
||||
avoid: 5,
|
||||
baseCost: 11,
|
||||
baseRice: 11,
|
||||
info: ['표준적인 기병입니다.'],
|
||||
},
|
||||
{
|
||||
id: 1400,
|
||||
armType: 4,
|
||||
name: '귀병',
|
||||
attack: 80,
|
||||
defence: 80,
|
||||
speed: 7,
|
||||
avoid: 5,
|
||||
baseCost: 9,
|
||||
baseRice: 9,
|
||||
info: ['계략을 사용하는 병종입니다.'],
|
||||
},
|
||||
].map((crewType) => ({ ...crewType, available: true, special: false }));
|
||||
const fourArmRecruitmentCommandTable = {
|
||||
...commandTable,
|
||||
inputOptions: {
|
||||
...inputOptions,
|
||||
crewTypes: basicRecruitmentCrewTypes.map((crewType) => ({ value: crewType.id, label: crewType.name })),
|
||||
recruitment: {
|
||||
...inputOptions.recruitment,
|
||||
groups: basicRecruitmentCrewTypes.map((crewType) => ({
|
||||
armType: crewType.armType,
|
||||
armName: crewType.name,
|
||||
values: [crewType],
|
||||
})),
|
||||
},
|
||||
},
|
||||
};
|
||||
const buildSimpleCommand = (key: string, name: string) => ({
|
||||
key,
|
||||
name,
|
||||
@@ -379,14 +444,25 @@ const generalContext = {
|
||||
name: '아국',
|
||||
color: '#008000',
|
||||
level: 1,
|
||||
levelName: '호족',
|
||||
gold: 5000,
|
||||
rice: 6000,
|
||||
tech: 100,
|
||||
typeCode: 'che_중립',
|
||||
typeName: '중립',
|
||||
capitalCityId: 1,
|
||||
capitalCityName: '업',
|
||||
typePros: '',
|
||||
typeCons: '',
|
||||
population: { cityCount: 1, current: 1000, max: 2000 },
|
||||
crew: { generalCount: 2, current: 500, max: 7000 },
|
||||
power: 1234,
|
||||
bill: 100,
|
||||
taxRate: 20,
|
||||
strategicCommandLimit: 0,
|
||||
diplomaticLimit: 0,
|
||||
prohibitScout: false,
|
||||
prohibitWar: false,
|
||||
techLevel: 1,
|
||||
techLimited: false,
|
||||
topChiefs: {},
|
||||
impossibleStrategicCommands: [],
|
||||
},
|
||||
settings: {},
|
||||
penalties: {},
|
||||
@@ -1210,6 +1286,79 @@ test('uses a Ref-style full recruitment page without horizontal overflow on desk
|
||||
await expect.poll(() => page.evaluate(() => getComputedStyle(document.body).overflow)).not.toBe('hidden');
|
||||
});
|
||||
|
||||
test('keeps arbitrary direct recruitment and mercenary amounts for all four arms after turn refresh', async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const requests = await install(page, false, fourArmRecruitmentCommandTable);
|
||||
await page.setViewportSize({ width: 1200, height: 900 });
|
||||
await page.goto('/');
|
||||
|
||||
const entries = [
|
||||
{ turn: 1, command: '징병', crewTypeId: 1100, name: '보병', inputAmount: 13, savedAmount: 1300 },
|
||||
{ turn: 2, command: '징병', crewTypeId: 1200, name: '궁병', inputAmount: 27, savedAmount: 2700 },
|
||||
{ turn: 3, command: '징병', crewTypeId: 1300, name: '기병', inputAmount: 41, savedAmount: 4100 },
|
||||
{ turn: 4, command: '징병', crewTypeId: 1400, name: '귀병', inputAmount: 59, savedAmount: 5900 },
|
||||
{ turn: 5, command: '모병', crewTypeId: 1100, name: '보병', inputAmount: 17, savedAmount: 1700 },
|
||||
{ turn: 6, command: '모병', crewTypeId: 1200, name: '궁병', inputAmount: 31, savedAmount: 3100 },
|
||||
{ turn: 7, command: '모병', crewTypeId: 1300, name: '기병', inputAmount: 43, savedAmount: 4300 },
|
||||
{ turn: 8, command: '모병', crewTypeId: 1400, name: '귀병', inputAmount: 61, savedAmount: 6100 },
|
||||
];
|
||||
|
||||
for (const entry of entries) {
|
||||
await page.getByRole('button', { name: `${entry.turn}턴 명령 입력`, exact: true }).click();
|
||||
const picker = page.getByTestId('command-picker');
|
||||
await picker.getByRole('button', { name: '내정', exact: true }).click();
|
||||
await picker.getByRole('button', { name: entry.command, exact: true }).click();
|
||||
|
||||
const row = picker.getByRole('button', { name: `${entry.name} 선택 가능`, exact: true });
|
||||
const amountInput = row.locator('input[type=number]');
|
||||
await amountInput.fill(String(entry.inputAmount));
|
||||
await expect(amountInput).toHaveValue(String(entry.inputAmount));
|
||||
if (entry.turn === 1) {
|
||||
const inputGeometry = await amountInput.evaluate((element) => {
|
||||
if (!(element instanceof HTMLInputElement)) throw new Error('Expected recruitment amount input');
|
||||
const rect = element.getBoundingClientRect();
|
||||
return {
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
textAlign: getComputedStyle(element).textAlign,
|
||||
value: element.value,
|
||||
};
|
||||
});
|
||||
expect(inputGeometry).toMatchObject({ height: 28, textAlign: 'right', value: '13' });
|
||||
expect(inputGeometry.width).toBeGreaterThan(0);
|
||||
await picker.screenshot({ path: testInfo.outputPath('recruitment-direct-amount-desktop.png') });
|
||||
}
|
||||
await row.getByRole('button', { name: entry.command, exact: true }).click();
|
||||
|
||||
await expect(picker).toHaveCount(0);
|
||||
await expect(page.locator('[data-command-scope="general"] .action-column > div').nth(entry.turn - 1)).toHaveText(
|
||||
`【${entry.name}】 ${entry.savedAmount}명 ${entry.command}`
|
||||
);
|
||||
}
|
||||
|
||||
const refreshResponse = page.waitForResponse((apiResponse) =>
|
||||
decodeURIComponent(apiResponse.url()).includes('turns.reserved.getGeneral')
|
||||
);
|
||||
await page.getByRole('button', { name: '갱 신', exact: true }).click();
|
||||
await refreshResponse;
|
||||
|
||||
for (const entry of entries) {
|
||||
await expect(page.locator('[data-command-scope="general"] .action-column > div').nth(entry.turn - 1)).toHaveText(
|
||||
`【${entry.name}】 ${entry.savedAmount}명 ${entry.command}`
|
||||
);
|
||||
}
|
||||
await page
|
||||
.locator('[data-command-scope="general"]')
|
||||
.screenshot({ path: testInfo.outputPath('recruitment-arbitrary-amounts-after-refresh.png') });
|
||||
|
||||
const serializedRequests = JSON.stringify(requests);
|
||||
for (const entry of entries) {
|
||||
expect(serializedRequests).toContain(`"crewType":${entry.crewTypeId}`);
|
||||
expect(serializedRequests).toContain(`"amount":${entry.savedAmount}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('uses the map to choose a nation target in the chief command window', async ({ page }) => {
|
||||
await install(page);
|
||||
await page.setViewportSize({ width: 1200, height: 900 });
|
||||
|
||||
@@ -995,6 +995,123 @@ test('desktop menus preserve ref columns, prefix-safe routes, and controlled dro
|
||||
await persistArtifact(page, `${basePath.slice(1)}-desktop-1200`);
|
||||
});
|
||||
|
||||
test('split buttons keep square inner corners and a single divider in every interaction state', async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const state: NavigationFixture = {
|
||||
officerLevel: 5,
|
||||
permission: 2,
|
||||
nationLevel: 3,
|
||||
stage: 1,
|
||||
npcMode: 1,
|
||||
scenarioTitle: '분할 버튼 이음새 검증 시나리오',
|
||||
generalMeCalls: 0,
|
||||
operations: [],
|
||||
nationColor: '#663399',
|
||||
};
|
||||
await installFixture(page, state);
|
||||
if (artifactRoot) await mkdir(resolve(artifactRoot), { recursive: true });
|
||||
|
||||
const measure = (main: Locator, toggle: Locator) =>
|
||||
Promise.all([
|
||||
main.evaluate((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
const style = getComputedStyle(element);
|
||||
return {
|
||||
rect: rect.toJSON(),
|
||||
borderRightWidth: style.borderRightWidth,
|
||||
borderTopLeftRadius: style.borderTopLeftRadius,
|
||||
borderTopRightRadius: style.borderTopRightRadius,
|
||||
borderBottomRightRadius: style.borderBottomRightRadius,
|
||||
borderBottomLeftRadius: style.borderBottomLeftRadius,
|
||||
};
|
||||
}),
|
||||
toggle.evaluate((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
const style = getComputedStyle(element);
|
||||
return {
|
||||
rect: rect.toJSON(),
|
||||
borderLeftWidth: style.borderLeftWidth,
|
||||
borderTopLeftRadius: style.borderTopLeftRadius,
|
||||
borderTopRightRadius: style.borderTopRightRadius,
|
||||
borderBottomRightRadius: style.borderBottomRightRadius,
|
||||
borderBottomLeftRadius: style.borderBottomLeftRadius,
|
||||
};
|
||||
}),
|
||||
]);
|
||||
|
||||
const expectAttached = async (main: Locator, toggle: Locator) => {
|
||||
const [mainStyle, toggleStyle] = await measure(main, toggle);
|
||||
expect(mainStyle).toMatchObject({
|
||||
borderRightWidth: '1px',
|
||||
borderTopLeftRadius: '5.25px',
|
||||
borderTopRightRadius: '0px',
|
||||
borderBottomRightRadius: '0px',
|
||||
borderBottomLeftRadius: '5.25px',
|
||||
});
|
||||
expect(toggleStyle).toMatchObject({
|
||||
borderLeftWidth: '0px',
|
||||
borderTopLeftRadius: '0px',
|
||||
borderTopRightRadius: '5.25px',
|
||||
borderBottomRightRadius: '5.25px',
|
||||
borderBottomLeftRadius: '0px',
|
||||
});
|
||||
expect(toggleStyle.rect.left).toBeCloseTo(mainStyle.rect.right, 2);
|
||||
};
|
||||
|
||||
for (const width of [1200, 500]) {
|
||||
await page.setViewportSize({ width, height: 900 });
|
||||
await waitForMain(page);
|
||||
const globalSplit = page.locator('.main-global-menu:visible .main-menu-split').first();
|
||||
const nationSplit = page.locator('.main-nation-menu:visible .nation-menu-split').first();
|
||||
const pairs: Array<[string, Locator, Locator]> = [
|
||||
[
|
||||
'global',
|
||||
globalSplit.locator('[data-navigation-id="board-community"]'),
|
||||
globalSplit.locator('[data-menu-id="boards"]'),
|
||||
],
|
||||
[
|
||||
'nation',
|
||||
nationSplit.locator('[data-navigation-id="auction-resource"]'),
|
||||
nationSplit.locator('[data-menu-id="auction"]'),
|
||||
],
|
||||
];
|
||||
|
||||
for (const [label, main, toggle] of pairs) {
|
||||
await expect(main).toBeVisible();
|
||||
await expect(toggle).toBeVisible();
|
||||
await page.mouse.move(width - 1, 899);
|
||||
await expectAttached(main, toggle);
|
||||
|
||||
await toggle.focus();
|
||||
await expect(toggle).toBeFocused();
|
||||
await expectAttached(main, toggle);
|
||||
|
||||
await toggle.hover();
|
||||
await expectAttached(main, toggle);
|
||||
|
||||
const box = await toggle.boundingBox();
|
||||
if (!box) throw new Error(`${label} split toggle is not measurable`);
|
||||
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
|
||||
await page.mouse.down();
|
||||
await expectAttached(main, toggle);
|
||||
await page.mouse.move(width - 1, 899);
|
||||
await page.mouse.up();
|
||||
|
||||
await toggle.click();
|
||||
await expect(toggle).toHaveAttribute('aria-expanded', 'true');
|
||||
await expectAttached(main, toggle);
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
await toggle.locator('..').screenshot({
|
||||
path: artifactRoot
|
||||
? resolve(artifactRoot, `${basePath.slice(1)}-${width}-${label}-split-button.png`)
|
||||
: testInfo.outputPath(`${width}-${label}-split-button.png`),
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('the repeated bottom global menu opens upward on the mobile document', async ({ page }, testInfo) => {
|
||||
const state: NavigationFixture = {
|
||||
officerLevel: 5,
|
||||
@@ -2107,16 +2224,24 @@ test('mobile bottom controls share Ref pressed geometry while their color bases
|
||||
await expect
|
||||
.poll(() => page.locator(manualRefreshSelector).evaluate((element) => getComputedStyle(element).boxShadow))
|
||||
.not.toBe('none');
|
||||
const callsBeforeManualRefresh = state.generalMeCalls;
|
||||
await page.locator(manualRefreshSelector).click();
|
||||
await expect(page.locator(manualRefreshSelector)).toBeDisabled();
|
||||
await expect(page.locator(manualRefreshSelector)).toBeEnabled();
|
||||
await expect(page.locator(manualRefreshSelector)).toHaveAttribute('aria-busy', 'true');
|
||||
await page.locator(manualRefreshSelector).click();
|
||||
await expect(page.getByTestId('game-toast')).toContainText('이미 정보를 갱신하고 있습니다.');
|
||||
await page.mouse.move(1, 1);
|
||||
expect(await buttonStyle(manualRefreshSelector)).toMatchObject({
|
||||
backgroundColor: 'rgb(33, 37, 41)',
|
||||
borderBottomWidth: '4px',
|
||||
marginTop: '0px',
|
||||
height: 45,
|
||||
});
|
||||
await expect(page.locator(manualRefreshSelector)).toHaveCSS('opacity', '0.55');
|
||||
await expect(page.locator(manualRefreshSelector)).toHaveCSS('opacity', '1');
|
||||
await page.screenshot({ path: testInfo.outputPath('mobile-refresh-busy-toast.png'), fullPage: true });
|
||||
await expect(page.locator(manualRefreshSelector)).toHaveAttribute('aria-busy', 'false');
|
||||
await expect(page.locator(manualRefreshSelector)).toBeEnabled();
|
||||
expect(state.generalMeCalls).toBe(callsBeforeManualRefresh + 1);
|
||||
|
||||
await persistArtifact(page, `${basePath.slice(1)}-mobile-bottom-ref-buttons`);
|
||||
});
|
||||
@@ -2685,6 +2810,7 @@ test('realtime read-model events skip clock-only work, merge bursts, patch in pl
|
||||
|
||||
const operationsBeforeChangedBurst = state.operations.length;
|
||||
state.generalName = '부드럽게갱신된장수';
|
||||
state.refreshDelayMs = 1_000;
|
||||
await page.evaluate(() => {
|
||||
const emit = (window as unknown as { __emitMainRealtime: (type: string, payload: unknown) => void })
|
||||
.__emitMainRealtime;
|
||||
@@ -2706,7 +2832,20 @@ test('realtime read-model events skip clock-only work, merge bursts, patch in pl
|
||||
}
|
||||
});
|
||||
|
||||
const manualRefresh = page.getByRole('button', { name: '갱 신' });
|
||||
await expect(manualRefresh).toHaveAttribute('aria-busy', 'true');
|
||||
await expect(manualRefresh).toBeEnabled();
|
||||
await manualRefresh.click();
|
||||
await expect(page.getByTestId('game-toast')).toContainText('이미 정보를 갱신하고 있습니다.');
|
||||
if (autoRefreshArtifactRoot) {
|
||||
await page.screenshot({
|
||||
path: resolve(autoRefreshArtifactRoot, 'auto-refresh-busy-feedback.png'),
|
||||
fullPage: true,
|
||||
});
|
||||
}
|
||||
|
||||
await expect.poll(() => state.generalMeCalls, { timeout: 3_000 }).toBe(callsBeforeRefresh + 1);
|
||||
state.refreshDelayMs = 300;
|
||||
await expect(page.locator('[data-main-target="general"] .skeleton-line')).toHaveCount(0);
|
||||
await expect(page.locator('[data-main-target="city"] .skeleton-line')).toHaveCount(0);
|
||||
await expect(page.getByRole('button', { name: '갱 신' })).toHaveAttribute('aria-busy', 'false');
|
||||
|
||||
@@ -35,6 +35,7 @@ const names = [
|
||||
'허저',
|
||||
'주태',
|
||||
longGeneralName,
|
||||
...Array.from({ length: 48 }, (_, index) => `예선장수${index + 17}`),
|
||||
];
|
||||
const participants = names.map((name, index) => ({
|
||||
id: index + 1,
|
||||
@@ -45,13 +46,20 @@ const participants = names.map((name, index) => ({
|
||||
level: 10,
|
||||
picture: 'default.jpg',
|
||||
imageServer: 0,
|
||||
groupId: 10 + (index % 8),
|
||||
groupId: Math.floor(index / 8) < 4 ? 10 + (index % 8) : index % 8,
|
||||
groupNo: Math.floor(index / 8),
|
||||
win: 3 - (index % 2),
|
||||
win: Math.floor(index / 8) < 4 ? 3 - (index % 2) : 7 - Math.floor(index / 8),
|
||||
draw: index % 2,
|
||||
lose: 0,
|
||||
gl: 12 - index,
|
||||
lose: Math.floor(index / 8) < 4 ? 0 : Math.floor(index / 8),
|
||||
gl: 64 - index,
|
||||
finalRank: Math.floor(index / 8) + 1,
|
||||
preliminaryGroupId: index % 8,
|
||||
preliminaryGroupNo: Math.floor(index / 8),
|
||||
preliminaryRank: Math.floor(index / 8) + 1,
|
||||
preliminaryWin: 7 - Math.floor(index / 8),
|
||||
preliminaryDraw: index % 2,
|
||||
preliminaryLose: Math.floor(index / 8),
|
||||
preliminaryGl: 64 - index,
|
||||
}));
|
||||
const matches = [
|
||||
...Array.from({ length: 8 }, (_, index) => ({
|
||||
@@ -181,11 +189,11 @@ const installFixture = async (page: Page, options: { applicationOpen?: boolean }
|
||||
if (operation === 'tournament.getBettingSummary') {
|
||||
return response({
|
||||
totals: Object.fromEntries(
|
||||
participants.map((participant, index) => [participant.id, 100 + index * 10])
|
||||
participants.slice(0, 16).map((participant, index) => [participant.id, 100 + index * 10])
|
||||
),
|
||||
myTotals: {},
|
||||
myTotals: { 1: 120, 2: 40 },
|
||||
totalAmount: 2800,
|
||||
myAmount: 0,
|
||||
myAmount: 160,
|
||||
});
|
||||
}
|
||||
if (operation === 'tournament.getRankings') {
|
||||
@@ -297,6 +305,14 @@ test('desktop bracket connects every real general slot to the next round', async
|
||||
expect(oddsContainment.cardHeight).toBeGreaterThanOrEqual(82);
|
||||
expect(oddsContainment.oddsTop).toBeGreaterThanOrEqual(oddsContainment.cardTop);
|
||||
expect(oddsContainment.oddsBottom).toBeLessThanOrEqual(oddsContainment.cardBottom);
|
||||
await expect(firstSlot.locator('.bracket-my-bet')).toHaveText('내 투자 금120');
|
||||
|
||||
const preliminaryTables = page.locator('.preliminary-grid table');
|
||||
await expect(preliminaryTables).toHaveCount(8);
|
||||
for (let groupIndex = 0; groupIndex < 8; groupIndex += 1) {
|
||||
await expect(preliminaryTables.nth(groupIndex).locator('tbody tr')).toHaveCount(8);
|
||||
await expect(preliminaryTables.nth(groupIndex).locator('.general-identity')).toHaveCount(8);
|
||||
}
|
||||
|
||||
await persistScreenshot(page, 'tournament-desktop', testInfo.outputPath('tournament-bracket-desktop.webp'));
|
||||
});
|
||||
@@ -413,6 +429,7 @@ test('mobile bracket exposes every round through tabs with standard horizontal i
|
||||
});
|
||||
expect(mobileOddsContainment.cardHeight).toBeGreaterThanOrEqual(82);
|
||||
expect(mobileOddsContainment.oddsBottom).toBeLessThanOrEqual(mobileOddsContainment.cardBottom);
|
||||
await expect(firstMobileSlot.locator('.bracket-my-bet')).toHaveText('내 투자 금120');
|
||||
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');
|
||||
@@ -420,12 +437,39 @@ test('mobile bracket exposes every round through tabs with standard horizontal i
|
||||
await persistScreenshot(page, 'tournament-mobile', testInfo.outputPath('tournament-bracket-mobile.webp'));
|
||||
});
|
||||
|
||||
test('tournament and betting pages expose same-row navigation tabs beside close', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await openTournament(page);
|
||||
|
||||
const navigation = page.getByRole('tablist', { name: '토너먼트와 베팅장 이동' });
|
||||
const tournamentTab = navigation.getByRole('tab', { name: '토너먼트' });
|
||||
const bettingTab = navigation.getByRole('tab', { name: '베팅장' });
|
||||
const close = page.getByRole('button', { name: '창 닫기' }).first();
|
||||
await expect(tournamentTab).toHaveAttribute('aria-selected', 'true');
|
||||
|
||||
const headerCenters = await Promise.all(
|
||||
[tournamentTab, bettingTab, close].map(async (control) => {
|
||||
const box = await control.boundingBox();
|
||||
return box ? box.y + box.height / 2 : -1;
|
||||
})
|
||||
);
|
||||
expect(Math.max(...headerCenters) - Math.min(...headerCenters)).toBeLessThan(1);
|
||||
|
||||
await bettingTab.click();
|
||||
await expect(page).toHaveURL(/\/betting$/);
|
||||
await expect(page.getByRole('tab', { name: '베팅장' })).toHaveAttribute('aria-selected', 'true');
|
||||
await page.getByRole('tab', { name: '토너먼트' }).click();
|
||||
await expect(page).toHaveURL(/\/tournament$/);
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(390);
|
||||
});
|
||||
|
||||
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.locator('.betting-bracket .bracket-my-bet').first()).toHaveText('내 투자 금120');
|
||||
await expect(page.getByRole('tablist', { name: '토너먼트 랭킹 종목 선택' })).toBeVisible();
|
||||
await expect(page.locator('.ranking-table:visible')).toHaveCount(1);
|
||||
await page.getByRole('tab', { name: '통솔전' }).click();
|
||||
|
||||
@@ -153,3 +153,18 @@
|
||||
border-color: var(--legacy-button-border);
|
||||
background: var(--legacy-button-bg);
|
||||
}
|
||||
|
||||
/*
|
||||
* Split controls share one outer silhouette. Keep the main control's right
|
||||
* border as the subtle divider while removing every inner corner and the
|
||||
* toggle's overlapping left border. These rules intentionally follow the
|
||||
* Lumen family so its border shorthand cannot restore the inner rounding.
|
||||
*/
|
||||
.legacy-split-button > .main-menu-link {
|
||||
border-radius: 5.25px 0 0 5.25px;
|
||||
}
|
||||
|
||||
.legacy-split-button > .legacy-split-button__toggle {
|
||||
border-left-width: 0;
|
||||
border-radius: 0 5.25px 5.25px 0;
|
||||
}
|
||||
|
||||
@@ -69,7 +69,7 @@ const updateAmount = (event: Event) => {
|
||||
};
|
||||
|
||||
const submit = async (crewType?: RecruitmentCrewType) => {
|
||||
if (crewType) selectCrewType(crewType);
|
||||
if (crewType && crewType.id !== selectedCrewTypeId.value) selectCrewType(crewType);
|
||||
await nextTick();
|
||||
if (valid.value) emit('submit');
|
||||
};
|
||||
|
||||
@@ -59,7 +59,7 @@ const isActive = (link: MainNavigationLinkItem) => link.id === 'survey' && props
|
||||
</template>
|
||||
</ul>
|
||||
</div>
|
||||
<div v-else class="main-menu-split">
|
||||
<div v-else class="main-menu-split legacy-split-button">
|
||||
<MainNavigationLink
|
||||
:link="entry.main"
|
||||
:enabled="isNavigationConfigured(entry.main)"
|
||||
@@ -67,7 +67,7 @@ const isActive = (link: MainNavigationLinkItem) => link.id === 'survey' && props
|
||||
lumen-variant="navigation"
|
||||
/>
|
||||
<button
|
||||
class="main-menu-button main-menu-split__toggle legacy-button legacy-button--navigation"
|
||||
class="main-menu-button main-menu-split__toggle legacy-split-button__toggle legacy-button legacy-button--navigation"
|
||||
type="button"
|
||||
:data-menu-id="entry.id"
|
||||
:aria-label="`${entry.main.label} 하위 메뉴`"
|
||||
@@ -131,12 +131,6 @@ const isActive = (link: MainNavigationLinkItem) => link.id === 'survey' && props
|
||||
min-width: 28px;
|
||||
width: 28px;
|
||||
padding: 0;
|
||||
border-left-width: 0;
|
||||
border-radius: 0 5.25px 5.25px 0;
|
||||
}
|
||||
|
||||
.main-menu-split > :deep(.main-menu-link) {
|
||||
border-radius: 5.25px 0 0 5.25px;
|
||||
}
|
||||
|
||||
.menu-caret {
|
||||
|
||||
@@ -226,7 +226,6 @@ const onQuick = (item: QuickNavigationItem) => {
|
||||
data-bottom-menu="manual-refresh"
|
||||
aria-label="직접 갱신"
|
||||
title="직접 갱신"
|
||||
:disabled="refreshing"
|
||||
:aria-busy="refreshing"
|
||||
@click="emit('refresh')"
|
||||
>
|
||||
@@ -306,12 +305,6 @@ const onQuick = (item: QuickNavigationItem) => {
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.manual-refresh-trigger:disabled {
|
||||
cursor: wait;
|
||||
filter: grayscale(0.6);
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.bottom-trigger.legacy-button:not(:disabled, [aria-disabled='true']):hover,
|
||||
.bottom-trigger.legacy-button:not(:disabled, [aria-disabled='true'])[aria-expanded='true'] {
|
||||
height: 44px;
|
||||
|
||||
@@ -35,7 +35,7 @@ const isActive = (link: MainNavigationLinkItem) => link.highlightStage === props
|
||||
:active="isActive(entry)"
|
||||
lumen-variant="lumen"
|
||||
/>
|
||||
<div v-else-if="entry.kind === 'split'" class="nation-menu-split">
|
||||
<div v-else-if="entry.kind === 'split'" class="nation-menu-split legacy-split-button">
|
||||
<MainNavigationLink
|
||||
:link="entry.main"
|
||||
:enabled="isNationNavigationEnabled(entry.main, access)"
|
||||
@@ -43,7 +43,7 @@ const isActive = (link: MainNavigationLinkItem) => link.highlightStage === props
|
||||
lumen-variant="lumen"
|
||||
/>
|
||||
<button
|
||||
class="main-menu-button nation-menu-split__toggle legacy-button legacy-button--lumen"
|
||||
class="main-menu-button nation-menu-split__toggle legacy-split-button__toggle legacy-button legacy-button--lumen"
|
||||
type="button"
|
||||
:data-menu-id="entry.id"
|
||||
:aria-label="`${entry.main.label} 하위 메뉴`"
|
||||
@@ -108,12 +108,6 @@ const isActive = (link: MainNavigationLinkItem) => link.highlightStage === props
|
||||
min-width: 28px;
|
||||
width: 28px;
|
||||
padding: 0;
|
||||
border-left-width: 0;
|
||||
border-radius: 0 5.25px 5.25px 0;
|
||||
}
|
||||
|
||||
.nation-menu-split > :deep(.main-menu-link) {
|
||||
border-radius: 5.25px 0 0 5.25px;
|
||||
}
|
||||
|
||||
.menu-caret {
|
||||
|
||||
@@ -12,6 +12,7 @@ const props = defineProps<{
|
||||
matches: TournamentBracketMatch[];
|
||||
winnerId?: number;
|
||||
betTotals?: Record<number, number>;
|
||||
myBetTotals?: Record<number, number>;
|
||||
totalBet: number;
|
||||
showLegend?: boolean;
|
||||
}>();
|
||||
@@ -66,6 +67,7 @@ const odds = (id: number | null) => {
|
||||
if (!amount) return '∞';
|
||||
return (props.totalBet / amount).toFixed(2);
|
||||
};
|
||||
const myBet = (id: number | null) => (id === null ? 0 : (props.myBetTotals?.[id] ?? 0));
|
||||
const mobilePairs = computed(() => {
|
||||
const column = roundColumns.value[activeMobileRound.value] ?? [];
|
||||
if (activeMobileRound.value === roundColumns.value.length - 1) return column.map((slot) => [slot]);
|
||||
@@ -116,7 +118,10 @@ const mobilePairs = computed(() => {
|
||||
}"
|
||||
>
|
||||
<GeneralIdentity :name="slot.name" :picture="slot.picture" :image-server="slot.imageServer" />
|
||||
<small v-if="columnIndex === 0" class="bracket-odds">배당 {{ odds(slot.id) }}</small>
|
||||
<div v-if="columnIndex === 0" class="bracket-bet-summary">
|
||||
<small class="bracket-odds">배당 {{ odds(slot.id) }}</small>
|
||||
<small class="bracket-my-bet">내 투자 금{{ myBet(slot.id) }}</small>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
</div>
|
||||
@@ -146,7 +151,10 @@ const mobilePairs = computed(() => {
|
||||
:data-general-id="slot.id ?? undefined"
|
||||
>
|
||||
<GeneralIdentity :name="slot.name" :picture="slot.picture" :image-server="slot.imageServer" />
|
||||
<small v-if="activeMobileRound === 0" class="bracket-odds">배당 {{ odds(slot.id) }}</small>
|
||||
<div v-if="activeMobileRound === 0" class="bracket-bet-summary">
|
||||
<small class="bracket-odds">배당 {{ odds(slot.id) }}</small>
|
||||
<small class="bracket-my-bet">내 투자 금{{ myBet(slot.id) }}</small>
|
||||
</div>
|
||||
</div>
|
||||
<strong v-if="pair.length === 2" class="versus" aria-hidden="true">VS</strong>
|
||||
</article>
|
||||
@@ -220,12 +228,26 @@ const mobilePairs = computed(() => {
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
.bracket-odds {
|
||||
.bracket-bet-summary {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 4px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.bracket-odds,
|
||||
.bracket-my-bet {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
color: skyblue;
|
||||
font-size: 11px;
|
||||
line-height: 12px;
|
||||
text-align: right;
|
||||
}
|
||||
.bracket-my-bet {
|
||||
overflow: hidden;
|
||||
color: orange;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.mobile-bracket {
|
||||
display: none;
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
activePage: 'tournament' | 'betting';
|
||||
title: string;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="tournament-page-header">
|
||||
<strong class="tournament-page-title">{{ title }}</strong>
|
||||
<div class="tournament-page-actions">
|
||||
<nav class="tournament-page-tabs" role="tablist" aria-label="토너먼트와 베팅장 이동">
|
||||
<RouterLink v-slot="{ navigate }" custom to="/tournament">
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
:aria-selected="activePage === 'tournament'"
|
||||
:class="{ active: activePage === 'tournament' }"
|
||||
@click="navigate"
|
||||
>
|
||||
토너먼트
|
||||
</button>
|
||||
</RouterLink>
|
||||
<RouterLink v-slot="{ navigate }" custom to="/betting">
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
:aria-selected="activePage === 'betting'"
|
||||
:class="{ active: activePage === 'betting' }"
|
||||
@click="navigate"
|
||||
>
|
||||
베팅장
|
||||
</button>
|
||||
</RouterLink>
|
||||
</nav>
|
||||
<RouterLink v-slot="{ navigate }" custom to="/">
|
||||
<button class="close-button" type="button" @click="navigate">창 닫기</button>
|
||||
</RouterLink>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.tournament-page-header {
|
||||
display: flex;
|
||||
min-height: 48px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 2px 4px;
|
||||
}
|
||||
.tournament-page-title {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
font-size: 16px;
|
||||
font-weight: 400;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.tournament-page-actions,
|
||||
.tournament-page-tabs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
button {
|
||||
height: 44px;
|
||||
margin: 0;
|
||||
border: 1px solid #666;
|
||||
border-radius: 5.25px;
|
||||
background: #444;
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
line-height: 18px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.tournament-page-tabs button {
|
||||
min-width: 72px;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
.tournament-page-tabs button.active {
|
||||
border-color: #f39c12;
|
||||
background: #8a5b13;
|
||||
}
|
||||
.close-button {
|
||||
width: 88px;
|
||||
padding: 10px 16px;
|
||||
border-color: #375a7f;
|
||||
background: #375a7f;
|
||||
}
|
||||
button:hover,
|
||||
button:focus {
|
||||
filter: brightness(1.25);
|
||||
}
|
||||
button:focus-visible {
|
||||
outline: 2px solid #f39c12;
|
||||
outline-offset: 1px;
|
||||
}
|
||||
@media (max-width: 800px) {
|
||||
.tournament-page-header {
|
||||
gap: 4px;
|
||||
padding-inline: 2px;
|
||||
}
|
||||
.tournament-page-title {
|
||||
font-size: 13px;
|
||||
}
|
||||
.tournament-page-actions,
|
||||
.tournament-page-tabs {
|
||||
gap: 2px;
|
||||
}
|
||||
.tournament-page-tabs button {
|
||||
min-width: 68px;
|
||||
padding-inline: 8px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -2,6 +2,7 @@
|
||||
import { formatServerDateTime } from '@sammo-ts/common';
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import TournamentBracket from '../components/tournament/TournamentBracket.vue';
|
||||
import TournamentPageHeader from '../components/tournament/TournamentPageHeader.vue';
|
||||
import GeneralIdentity from '../components/ui/GeneralIdentity.vue';
|
||||
import { trpc } from '../utils/trpc';
|
||||
|
||||
@@ -72,6 +73,7 @@ const candidates = computed(() =>
|
||||
const totalAmount = computed(() => summary.value?.totalAmount ?? 0);
|
||||
const myAmount = computed(() => summary.value?.myAmount ?? 0);
|
||||
const betTotals = computed(() => summary.value?.totals as Record<number, number> | undefined);
|
||||
const myBetTotals = computed(() => summary.value?.myTotals as Record<number, number> | undefined);
|
||||
const ratio = (id: number) => {
|
||||
const totals = summary.value?.totals as Record<number, number> | undefined;
|
||||
const amount = totals?.[id] ?? 0;
|
||||
@@ -110,12 +112,7 @@ const placeBet = async (targetId: number) => {
|
||||
|
||||
<template>
|
||||
<main id="tournament-betting-container" class="betting-page">
|
||||
<section class="title bg0">
|
||||
베 팅 장<br />
|
||||
<RouterLink v-slot="{ navigate }" custom to="/">
|
||||
<button class="close-button" type="button" @click="navigate">창 닫기</button>
|
||||
</RouterLink>
|
||||
</section>
|
||||
<TournamentPageHeader class="bg0" active-page="betting" title="베 팅 장" />
|
||||
<section class="toolbar bg0">
|
||||
<button type="button" @click="load">갱신</button>
|
||||
<span v-if="loading">불러오는 중...</span>
|
||||
@@ -138,6 +135,7 @@ const placeBet = async (targetId: number) => {
|
||||
:matches="snapshot?.matches ?? []"
|
||||
:winner-id="snapshot?.state?.winnerId"
|
||||
:bet-totals="betTotals"
|
||||
:my-bet-totals="myBetTotals"
|
||||
:total-bet="totalAmount"
|
||||
:show-legend="false"
|
||||
/>
|
||||
@@ -303,25 +301,6 @@ const placeBet = async (targetId: number) => {
|
||||
.bg2 {
|
||||
background: #142b42 var(--sammo-texture-blue);
|
||||
}
|
||||
.title {
|
||||
min-height: 68px;
|
||||
padding: 0;
|
||||
font-size: 14px;
|
||||
line-height: 19.1875px;
|
||||
}
|
||||
.close-button {
|
||||
display: block;
|
||||
width: 88px;
|
||||
height: 44px;
|
||||
padding: 10px 16px;
|
||||
border: 1px solid #375a7f;
|
||||
border-radius: 5.25px;
|
||||
background: #375a7f;
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
line-height: 18px;
|
||||
text-decoration: none;
|
||||
}
|
||||
.toolbar {
|
||||
min-height: 46px;
|
||||
padding: 1px;
|
||||
@@ -417,10 +396,6 @@ button:hover,
|
||||
button:focus {
|
||||
filter: brightness(1.25);
|
||||
}
|
||||
.close-button:hover,
|
||||
.close-button:focus {
|
||||
filter: brightness(1.2);
|
||||
}
|
||||
button:focus-visible,
|
||||
select:focus-visible {
|
||||
outline: 2px solid #f39c12;
|
||||
@@ -507,10 +482,6 @@ select:disabled {
|
||||
max-width: 100%;
|
||||
font-size: 13px;
|
||||
}
|
||||
.title {
|
||||
height: auto;
|
||||
min-height: 55px;
|
||||
}
|
||||
.state {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
@@ -20,11 +20,13 @@ import type { QuickNavigationItem } from '../components/main/mainNavigation';
|
||||
import { formatLog } from '../utils/formatLog';
|
||||
import { useSessionStore } from '../stores/session';
|
||||
import { useMainDashboardStore } from '../stores/mainDashboard';
|
||||
import { useGameFeedback } from '../composables/useGameFeedback';
|
||||
import { trpc } from '../utils/trpc';
|
||||
import type { CommandPatternEntry } from '../components/command/types';
|
||||
|
||||
const session = useSessionStore();
|
||||
const dashboard = useMainDashboardStore();
|
||||
const { info: showInfoToast } = useGameFeedback();
|
||||
const isMobile = useMediaQuery('(max-width: 939.98px)');
|
||||
|
||||
const npcMode = ref(0);
|
||||
@@ -115,6 +117,14 @@ const loadMainData = async () => {
|
||||
npcMode.value = worldState?.config.npcMode ?? 0;
|
||||
};
|
||||
|
||||
const requestManualRefresh = () => {
|
||||
if (refreshing.value) {
|
||||
showInfoToast('이미 정보를 갱신하고 있습니다.');
|
||||
return;
|
||||
}
|
||||
void loadMainData();
|
||||
};
|
||||
|
||||
const moveLobby = () => {
|
||||
window.location.replace(import.meta.env.VITE_GATEWAY_WEB_URL?.trim() || '/gateway/');
|
||||
};
|
||||
@@ -154,9 +164,8 @@ watch(
|
||||
<button
|
||||
class="game-shell__action legacy-button legacy-button--navigation"
|
||||
type="button"
|
||||
:disabled="refreshing"
|
||||
:aria-busy="refreshing"
|
||||
@click="loadMainData"
|
||||
@click="requestManualRefresh"
|
||||
>
|
||||
갱 신
|
||||
</button>
|
||||
@@ -451,7 +460,7 @@ watch(
|
||||
:npc-mode="npcMode"
|
||||
:realtime-enabled="realtimeEnabled"
|
||||
:refreshing="refreshing"
|
||||
@refresh="loadMainData"
|
||||
@refresh="requestManualRefresh"
|
||||
@toggle-realtime="dashboard.setRealtimeEnabled(!realtimeEnabled)"
|
||||
@lobby="moveLobby"
|
||||
@quick="moveQuick"
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { formatServerDateTime } from '@sammo-ts/common';
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import TournamentBracket from '../components/tournament/TournamentBracket.vue';
|
||||
import TournamentPageHeader from '../components/tournament/TournamentPageHeader.vue';
|
||||
import GeneralIdentity from '../components/ui/GeneralIdentity.vue';
|
||||
import { trpc } from '../utils/trpc';
|
||||
import { resolveTournamentStageName } from '../utils/tournamentStatus';
|
||||
@@ -58,6 +59,7 @@ const openingTime = computed(() =>
|
||||
formatServerDateTime(snapshot.value?.state?.nextAt, { format: 'hourMinute', fallback: '--:--' })
|
||||
);
|
||||
const betTotals = computed(() => betting.value?.totals as Record<number, number> | undefined);
|
||||
const myBetTotals = computed(() => betting.value?.myTotals as Record<number, number> | undefined);
|
||||
const isParticipant = computed(() =>
|
||||
(snapshot.value?.participants ?? []).some((participant) => participant.id === myGeneralId.value)
|
||||
);
|
||||
@@ -71,8 +73,25 @@ const groups = computed(() =>
|
||||
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))
|
||||
.filter((participant) => {
|
||||
const groupId =
|
||||
participant.preliminaryGroupId ??
|
||||
(participant.groupId !== undefined && participant.groupId < 8 ? participant.groupId : undefined);
|
||||
return groupId === index;
|
||||
})
|
||||
.map((participant) => ({
|
||||
...participant,
|
||||
groupNo: participant.preliminaryGroupNo ?? participant.groupNo,
|
||||
win: participant.preliminaryWin ?? participant.win,
|
||||
draw: participant.preliminaryDraw ?? participant.draw,
|
||||
lose: participant.preliminaryLose ?? participant.lose,
|
||||
gl: participant.preliminaryGl ?? participant.gl,
|
||||
}))
|
||||
.sort(
|
||||
(a, b) =>
|
||||
(a.preliminaryRank ?? a.seedRank ?? 99) - (b.preliminaryRank ?? b.seedRank ?? 99) ||
|
||||
(a.groupNo ?? 99) - (b.groupNo ?? 99)
|
||||
)
|
||||
)
|
||||
);
|
||||
const groupNames = ['一', '二', '三', '四', '五', '六', '七', '八'];
|
||||
@@ -141,12 +160,7 @@ const start = async () => {
|
||||
|
||||
<template>
|
||||
<main id="tournament-container" class="legacy-page">
|
||||
<section class="legacy-title bg0">
|
||||
<div>삼모전 토너먼트</div>
|
||||
<RouterLink v-slot="{ navigate }" custom to="/">
|
||||
<button class="close-button" type="button" @click="navigate">창 닫기</button>
|
||||
</RouterLink>
|
||||
</section>
|
||||
<TournamentPageHeader class="bg0" active-page="tournament" title="삼모전 토너먼트" />
|
||||
|
||||
<section class="toolbar bg0">
|
||||
<button type="button" @click="load">갱신</button>
|
||||
@@ -177,6 +191,7 @@ const start = async () => {
|
||||
:matches="snapshot?.matches ?? []"
|
||||
:winner-id="snapshot?.state?.winnerId"
|
||||
:bet-totals="betTotals"
|
||||
:my-bet-totals="myBetTotals"
|
||||
:total-bet="totalBet"
|
||||
/>
|
||||
|
||||
@@ -384,25 +399,6 @@ const start = async () => {
|
||||
.bg2 {
|
||||
background: #142b42 var(--sammo-texture-blue);
|
||||
}
|
||||
.legacy-title {
|
||||
min-height: 68px;
|
||||
padding: 0;
|
||||
font-size: 14px;
|
||||
line-height: 19.1875px;
|
||||
}
|
||||
.close-button {
|
||||
display: block;
|
||||
width: 88px;
|
||||
height: 44px;
|
||||
padding: 10px 16px;
|
||||
border: 1px solid #375a7f;
|
||||
border-radius: 5.25px;
|
||||
background: #375a7f;
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
line-height: 18px;
|
||||
text-decoration: none;
|
||||
}
|
||||
.toolbar {
|
||||
min-height: 46px;
|
||||
padding: 1px;
|
||||
@@ -433,10 +429,6 @@ button:hover,
|
||||
button:focus {
|
||||
filter: brightness(1.25);
|
||||
}
|
||||
.close-button:hover,
|
||||
.close-button:focus {
|
||||
filter: brightness(1.2);
|
||||
}
|
||||
button:focus-visible {
|
||||
outline: 2px solid #f39c12;
|
||||
outline-offset: 1px;
|
||||
@@ -532,10 +524,6 @@ td {
|
||||
max-width: 100%;
|
||||
font-size: 13px;
|
||||
}
|
||||
.legacy-title {
|
||||
height: auto;
|
||||
min-height: 55px;
|
||||
}
|
||||
.state-row {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user