feat(game-ui): 토너먼트 조별 순위를 카드로 개선한다
빈 조별 슬롯에도 64px 아이콘 자리와 동일한 높이를 유지한다. 경기 지표는 장수명 옆의 의미 단위 요약으로 재배치하고 desktop/mobile Chromium 검증을 보강한다.
This commit is contained in:
@@ -194,6 +194,7 @@ const installFixture = async (
|
|||||||
tournamentType?: number;
|
tournamentType?: number;
|
||||||
tournamentStage?: number;
|
tournamentStage?: number;
|
||||||
joinedGroupId?: number;
|
joinedGroupId?: number;
|
||||||
|
emptyFinalGroups?: boolean;
|
||||||
} = {}
|
} = {}
|
||||||
) => {
|
) => {
|
||||||
let joined = false;
|
let joined = false;
|
||||||
@@ -251,7 +252,12 @@ const installFixture = async (
|
|||||||
finalRank: 0,
|
finalRank: 0,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
: participants,
|
: options.emptyFinalGroups
|
||||||
|
? participants.map((participant) => ({
|
||||||
|
...participant,
|
||||||
|
groupId: participant.preliminaryGroupId,
|
||||||
|
}))
|
||||||
|
: participants,
|
||||||
matches: matchesForStage(tournamentStage),
|
matches: matchesForStage(tournamentStage),
|
||||||
betCount: 16,
|
betCount: 16,
|
||||||
});
|
});
|
||||||
@@ -393,12 +399,28 @@ test('desktop bracket connects every real general slot to the next round', async
|
|||||||
expect(oddsContainment.oddsBottom).toBeLessThanOrEqual(oddsContainment.cardBottom);
|
expect(oddsContainment.oddsBottom).toBeLessThanOrEqual(oddsContainment.cardBottom);
|
||||||
await expect(firstSlot.locator('.bracket-my-bet')).toHaveText('내 투자 금120');
|
await expect(firstSlot.locator('.bracket-my-bet')).toHaveText('내 투자 금120');
|
||||||
|
|
||||||
const preliminaryTables = page.locator('.preliminary-grid table');
|
const preliminaryGroups = page.locator('.preliminary-grid .tournament-group-card');
|
||||||
await expect(preliminaryTables).toHaveCount(8);
|
await expect(preliminaryGroups).toHaveCount(8);
|
||||||
for (let groupIndex = 0; groupIndex < 8; groupIndex += 1) {
|
for (let groupIndex = 0; groupIndex < 8; groupIndex += 1) {
|
||||||
await expect(preliminaryTables.nth(groupIndex).locator('tbody tr')).toHaveCount(8);
|
await expect(preliminaryGroups.nth(groupIndex).locator('.standing-row')).toHaveCount(8);
|
||||||
await expect(preliminaryTables.nth(groupIndex).locator('.general-identity')).toHaveCount(8);
|
await expect(preliminaryGroups.nth(groupIndex).locator('.general-identity')).toHaveCount(8);
|
||||||
}
|
}
|
||||||
|
await expect(page.locator('.group-grid th, .group-grid td')).toHaveCount(0);
|
||||||
|
|
||||||
|
const longName = page
|
||||||
|
.locator('.tournament-group-card .general-identity-name', { hasText: longGeneralName })
|
||||||
|
.first();
|
||||||
|
await expect(longName).toHaveAttribute('title', longGeneralName);
|
||||||
|
const longNameGeometry = await longName.evaluate((element) => ({
|
||||||
|
clientWidth: element.clientWidth,
|
||||||
|
scrollWidth: element.scrollWidth,
|
||||||
|
overflow: getComputedStyle(element).overflow,
|
||||||
|
textOverflow: getComputedStyle(element).textOverflow,
|
||||||
|
whiteSpace: getComputedStyle(element).whiteSpace,
|
||||||
|
}));
|
||||||
|
expect(longNameGeometry.clientWidth).toBeGreaterThan(100);
|
||||||
|
expect(longNameGeometry.scrollWidth).toBeGreaterThan(longNameGeometry.clientWidth);
|
||||||
|
expect(longNameGeometry).toMatchObject({ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' });
|
||||||
|
|
||||||
await persistScreenshot(page, 'tournament-desktop', testInfo.outputPath('tournament-bracket-desktop.webp'));
|
await persistScreenshot(page, 'tournament-desktop', testInfo.outputPath('tournament-bracket-desktop.webp'));
|
||||||
});
|
});
|
||||||
@@ -427,6 +449,16 @@ test('join refresh shows the assigned preliminary group immediately with accessi
|
|||||||
await expect(preliminaryTabs.getByRole('tab').nth(5)).toHaveAttribute('aria-selected', 'true');
|
await expect(preliminaryTabs.getByRole('tab').nth(5)).toHaveAttribute('aria-selected', 'true');
|
||||||
const assignedGroup = page.locator('[data-preliminary-group="5"]');
|
const assignedGroup = page.locator('[data-preliminary-group="5"]');
|
||||||
await expect(assignedGroup.locator('.general-identity', { hasText: names[0] })).toBeVisible();
|
await expect(assignedGroup.locator('.general-identity', { hasText: names[0] })).toBeVisible();
|
||||||
|
await expect(assignedGroup.locator('.standing-row')).toHaveCount(8);
|
||||||
|
await expect(assignedGroup.locator('.standing-row[data-empty="true"]')).toHaveCount(7);
|
||||||
|
const emptySlotGeometry = await assignedGroup.locator('.standing-row').evaluateAll((rows) =>
|
||||||
|
rows.map((row) => {
|
||||||
|
const icon = row.querySelector<HTMLElement>('.general-identity-icon')!.getBoundingClientRect();
|
||||||
|
return { height: row.getBoundingClientRect().height, iconWidth: icon.width, iconHeight: icon.height };
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(new Set(emptySlotGeometry.map((row) => row.height)).size).toBe(1);
|
||||||
|
expect(emptySlotGeometry.every((row) => row.iconWidth === 64 && row.iconHeight === 64)).toBe(true);
|
||||||
const assignedGroupBounds = await assignedGroup.boundingBox();
|
const assignedGroupBounds = await assignedGroup.boundingBox();
|
||||||
expect(assignedGroupBounds?.y).toBeLessThan(844);
|
expect(assignedGroupBounds?.y).toBeLessThan(844);
|
||||||
expect((assignedGroupBounds?.y ?? 0) + (assignedGroupBounds?.height ?? 0)).toBeGreaterThan(0);
|
expect((assignedGroupBounds?.y ?? 0) + (assignedGroupBounds?.height ?? 0)).toBeGreaterThan(0);
|
||||||
@@ -479,12 +511,19 @@ test('desktop join scrolls the assigned preliminary group into view without futu
|
|||||||
|
|
||||||
test('final group section appears before the later knockout section', async ({ page }, testInfo) => {
|
test('final group section appears before the later knockout section', async ({ page }, testInfo) => {
|
||||||
await page.setViewportSize({ width: 390, height: 844 });
|
await page.setViewportSize({ width: 390, height: 844 });
|
||||||
await installFixture(page, { tournamentStage: 3 });
|
await installFixture(page, { tournamentStage: 3, emptyFinalGroups: true });
|
||||||
await page.goto('tournament');
|
await page.goto('tournament');
|
||||||
|
|
||||||
await expect(page.getByText('조별 예선 순위')).toBeVisible();
|
await expect(page.getByText('조별 예선 순위')).toBeVisible();
|
||||||
await expect(page.getByText('조별 본선 순위')).toBeVisible();
|
await expect(page.getByText('조별 본선 순위')).toBeVisible();
|
||||||
await expect(page.getByLabel('토너먼트 대진표')).toHaveCount(0);
|
await expect(page.getByLabel('토너먼트 대진표')).toHaveCount(0);
|
||||||
|
const activeFinalGroup = page.locator('.final-grid .tournament-group-card.mobile-active');
|
||||||
|
await expect(activeFinalGroup.locator('.standing-row')).toHaveCount(4);
|
||||||
|
await expect(activeFinalGroup.locator('.standing-row[data-empty="true"]')).toHaveCount(4);
|
||||||
|
const emptyFinalHeights = await activeFinalGroup
|
||||||
|
.locator('.standing-row')
|
||||||
|
.evaluateAll((rows) => rows.map((row) => row.getBoundingClientRect().height));
|
||||||
|
expect(new Set(emptyFinalHeights).size).toBe(1);
|
||||||
await persistScreenshot(page, 'tournament-final-stage-mobile', testInfo.outputPath('tournament-final-stage.webp'));
|
await persistScreenshot(page, 'tournament-final-stage-mobile', testInfo.outputPath('tournament-final-stage.webp'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,161 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import type { GeneralIconSource } from '../../utils/generalIcon';
|
||||||
|
import GeneralIdentity from '../ui/GeneralIdentity.vue';
|
||||||
|
|
||||||
|
type StandingParticipant = {
|
||||||
|
name: string;
|
||||||
|
picture?: GeneralIconSource['picture'];
|
||||||
|
imageServer?: GeneralIconSource['imageServer'];
|
||||||
|
leadership: number;
|
||||||
|
strength: number;
|
||||||
|
intel: number;
|
||||||
|
win?: number;
|
||||||
|
draw?: number;
|
||||||
|
lose?: number;
|
||||||
|
gl?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
groupName: string;
|
||||||
|
rows: StandingParticipant[];
|
||||||
|
rowCount: number;
|
||||||
|
statLabel: string;
|
||||||
|
tournamentType: number;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const slots = computed(() =>
|
||||||
|
Array.from({ length: props.rowCount }, (_, index) => ({ rank: index + 1, participant: props.rows[index] }))
|
||||||
|
);
|
||||||
|
const statOf = (participant: StandingParticipant): number => {
|
||||||
|
if (props.tournamentType === 0) return participant.leadership + participant.strength + participant.intel;
|
||||||
|
if (props.tournamentType === 1) return participant.leadership;
|
||||||
|
if (props.tournamentType === 2) return participant.strength;
|
||||||
|
return participant.intel;
|
||||||
|
};
|
||||||
|
const gamesOf = (participant: StandingParticipant): number =>
|
||||||
|
(participant.win ?? 0) + (participant.draw ?? 0) + (participant.lose ?? 0);
|
||||||
|
const pointsOf = (participant: StandingParticipant): number => (participant.win ?? 0) * 3 + (participant.draw ?? 0);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="tournament-group-card" :aria-label="`${groupName}조 순위`">
|
||||||
|
<h3>{{ groupName }}조</h3>
|
||||||
|
<ol class="standing-list">
|
||||||
|
<li
|
||||||
|
v-for="slot in slots"
|
||||||
|
:key="slot.rank"
|
||||||
|
class="standing-row"
|
||||||
|
:class="{ 'standing-row--empty': !slot.participant }"
|
||||||
|
:data-empty="!slot.participant"
|
||||||
|
>
|
||||||
|
<span class="standing-rank" :aria-label="`${slot.rank}위`">{{ slot.rank }}</span>
|
||||||
|
<GeneralIdentity
|
||||||
|
v-if="slot.participant"
|
||||||
|
:name="slot.participant.name"
|
||||||
|
:picture="slot.participant.picture"
|
||||||
|
:image-server="slot.participant.imageServer"
|
||||||
|
>
|
||||||
|
<template #details>
|
||||||
|
<span class="standing-summary">
|
||||||
|
<span class="standing-summary-line">
|
||||||
|
<strong>{{ statLabel }} {{ statOf(slot.participant) }}</strong>
|
||||||
|
<span>· {{ gamesOf(slot.participant) }}경기</span>
|
||||||
|
</span>
|
||||||
|
<span class="standing-summary-line standing-summary-line--record">
|
||||||
|
{{ slot.participant.win ?? 0 }}승 · {{ slot.participant.draw ?? 0 }}무 ·
|
||||||
|
{{ slot.participant.lose ?? 0 }}패
|
||||||
|
</span>
|
||||||
|
<span class="standing-summary-line">
|
||||||
|
<strong>{{ pointsOf(slot.participant) }}점</strong>
|
||||||
|
<span>· 득실 {{ slot.participant.gl ?? 0 }}</span>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</GeneralIdentity>
|
||||||
|
<GeneralIdentity v-else name="빈 자리" placeholder />
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.tournament-group-card {
|
||||||
|
min-width: 0;
|
||||||
|
border: 1px solid #555;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.tournament-group-card h3 {
|
||||||
|
margin: 0;
|
||||||
|
padding: 3px;
|
||||||
|
background: #000;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 400;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.standing-list {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
.standing-row {
|
||||||
|
display: grid;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 68px;
|
||||||
|
grid-template-columns: 24px minmax(0, 1fr);
|
||||||
|
align-items: center;
|
||||||
|
border-top: 1px solid #555;
|
||||||
|
}
|
||||||
|
.standing-rank {
|
||||||
|
align-self: stretch;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
border-right: 1px solid #555;
|
||||||
|
background: rgb(0 0 0 / 16%);
|
||||||
|
color: #d6cbc6;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.general-identity {
|
||||||
|
width: 100%;
|
||||||
|
padding-right: 5px;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
.standing-summary {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
color: #d8cfca;
|
||||||
|
font-size: 12px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
line-height: 14px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.standing-summary-line {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.standing-summary-line strong {
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
.standing-summary-line--record {
|
||||||
|
color: #b9d8ed;
|
||||||
|
}
|
||||||
|
.standing-row--empty .general-identity {
|
||||||
|
min-height: 64px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 800px) {
|
||||||
|
.standing-row {
|
||||||
|
grid-template-columns: 28px minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
.standing-summary {
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 15px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -8,11 +8,13 @@ const props = withDefaults(
|
|||||||
picture?: GeneralIconSource['picture'];
|
picture?: GeneralIconSource['picture'];
|
||||||
imageServer?: GeneralIconSource['imageServer'];
|
imageServer?: GeneralIconSource['imageServer'];
|
||||||
hideIcon?: boolean;
|
hideIcon?: boolean;
|
||||||
|
placeholder?: boolean;
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
picture: null,
|
picture: null,
|
||||||
imageServer: 0,
|
imageServer: 0,
|
||||||
hideIcon: false,
|
hideIcon: false,
|
||||||
|
placeholder: false,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -25,16 +27,25 @@ const iconUrl = computed(() =>
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<span class="general-identity">
|
<span class="general-identity" :class="{ 'general-identity--placeholder': placeholder }">
|
||||||
<img
|
<img
|
||||||
v-if="!hideIcon && name !== '-'"
|
v-if="!hideIcon && name !== '-' && !placeholder"
|
||||||
class="general-identity-icon"
|
class="general-identity-icon"
|
||||||
:src="iconUrl"
|
:src="iconUrl"
|
||||||
alt=""
|
alt=""
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
@error="useDefaultGeneralIcon"
|
@error="useDefaultGeneralIcon"
|
||||||
/>
|
/>
|
||||||
<span class="general-identity-name" :title="name">{{ name }}</span>
|
<span
|
||||||
|
v-else-if="!hideIcon && placeholder"
|
||||||
|
class="general-identity-icon general-identity-icon--placeholder"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<span v-if="$slots.details" class="general-identity-copy">
|
||||||
|
<span class="general-identity-name" :title="name">{{ name }}</span>
|
||||||
|
<span class="general-identity-details"><slot name="details" /></span>
|
||||||
|
</span>
|
||||||
|
<span v-else class="general-identity-name" :title="name">{{ name }}</span>
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -56,10 +67,28 @@ const iconUrl = computed(() =>
|
|||||||
background: #111;
|
background: #111;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
}
|
}
|
||||||
|
.general-identity-icon--placeholder {
|
||||||
|
background:
|
||||||
|
linear-gradient(135deg, transparent 47%, rgb(255 255 255 / 8%) 48%, rgb(255 255 255 / 8%) 52%, transparent 53%),
|
||||||
|
#17110f;
|
||||||
|
}
|
||||||
|
.general-identity-copy {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
.general-identity-name {
|
.general-identity-name {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
.general-identity-details {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.general-identity--placeholder {
|
||||||
|
color: #91847e;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
import { formatServerDateTime } from '@sammo-ts/common/time/ServerDateTime';
|
import { formatServerDateTime } from '@sammo-ts/common/time/ServerDateTime';
|
||||||
import { computed, nextTick, onMounted, ref } from 'vue';
|
import { computed, nextTick, onMounted, ref } from 'vue';
|
||||||
import TournamentBracket from '../components/tournament/TournamentBracket.vue';
|
import TournamentBracket from '../components/tournament/TournamentBracket.vue';
|
||||||
|
import TournamentGroupCard from '../components/tournament/TournamentGroupCard.vue';
|
||||||
import TournamentPageHeader from '../components/tournament/TournamentPageHeader.vue';
|
import TournamentPageHeader from '../components/tournament/TournamentPageHeader.vue';
|
||||||
import GeneralIdentity from '../components/ui/GeneralIdentity.vue';
|
|
||||||
import { useGameFeedback } from '../composables/useGameFeedback';
|
import { useGameFeedback } from '../composables/useGameFeedback';
|
||||||
import { formatLog } from '../utils/formatLog';
|
import { formatLog } from '../utils/formatLog';
|
||||||
import { trpc } from '../utils/trpc';
|
import { trpc } from '../utils/trpc';
|
||||||
@@ -101,18 +101,6 @@ const preliminaryGroups = computed(() =>
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
const groupNames = ['一', '二', '三', '四', '五', '六', '七', '八'];
|
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 groupFightLogsAt = (stage: 2 | 4, groupStart: 0 | 10) =>
|
const groupFightLogsAt = (stage: 2 | 4, groupStart: 0 | 10) =>
|
||||||
Array.from({ length: 8 }, (_, index) =>
|
Array.from({ length: 8 }, (_, index) =>
|
||||||
(snapshot.value?.matches ?? []).find(
|
(snapshot.value?.matches ?? []).find(
|
||||||
@@ -274,51 +262,17 @@ const start = async () => {
|
|||||||
{{ groupName }}조
|
{{ groupName }}조
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<section class="group-grid bg0">
|
<section class="group-grid final-grid bg0">
|
||||||
<table
|
<TournamentGroupCard
|
||||||
v-for="(group, groupIndex) in groups"
|
v-for="(group, groupIndex) in groups"
|
||||||
:key="groupIndex"
|
:key="groupIndex"
|
||||||
|
:group-name="groupNames[groupIndex]!"
|
||||||
|
:rows="group"
|
||||||
|
:row-count="4"
|
||||||
|
:stat-label="typeStatNames[snapshot?.state?.type ?? 0]!"
|
||||||
|
:tournament-type="snapshot?.state?.type ?? 0"
|
||||||
:class="{ 'mobile-active': activeFinalGroup === groupIndex }"
|
:class="{ 'mobile-active': activeFinalGroup === groupIndex }"
|
||||||
>
|
/>
|
||||||
<caption>
|
|
||||||
{{
|
|
||||||
groupNames[groupIndex]
|
|
||||||
}}조
|
|
||||||
</caption>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>순</th>
|
|
||||||
<th>장수</th>
|
|
||||||
<th>{{ typeStatNames[snapshot?.state?.type ?? 0] }}</th>
|
|
||||||
<th>경</th>
|
|
||||||
<th>승</th>
|
|
||||||
<th>무</th>
|
|
||||||
<th>패</th>
|
|
||||||
<th>점</th>
|
|
||||||
<th>득</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr v-for="rowIndex in 4" :key="rowIndex">
|
|
||||||
<td>{{ rowIndex }}</td>
|
|
||||||
<td class="general-cell">
|
|
||||||
<GeneralIdentity
|
|
||||||
v-if="group[rowIndex - 1]"
|
|
||||||
:name="group[rowIndex - 1]!.name"
|
|
||||||
:picture="group[rowIndex - 1]!.picture"
|
|
||||||
:image-server="group[rowIndex - 1]!.imageServer"
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td>{{ statOf(group[rowIndex - 1]) }}</td>
|
|
||||||
<td>{{ gamesOf(group[rowIndex - 1]) }}</td>
|
|
||||||
<td>{{ group[rowIndex - 1]?.win ?? '' }}</td>
|
|
||||||
<td>{{ group[rowIndex - 1]?.draw ?? '' }}</td>
|
|
||||||
<td>{{ group[rowIndex - 1]?.lose ?? '' }}</td>
|
|
||||||
<td>{{ pointsOf(group[rowIndex - 1]) }}</td>
|
|
||||||
<td>{{ group[rowIndex - 1]?.gl ?? '' }}</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</section>
|
</section>
|
||||||
<section v-if="showFinalFightLogs" class="fight-log-grid bg0" aria-label="본선 조별 전투 로그">
|
<section v-if="showFinalFightLogs" class="fight-log-grid bg0" aria-label="본선 조별 전투 로그">
|
||||||
<article
|
<article
|
||||||
@@ -353,51 +307,17 @@ const start = async () => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<section class="group-grid preliminary-grid bg0">
|
<section class="group-grid preliminary-grid bg0">
|
||||||
<table
|
<TournamentGroupCard
|
||||||
v-for="(group, groupIndex) in preliminaryGroups"
|
v-for="(group, groupIndex) in preliminaryGroups"
|
||||||
:key="`preliminary-${groupIndex}`"
|
:key="`preliminary-${groupIndex}`"
|
||||||
|
:group-name="groupNames[groupIndex]!"
|
||||||
|
:rows="group"
|
||||||
|
:row-count="8"
|
||||||
|
:stat-label="typeStatNames[snapshot?.state?.type ?? 0]!"
|
||||||
|
:tournament-type="snapshot?.state?.type ?? 0"
|
||||||
:data-preliminary-group="groupIndex"
|
:data-preliminary-group="groupIndex"
|
||||||
:class="{ 'mobile-active': activePreliminaryGroup === groupIndex }"
|
:class="{ 'mobile-active': activePreliminaryGroup === groupIndex }"
|
||||||
>
|
/>
|
||||||
<caption>
|
|
||||||
{{
|
|
||||||
groupNames[groupIndex]
|
|
||||||
}}조
|
|
||||||
</caption>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>순</th>
|
|
||||||
<th>장수</th>
|
|
||||||
<th>{{ typeStatNames[snapshot?.state?.type ?? 0] }}</th>
|
|
||||||
<th>경</th>
|
|
||||||
<th>승</th>
|
|
||||||
<th>무</th>
|
|
||||||
<th>패</th>
|
|
||||||
<th>점</th>
|
|
||||||
<th>득</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr v-for="rowIndex in 8" :key="rowIndex">
|
|
||||||
<td>{{ rowIndex }}</td>
|
|
||||||
<td class="general-cell">
|
|
||||||
<GeneralIdentity
|
|
||||||
v-if="group[rowIndex - 1]"
|
|
||||||
:name="group[rowIndex - 1]!.name"
|
|
||||||
:picture="group[rowIndex - 1]!.picture"
|
|
||||||
:image-server="group[rowIndex - 1]!.imageServer"
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td>{{ statOf(group[rowIndex - 1]) }}</td>
|
|
||||||
<td>{{ gamesOf(group[rowIndex - 1]) }}</td>
|
|
||||||
<td>{{ group[rowIndex - 1]?.win ?? '' }}</td>
|
|
||||||
<td>{{ group[rowIndex - 1]?.draw ?? '' }}</td>
|
|
||||||
<td>{{ group[rowIndex - 1]?.lose ?? '' }}</td>
|
|
||||||
<td>{{ pointsOf(group[rowIndex - 1]) }}</td>
|
|
||||||
<td>{{ group[rowIndex - 1]?.gl ?? '' }}</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</section>
|
</section>
|
||||||
<section v-if="showPreliminaryFightLogs" class="fight-log-grid bg0" aria-label="예선 조별 전투 로그">
|
<section v-if="showPreliminaryFightLogs" class="fight-log-grid bg0" aria-label="예선 조별 전투 로그">
|
||||||
<article
|
<article
|
||||||
@@ -597,41 +517,6 @@ button:not(.legacy-button):focus-visible {
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
}
|
}
|
||||||
table {
|
|
||||||
width: 100%;
|
|
||||||
border-collapse: collapse;
|
|
||||||
table-layout: fixed;
|
|
||||||
}
|
|
||||||
caption {
|
|
||||||
padding: 3px;
|
|
||||||
background: #000;
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
th {
|
|
||||||
background: #154b2a var(--sammo-texture-green);
|
|
||||||
font-weight: 400;
|
|
||||||
}
|
|
||||||
th,
|
|
||||||
td {
|
|
||||||
height: 30px;
|
|
||||||
border: 1px solid #555;
|
|
||||||
padding: 1px 3px;
|
|
||||||
}
|
|
||||||
.group-grid th:first-child,
|
|
||||||
.group-grid td:first-child {
|
|
||||||
width: 24px;
|
|
||||||
}
|
|
||||||
.group-grid th:nth-child(2),
|
|
||||||
.group-grid td:nth-child(2) {
|
|
||||||
width: 130px;
|
|
||||||
}
|
|
||||||
.general-cell {
|
|
||||||
overflow: hidden;
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
.group-grid tbody tr:has(.general-identity) td {
|
|
||||||
height: 66px;
|
|
||||||
}
|
|
||||||
.group-tabs {
|
.group-tabs {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
@@ -675,31 +560,16 @@ td {
|
|||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
padding: 6px 0;
|
padding: 6px 0;
|
||||||
}
|
}
|
||||||
.group-grid table {
|
.group-grid > .tournament-group-card {
|
||||||
display: none;
|
display: none;
|
||||||
min-width: 370px;
|
|
||||||
}
|
}
|
||||||
.group-grid table.mobile-active {
|
.group-grid > .tournament-group-card.mobile-active {
|
||||||
display: table;
|
display: block;
|
||||||
}
|
}
|
||||||
.fight-log-grid {
|
.fight-log-grid {
|
||||||
grid-template-columns: minmax(0, 1fr);
|
grid-template-columns: minmax(0, 1fr);
|
||||||
padding: 6px 0;
|
padding: 6px 0;
|
||||||
}
|
}
|
||||||
.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: 138px;
|
|
||||||
}
|
|
||||||
.tournament-guide {
|
.tournament-guide {
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
|
|||||||
@@ -18,7 +18,10 @@ tree instead of replacing images with layout-neutral placeholders.
|
|||||||
`public-gaps.spec.ts` adds bounded fixtures for nation betting and the public
|
`public-gaps.spec.ts` adds bounded fixtures for nation betting and the public
|
||||||
NPC list, including mutations and recoverable API failures.
|
NPC list, including mutations and recoverable API failures.
|
||||||
`tournament-betting.spec.ts` covers the separate tournament and tournament
|
`tournament-betting.spec.ts` covers the separate tournament and tournament
|
||||||
betting routes, including a recoverable failed bet.
|
betting routes, including a recoverable failed bet. The production-bundle
|
||||||
|
`app/game-frontend/e2e/tournamentBracket.spec.ts` additionally measures the
|
||||||
|
responsive group cards, equal-height empty draw slots, 64px icon placeholders,
|
||||||
|
long-name ellipsis, and mobile group tabs.
|
||||||
`reference-rankings.mjs` records the authenticated PHP 명장일람 and public
|
`reference-rankings.mjs` records the authenticated PHP 명장일람 and public
|
||||||
명예의 전당 computed DOM without embedding the reference password.
|
명예의 전당 computed DOM without embedding the reference password.
|
||||||
`battleSimulator.spec.ts` covers the authenticated simulator with and without
|
`battleSimulator.spec.ts` covers the authenticated simulator with and without
|
||||||
@@ -74,28 +77,28 @@ storage, route guards, and image loading.
|
|||||||
|
|
||||||
## Enforced contracts
|
## Enforced contracts
|
||||||
|
|
||||||
| Screen | Ref entry point | Current automated contract |
|
| Screen | Ref entry point | Current automated contract |
|
||||||
| -------------------- | ---------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| -------------------- | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| gateway login/status | `index.php` | 450/700px desktop widths, mobile collapse, Pretendard title, real login mutation/session storage, actual seasonal map asset |
|
| gateway login/status | `index.php` | 450/700px desktop widths, mobile collapse, Pretendard title, real login mutation/session storage, actual seasonal map asset |
|
||||||
| gateway account | `i_entrance/user_info.php` | 550px × minimum 575px panel, 14px Pretendard, three legacy textures, success and API-error password flows |
|
| gateway account | `i_entrance/user_info.php` | 550px × minimum 575px panel, 14px Pretendard, three legacy textures, success and API-error password flows |
|
||||||
| gateway OAuth join | `oauth_kakao/join.php` | 700px centered registration card, Kakao exchange/register success, retained-input API error, hover/focus |
|
| gateway OAuth join | `oauth_kakao/join.php` | 700px centered registration card, Kakao exchange/register success, retained-input API error, hover/focus |
|
||||||
| gateway Kakao OTP | `index.php#modalOTP` | 동일 문구·500px modal, desktop/mobile geometry와 색상·typography, password/OAuth 진입, autofocus·focus-visible·active·disabled·오류 재시도·session 저장 |
|
| gateway Kakao OTP | `index.php#modalOTP` | 동일 문구·500px modal, desktop/mobile geometry와 색상·typography, password/OAuth 진입, autofocus·focus-visible·active·disabled·오류 재시도·session 저장 |
|
||||||
| game login hand-off | unauthenticated `hwe/index.php` redirect | `/che/login` delegates to `/gateway/` |
|
| game login hand-off | unauthenticated `hwe/index.php` redirect | `/che/login` delegates to `/gateway/` |
|
||||||
| troop | `hwe/v_troop.php` | existing `app/game-frontend/e2e/troop.spec.ts` desktop/mobile geometry and interaction suite |
|
| troop | `hwe/v_troop.php` | existing `app/game-frontend/e2e/troop.spec.ts` desktop/mobile geometry and interaction suite |
|
||||||
| current city | `hwe/b_currentCity.php` | main-page Pretendard 14px, wrapping general-name summary, small reserved-turn lines but normal-size NPC labels, 1000px summary/1024px general tables, 400px selector, 64px icon, nation title color, force summary, actor/spy/admin redaction, and map-click query navigation |
|
| current city | `hwe/b_currentCity.php` | main-page Pretendard 14px, wrapping general-name summary, small reserved-turn lines but normal-size NPC labels, 1000px summary/1024px general tables, 400px selector, 64px icon, nation title color, force summary, actor/spy/admin redaction, and map-click query navigation |
|
||||||
| best general | `hwe/a_bestGeneral.php` | authenticated 500/1000px ranking and unique-item grids, user/NPC switch, 100/64px cell/image geometry, title/button computed styles, retained-data API error |
|
| best general | `hwe/a_bestGeneral.php` | authenticated 500/1000px ranking and unique-item grids, user/NPC switch, 100/64px cell/image geometry, title/button computed styles, retained-data API error |
|
||||||
| hall of fame | `hwe/a_hallOfFame.php` | public 500/1000px container, 100px ranking cells, 64px natural image, title/button/select computed styles, scenario switch and retained-data API error |
|
| hall of fame | `hwe/a_hallOfFame.php` | public 500/1000px container, 100px ranking cells, 64px natural image, title/button/select computed styles, scenario switch and retained-data API error |
|
||||||
| yearbook | `hwe/v_history.php` | 1000px 700+300 desktop grid, 500px stacked grid, month navigation, legacy textures, success and API-error flows |
|
| yearbook | `hwe/v_history.php` | 1000px 700+300 desktop grid, 500px stacked grid, month navigation, legacy textures, success and API-error flows |
|
||||||
| inheritance | `hwe/v_inheritPoint.php` | 1000px 3-column desktop and 500px stacked layout, walnut/green textures, Pretendard 14px, scenario unique selector, buff purchase success and retained-input API error, two 30-row history pages that expand the document and keep the last row/load-more button reachable by scrolling |
|
| inheritance | `hwe/v_inheritPoint.php` | 1000px 3-column desktop and 500px stacked layout, walnut/green textures, Pretendard 14px, scenario unique selector, buff purchase success and retained-input API error, two 30-row history pages that expand the document and keep the last row/load-more button reachable by scrolling |
|
||||||
| nation betting | `hwe/v_nationBetting.php` | 1000px/6-column desktop and 500px/3-column mobile grids, picked card style, payout table, success and retained-form error |
|
| nation betting | `hwe/v_nationBetting.php` | 1000px/6-column desktop and 500px/3-column mobile grids, picked card style, payout table, success and retained-form error |
|
||||||
| public NPC list | `hwe/a_npcList.php` | 1000px 12-column table with Chromium-expanded legacy widths, NPC color, eight sorts, retained table/sort after API error |
|
| public NPC list | `hwe/a_npcList.php` | 1000px 12-column table with Chromium-expanded legacy widths, NPC color, eight sorts, retained table/sort after API error |
|
||||||
| survey | `hwe/v_vote.php`, `hwe/ts/PageVote.vue` | 1000/500px fixed container, blue title/green table textures, list/detail/results/comments, selection/focus/hover, submit and retained-selection API error |
|
| survey | `hwe/v_vote.php`, `hwe/ts/PageVote.vue` | 1000/500px fixed container, blue title/green table textures, list/detail/results/comments, selection/focus/hover, submit and retained-selection API error |
|
||||||
| nation personnel | `hwe/b_myBossInfo.php` | fixed 1000px document at both viewports, chief icon columns, officer/permission/city/kick controls, role redaction |
|
| nation personnel | `hwe/b_myBossInfo.php` | fixed 1000px document at both viewports, chief icon columns, officer/permission/city/kick controls, role redaction |
|
||||||
| nation finance | `hwe/v_nationStratFinan.php` | 1000/500px at the legacy 940px breakpoint, exact diplomacy grid, policy controls, role gating and failed-mutation rollback |
|
| nation finance | `hwe/v_nationStratFinan.php` | 1000/500px at the legacy 940px breakpoint, exact diplomacy grid, policy controls, role gating and failed-mutation rollback |
|
||||||
| battle simulator | `hwe/battle_simulator.php` | centered 1000px desktop document, 500px responsive stacking, independent/current presets, owned-general import gating, browser Web Worker calculation including 1000 repeats, fixed-seed result/logs, retained input after API error |
|
| battle simulator | `hwe/battle_simulator.php` | centered 1000px desktop document, 500px responsive stacking, independent/current presets, owned-general import gating, browser Web Worker calculation including 1000 repeats, fixed-seed result/logs, retained input after API error |
|
||||||
| NPC policy | `hwe/v_NPCControl.php` | 1000/500px form and priority-list geometry, walnut/green textures, dynamic zero hints, drag/focus/tooltip, successful save and permission failures |
|
| NPC policy | `hwe/v_NPCControl.php` | 1000/500px form and priority-list geometry, walnut/green textures, dynamic zero hints, drag/focus/tooltip, successful save and permission failures |
|
||||||
| tournament | `hwe/b_tournament.php` | fixed 2000px Ref canvas and eight 250px group tables; Core responsive bracket plus all eight latest preliminary/final-group fight logs, latest knockout/final log, safe Ref marker colors, desktop/mobile containment |
|
| tournament | `hwe/b_tournament.php` | fixed 2000px Ref canvas and eight 250px group tables; Core responsive bracket, four-column/one-tab group cards with equal-height 64px empty slots and grouped stat/record/score summaries, long-name ellipsis/title, all eight latest preliminary/final-group fight logs, latest knockout/final log, safe Ref marker colors, desktop/mobile containment |
|
||||||
| tournament betting | `hwe/b_betting.php` | fixed 1120px canvas, 16×70px candidates, four 280px rank tables, exact title/button geometry, retained selection on error |
|
| tournament betting | `hwe/b_betting.php` | fixed 1120px canvas, 16×70px candidates, four 280px rank tables, exact title/button geometry, retained selection on error |
|
||||||
|
|
||||||
The global game baseline is black, white, Pretendard 14px. Legacy texture
|
The global game baseline is black, white, Pretendard 14px. Legacy texture
|
||||||
helpers intentionally follow `common.orig.css`: `bg0` is walnut, `bg1` is
|
helpers intentionally follow `common.orig.css`: `bg0` is walnut, `bg1` is
|
||||||
|
|||||||
Reference in New Issue
Block a user