merge: 토너먼트 전투 로그 복원을 main에 반영한다
This commit is contained in:
@@ -84,6 +84,7 @@ const zMatch = z.object({
|
||||
id: z.number().int().positive(),
|
||||
stage: z.number().int().min(0),
|
||||
roundIndex: z.number().int().min(0),
|
||||
groupId: z.number().int().min(0).max(17).optional(),
|
||||
attackerId: z.number().int().positive(),
|
||||
defenderId: z.number().int().positive(),
|
||||
winnerId: z.number().int().positive().optional(),
|
||||
|
||||
@@ -47,6 +47,8 @@ export interface TournamentMatchEntry {
|
||||
id: number;
|
||||
stage: number;
|
||||
roundIndex: number;
|
||||
/** Ref fight{group}.txt와 같이 조별전의 최신 로그를 식별합니다. */
|
||||
groupId?: number;
|
||||
attackerId: number;
|
||||
defenderId: number;
|
||||
winnerId?: number;
|
||||
|
||||
@@ -39,6 +39,35 @@ import {
|
||||
type TournamentPrismaClient,
|
||||
} from './workerHelpers.js';
|
||||
|
||||
const persistLatestGroupFightLogs = async (
|
||||
store: TournamentStore,
|
||||
stage: 2 | 4,
|
||||
outcomes: TournamentMatchOutcome[]
|
||||
): Promise<void> => {
|
||||
if (outcomes.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const matches = await store.getMatches();
|
||||
const replacedGroupIds = new Set(outcomes.map((outcome) => outcome.groupId));
|
||||
const retained = matches.filter(
|
||||
(match) => match.stage !== stage || match.groupId === undefined || !replacedGroupIds.has(match.groupId)
|
||||
);
|
||||
const latest = outcomes.map((outcome): TournamentMatchEntry => ({
|
||||
id: stage * 100 + outcome.groupId + 1,
|
||||
stage,
|
||||
roundIndex: outcome.groupId,
|
||||
groupId: outcome.groupId,
|
||||
attackerId: outcome.attackerId,
|
||||
defenderId: outcome.defenderId,
|
||||
winnerId: outcome.winnerId,
|
||||
log: outcome.log,
|
||||
logEntries: outcome.logEntries,
|
||||
lastEnergy: outcome.lastEnergy,
|
||||
}));
|
||||
await store.setMatches(retained.concat(latest));
|
||||
};
|
||||
|
||||
export const applyBattle = async (
|
||||
store: TournamentStore,
|
||||
state: TournamentState,
|
||||
@@ -222,6 +251,7 @@ export const applyPreBattleStage = async (
|
||||
outcomes.push(result.outcome);
|
||||
}
|
||||
await store.setParticipants(updated);
|
||||
await persistLatestGroupFightLogs(store, 2, outcomes);
|
||||
|
||||
if (outcomes.length > 0) {
|
||||
await Promise.all(
|
||||
@@ -366,6 +396,7 @@ export const applyPreBattleStage = async (
|
||||
outcomes.push(result.outcome);
|
||||
}
|
||||
await store.setParticipants(updated);
|
||||
await persistLatestGroupFightLogs(store, 4, outcomes);
|
||||
|
||||
if (outcomes.length > 0) {
|
||||
await Promise.all(
|
||||
@@ -417,13 +448,13 @@ export const applyPreBattleStage = async (
|
||||
|
||||
if (state.stage === 5) {
|
||||
const matches = await store.getMatches();
|
||||
if (matches.length === 0) {
|
||||
if (!matches.some((match) => match.stage >= 7)) {
|
||||
const fixedMatches = buildFinal16MatchesFromGroups(participants);
|
||||
const participantIds = fixedMatches
|
||||
? fixedMatches.flatMap((entry) => [entry.attackerId, entry.defenderId])
|
||||
: pickFinalists(state, participants);
|
||||
const initialMatches = fixedMatches ?? buildInitialMatches(state, baseSeed, participantIds);
|
||||
await store.setMatches(initialMatches);
|
||||
await store.setMatches(matches.concat(initialMatches));
|
||||
}
|
||||
const nextState: TournamentState = {
|
||||
...state,
|
||||
|
||||
@@ -371,9 +371,14 @@ export const fillParticipants = async (options: {
|
||||
};
|
||||
|
||||
export type TournamentMatchOutcome = {
|
||||
groupId: number;
|
||||
attackerId: number;
|
||||
defenderId: number;
|
||||
result: 'attacker' | 'defender' | 'draw';
|
||||
winnerId?: number;
|
||||
log: string[];
|
||||
logEntries: NonNullable<TournamentMatchEntry['logEntries']>;
|
||||
lastEnergy?: NonNullable<TournamentMatchEntry['lastEnergy']>;
|
||||
};
|
||||
|
||||
export const applyGroupMatch = (
|
||||
@@ -419,10 +424,18 @@ export const applyGroupMatch = (
|
||||
|
||||
const glDelta = Math.round((result.totalDamage.defender - result.totalDamage.attacker) / 50);
|
||||
|
||||
const lastLogEntry = result.logEntries.at(-1);
|
||||
const outcome: TournamentMatchOutcome = {
|
||||
groupId: matchIndex,
|
||||
attackerId: attacker.id,
|
||||
defenderId: defender.id,
|
||||
result: result.draw ? 'draw' : result.winnerId === attacker.id ? 'attacker' : 'defender',
|
||||
winnerId: result.winnerId ?? undefined,
|
||||
log: result.log,
|
||||
logEntries: result.logEntries,
|
||||
lastEnergy: lastLogEntry
|
||||
? { attacker: lastLogEntry.attackerEnergy, defender: lastLogEntry.defenderEnergy }
|
||||
: undefined,
|
||||
};
|
||||
|
||||
return {
|
||||
@@ -572,7 +585,10 @@ export const buildNextMatches = (stage: number, matches: TournamentMatchEntry[])
|
||||
throw new Error('다음 라운드를 만들 수 없습니다.');
|
||||
}
|
||||
|
||||
const nextIdBase = matches.reduce((max, entry) => Math.max(max, entry.id), 0) + 1;
|
||||
// 조별 최신 로그도 matches projection에 함께 보존하지만 결선 match ID는
|
||||
// 전투 RNG seed의 일부이므로 기존 결선 경기만으로 연속 번호를 계산합니다.
|
||||
const nextIdBase =
|
||||
matches.filter((entry) => entry.stage >= 7).reduce((max, entry) => Math.max(max, entry.id), 0) + 1;
|
||||
const nextStageValue = nextStage(stage);
|
||||
const result: TournamentMatchEntry[] = [];
|
||||
|
||||
|
||||
@@ -179,6 +179,53 @@ const setTournamentFixture = async (redis: MemoryRedis, state: Record<string, un
|
||||
};
|
||||
|
||||
describe('tournament router permissions and mutations', () => {
|
||||
it('returns persisted group fight logs to an authenticated tournament viewer', async () => {
|
||||
const redis = new MemoryRedis();
|
||||
const transport = new TournamentTransport();
|
||||
const general = buildGeneral(1, 'user-1');
|
||||
await setTournamentFixture(redis, {
|
||||
stage: 2,
|
||||
phase: 1,
|
||||
type: 0,
|
||||
auto: true,
|
||||
openYear: 193,
|
||||
openMonth: 1,
|
||||
termSeconds: 60,
|
||||
nextAt: '2026-07-26T01:00:00.000Z',
|
||||
});
|
||||
await redis.set(
|
||||
'sammo:che:default:tournament:matches',
|
||||
JSON.stringify([
|
||||
{
|
||||
id: 201,
|
||||
stage: 2,
|
||||
roundIndex: 0,
|
||||
groupId: 0,
|
||||
attackerId: 11,
|
||||
defenderId: 12,
|
||||
winnerId: 11,
|
||||
log: ['<S>●</> <Y>후보11</> <S>승리</>!'],
|
||||
},
|
||||
])
|
||||
);
|
||||
const caller = appRouter.createCaller(
|
||||
buildContext({ redis, transport, generals: [general, buildGeneral(11, 'user-11')], userId: 'user-1' })
|
||||
);
|
||||
|
||||
const snapshot = await caller.tournament.getSnapshot();
|
||||
|
||||
expect(snapshot.matches).toEqual([
|
||||
expect.objectContaining({
|
||||
stage: 2,
|
||||
groupId: 0,
|
||||
attackerId: 11,
|
||||
defenderId: 12,
|
||||
winnerId: 11,
|
||||
log: ['<S>●</> <Y>후보11</> <S>승리</>!'],
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('charges the authenticated general once when joining', async () => {
|
||||
const redis = new MemoryRedis();
|
||||
const transport = new TournamentTransport();
|
||||
|
||||
@@ -359,10 +359,23 @@ describe('tournament worker (in-memory)', () => {
|
||||
const prisma = createPrismaMock({ baseSeed: 'seed' });
|
||||
const finalState = await runTournamentToCompletion({ store, prisma, baseSeed: 'seed' });
|
||||
const matches = await store.getMatches();
|
||||
const preliminaryLogs = matches.filter((match) => match.stage === 2);
|
||||
const finalGroupLogs = matches.filter((match) => match.stage === 4);
|
||||
const knockoutMatches = matches.filter((match) => match.stage >= 7);
|
||||
const finalMatches = matches.filter((match) => match.stage === 10);
|
||||
|
||||
expect(finalState.stage).toBe(0);
|
||||
expect(finalState.winnerId).toBe(15);
|
||||
expect(preliminaryLogs).toHaveLength(8);
|
||||
expect(finalGroupLogs).toHaveLength(8);
|
||||
expect(knockoutMatches).toHaveLength(15);
|
||||
expect(preliminaryLogs.map((match) => match.groupId).sort((a, b) => Number(a) - Number(b))).toEqual([
|
||||
0, 1, 2, 3, 4, 5, 6, 7,
|
||||
]);
|
||||
expect(finalGroupLogs.map((match) => match.groupId).sort((a, b) => Number(a) - Number(b))).toEqual([
|
||||
10, 11, 12, 13, 14, 15, 16, 17,
|
||||
]);
|
||||
expect([...preliminaryLogs, ...finalGroupLogs].every((match) => (match.log?.length ?? 0) >= 3)).toBe(true);
|
||||
expect(finalMatches).toHaveLength(1);
|
||||
expect(finalMatches[0]).toMatchObject({
|
||||
attackerId: 15,
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -74,28 +74,28 @@ storage, route guards, and image loading.
|
||||
|
||||
## Enforced contracts
|
||||
|
||||
| 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 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 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/` |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| yearbook | `hwe/v_history.php` | 1000px 700+300 desktop grid, 500px stacked grid, month navigation, legacy textures, success and API-error flows |
|
||||
| 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 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 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/` |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| 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 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 |
|
||||
| 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 canvas, 16×125px bracket, eight 250px group tables, walnut texture, 1024px overflow, hover/focus |
|
||||
| tournament betting | `hwe/b_betting.php` | fixed 1120px canvas, 16×70px candidates, four 280px rank tables, exact title/button geometry, retained selection on 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 |
|
||||
| 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 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 |
|
||||
| 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 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
|
||||
helpers intentionally follow `common.orig.css`: `bg0` is walnut, `bg1` is
|
||||
|
||||
Reference in New Issue
Block a user