feat: 토너먼트 조별 전투 로그를 복원한다

예선과 본선 조별 경기의 최신 전투 로그를 저장하고 화면에 여덟 조 모두 표시한다. 결선은 최근 완료 경기와 종료 후 결승 로그를 유지하며 기존 대진 난수 순서를 보존한다.
This commit is contained in:
2026-08-22 04:51:46 +00:00
parent a41eb16bb9
commit e7fed61fb3
9 changed files with 345 additions and 30 deletions
+125 -2
View File
@@ -86,8 +86,55 @@ const matches = [
defenderId: index * 8 + 5,
winnerId: index * 8 + 1,
})),
{ id: 15, stage: 10, roundIndex: 0, attackerId: 1, defenderId: 9, winnerId: 1 },
{
id: 15,
stage: 10,
roundIndex: 0,
attackerId: 1,
defenderId: 9,
winnerId: 1,
log: ['<S>●</> <Y>관우</> <C>(800)</> vs <C>(790)</> <Y>여포</>', '<S>●</> <Y>관우</> <S>우승</>!'],
},
];
const buildGroupFightMatches = (stage: 2 | 4) => {
const groupStart = stage === 2 ? 0 : 10;
return Array.from({ length: 8 }, (_, index) => ({
id: stage * 100 + groupStart + index + 1,
stage,
roundIndex: groupStart + index,
groupId: groupStart + index,
attackerId: index * 2 + 1,
defenderId: index * 2 + 2,
winnerId: index * 2 + 1,
log: [
`<S>●</> <Y>${names[index * 2]}</> <C>(800)</> vs <C>(790)</> <Y>${names[index * 2 + 1]}</>`,
'<S>●</> 01合 : <C>720</><span class="ev_highlight">(-080)</span> vs <span class="ev_highlight">(-090)</span><C>700</>',
`<S>●</> <Y>${names[index * 2]}</> <S>승리</>!`,
],
}));
};
const matchesForStage = (stage: number) => {
if (stage === 2 || stage === 3) {
return [...buildGroupFightMatches(2), ...matches];
}
if (stage === 4 || stage === 5) {
return [...buildGroupFightMatches(2), ...buildGroupFightMatches(4), ...matches];
}
if (stage === 7) {
return matches.map((match, index) =>
match.stage === 7 && index === 0
? {
...match,
log: [
'<S>●</> <Y>관우</> <C>(800)</> vs <C>(790)</> <Y>장료</>',
'<S>●</> <Y>관우</> <S>승리</>!',
],
}
: match
);
}
return matches;
};
const response = (data: unknown) => ({ result: { data } });
const asRecord = (value: unknown): Record<string, unknown> | null =>
@@ -205,7 +252,7 @@ const installFixture = async (
},
]
: participants,
matches,
matches: matchesForStage(tournamentStage),
betCount: 16,
});
}
@@ -441,6 +488,82 @@ test('final group section appears before the later knockout section', async ({ p
await persistScreenshot(page, 'tournament-final-stage-mobile', testInfo.outputPath('tournament-final-stage.webp'));
});
test('preliminary stage renders the latest fight log for all eight groups', async ({ page }, testInfo) => {
await page.setViewportSize({ width: 1365, height: 900 });
await installFixture(page, { tournamentStage: 2 });
await page.goto('tournament');
const region = page.getByRole('region', { name: '예선 조별 전투 로그' });
const logs = region.locator('.fight-log');
await expect(logs).toHaveCount(8);
await expect(logs).toHaveText([
/ .*.*.*/s,
/ .*.*.*/s,
/ /s,
/ /s,
/ /s,
/ /s,
/ /s,
/ /s,
]);
const geometry = await logs.evaluateAll((elements) =>
elements.map((element) => {
const bounds = element.getBoundingClientRect();
return { top: bounds.top, left: bounds.left, right: bounds.right, width: bounds.width };
})
);
expect(new Set(geometry.slice(0, 4).map((item) => item.top)).size).toBe(1);
expect(geometry[4]!.top).toBeGreaterThan(geometry[0]!.top);
expect(geometry.every((item) => item.left >= 0 && item.right <= 1365 && item.width > 0)).toBe(true);
await expect(logs.first().locator('p').first().locator('span').first()).toHaveCSS('color', 'rgb(135, 206, 235)');
await persistScreenshot(page, 'tournament-preliminary-fight-logs', testInfo.outputPath('preliminary-logs.webp'));
});
test('final group stage keeps all eight fight logs visible on mobile without overflow', async ({ page }, testInfo) => {
await page.setViewportSize({ width: 390, height: 844 });
await installFixture(page, { tournamentStage: 4 });
await page.goto('tournament');
const region = page.getByRole('region', { name: '본선 조별 전투 로그' });
const logs = region.locator('.fight-log');
await expect(logs).toHaveCount(8);
for (let index = 0; index < 8; index += 1) {
await expect(logs.nth(index)).toBeVisible();
}
const geometry = await logs.evaluateAll((elements) =>
elements.map((element) => {
const bounds = element.getBoundingClientRect();
return { top: bounds.top, left: bounds.left, right: bounds.right };
})
);
expect(geometry.every((item, index) => index === 0 || item.top > geometry[index - 1]!.top)).toBe(true);
expect(geometry.every((item) => item.left >= 0 && item.right <= 390)).toBe(true);
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(390);
await persistScreenshot(page, 'tournament-final-fight-logs-mobile', testInfo.outputPath('final-logs-mobile.webp'));
});
test('knockout stage shows the latest completed match instead of the next empty match', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await installFixture(page, { tournamentStage: 7 });
await page.goto('tournament');
const region = page.getByRole('region', { name: '현재 토너먼트 전투 로그' });
await expect(region).toContainText('관우 vs 장료');
await expect(region).toContainText('관우 승리!');
await expect(region).not.toContainText('<S>');
await expect(region.locator('p').first().locator('span').first()).toHaveCSS('color', 'rgb(135, 206, 235)');
});
test('completed tournament retains the final fight log like Ref', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await installFixture(page);
await page.goto('tournament');
const region = page.getByRole('region', { name: '현재 토너먼트 전투 로그' });
await expect(region).toContainText('관우 vs 여포');
await expect(region).toContainText('관우 우승!');
});
test('mobile bracket exposes every round through tabs with standard horizontal identities', async ({
page,
}, testInfo) => {
+86 -4
View File
@@ -5,6 +5,7 @@ import TournamentBracket from '../components/tournament/TournamentBracket.vue';
import TournamentPageHeader from '../components/tournament/TournamentPageHeader.vue';
import GeneralIdentity from '../components/ui/GeneralIdentity.vue';
import { useGameFeedback } from '../composables/useGameFeedback';
import { formatLog } from '../utils/formatLog';
import { trpc } from '../utils/trpc';
import { resolveTournamentSectionVisibility, resolveTournamentStageName } from '../utils/tournamentStatus';
@@ -112,10 +113,30 @@ const gamesOf = (participant: Snapshot['participants'][number] | undefined): num
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 groupFightLogsAt = (stage: 2 | 4, groupStart: 0 | 10) =>
Array.from({ length: 8 }, (_, index) =>
(snapshot.value?.matches ?? []).find(
(match) => match.stage === stage && match.groupId === groupStart + index && (match.log?.length ?? 0) > 0
)
);
const preliminaryFightLogs = computed(() => groupFightLogsAt(2, 0));
const finalFightLogs = computed(() => groupFightLogsAt(4, 10));
const showPreliminaryFightLogs = computed(
() => [2, 3].includes(snapshot.value?.state?.stage ?? -1) && preliminaryFightLogs.value.some(Boolean)
);
const showFinalFightLogs = computed(
() => [4, 5].includes(snapshot.value?.state?.stage ?? -1) && finalFightLogs.value.some(Boolean)
);
const currentMatch = computed(() => {
const state = snapshot.value?.state;
if (!state || state.stage < 7 || state.stage > 10) return null;
return matchesAt(state.stage).find((match) => !match.winnerId) ?? matchesAt(state.stage)[state.phase] ?? null;
if (!state) return null;
const logStage = state.stage === 0 && state.winnerId ? 10 : state.stage;
if (logStage < 7 || logStage > 10) return null;
return (
matchesAt(logStage)
.filter((match) => (match.log?.length ?? 0) > 0)
.at(-1) ?? null
);
});
const revealMyPreliminaryGroup = async (): Promise<number | undefined> => {
@@ -230,9 +251,11 @@ const start = async () => {
:tournament-type="snapshot?.state?.type ?? 0"
/>
<section v-if="currentMatch" class="fight bg0">
<section v-if="currentMatch" class="fight bg0" aria-label="현재 토너먼트 전투 로그">
<h2>{{ nameOf(currentMatch.attackerId) }} vs {{ nameOf(currentMatch.defenderId) }}</h2>
<p v-for="(line, index) in currentMatch.log ?? []" :key="index">{{ line }}</p>
<!-- formatLog rebuilds only the shared Ref log allowlist. -->
<!-- eslint-disable-next-line vue/no-v-html -->
<p v-for="(line, index) in currentMatch.log ?? []" :key="index" v-html="formatLog(line)" />
</section>
</template>
@@ -297,6 +320,21 @@ const start = async () => {
</tbody>
</table>
</section>
<section v-if="showFinalFightLogs" class="fight-log-grid bg0" aria-label="본선 조별 전투 로그">
<article
v-for="(match, groupIndex) in finalFightLogs"
:key="`final-fight-${groupIndex}`"
class="fight-log"
:data-fight-log-group="groupIndex"
>
<h3>{{ groupNames[groupIndex] }} 전투 로그</h3>
<template v-if="match">
<!-- formatLog rebuilds only the shared Ref log allowlist. -->
<!-- eslint-disable-next-line vue/no-v-html -->
<p v-for="(line, index) in match.log ?? []" :key="index" v-html="formatLog(line)" />
</template>
</article>
</section>
</template>
<template v-if="sectionVisibility.preliminary">
@@ -361,6 +399,21 @@ const start = async () => {
</tbody>
</table>
</section>
<section v-if="showPreliminaryFightLogs" class="fight-log-grid bg0" aria-label="예선 조별 전투 로그">
<article
v-for="(match, groupIndex) in preliminaryFightLogs"
:key="`preliminary-fight-${groupIndex}`"
class="fight-log"
:data-fight-log-group="groupIndex"
>
<h3>{{ groupNames[groupIndex] }} 전투 로그</h3>
<template v-if="match">
<!-- formatLog rebuilds only the shared Ref log allowlist. -->
<!-- eslint-disable-next-line vue/no-v-html -->
<p v-for="(line, index) in match.log ?? []" :key="index" v-html="formatLog(line)" />
</template>
</article>
</section>
</template>
<div class="legacy-bracket-table-signature" hidden>
@@ -509,6 +562,31 @@ button:not(.legacy-button):focus-visible {
.fight p {
margin: 2px 10px;
}
.fight-log-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
align-items: start;
gap: 8px;
padding: 8px;
text-align: left;
}
.fight-log {
min-width: 0;
border: 1px solid #555;
overflow-wrap: anywhere;
}
.fight-log h3 {
margin: 0;
padding: 4px;
background: #000;
color: orange;
font-size: 14px;
font-weight: 400;
text-align: center;
}
.fight-log p {
margin: 2px 6px;
}
.groups-title {
color: orange;
}
@@ -604,6 +682,10 @@ td {
.group-grid table.mobile-active {
display: table;
}
.fight-log-grid {
grid-template-columns: minmax(0, 1fr);
padding: 6px 0;
}
.group-grid th,
.group-grid td {
height: 31px;