feat(archive): 과거 장수 서버별 조회와 명전 기록 추가
This commit is contained in:
@@ -19,11 +19,13 @@ import {
|
|||||||
findLegacyEmperors,
|
findLegacyEmperors,
|
||||||
findLegacyGeneral,
|
findLegacyGeneral,
|
||||||
findLegacyGeneralBattleResult,
|
findLegacyGeneralBattleResult,
|
||||||
|
findLegacyGeneralHallRows,
|
||||||
findLegacyGeneralsByOwner,
|
findLegacyGeneralsByOwner,
|
||||||
findLegacyGames,
|
findLegacyGames,
|
||||||
findLegacyNations,
|
findLegacyNations,
|
||||||
LEGACY_ARCHIVE_PROFILES,
|
LEGACY_ARCHIVE_PROFILES,
|
||||||
type LegacyArchiveProfile,
|
type LegacyArchiveProfile,
|
||||||
|
type LegacyGeneralHallRow,
|
||||||
} from '../../services/legacyArchiveStore.js';
|
} from '../../services/legacyArchiveStore.js';
|
||||||
import { readOnlyAuthedProcedure, router } from '../../trpc.js';
|
import { readOnlyAuthedProcedure, router } from '../../trpc.js';
|
||||||
import { loadTraitNames } from '../nation/shared.js';
|
import { loadTraitNames } from '../nation/shared.js';
|
||||||
@@ -53,6 +55,10 @@ const zPastPlayDetailInput = z.object({
|
|||||||
generalNo: z.number().int().positive(),
|
generalNo: z.number().int().positive(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const zPastPlaysInput = z.object({
|
||||||
|
sourceProfile: z.enum(LEGACY_ARCHIVE_PROFILES),
|
||||||
|
});
|
||||||
|
|
||||||
type ArchiveSource = 'current' | 'legacy';
|
type ArchiveSource = 'current' | 'legacy';
|
||||||
|
|
||||||
interface GeneralArchiveEntry {
|
interface GeneralArchiveEntry {
|
||||||
@@ -75,6 +81,42 @@ interface ArchiveNationEntry {
|
|||||||
data: Record<string, unknown>;
|
data: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface LegacyHallBattleSummary {
|
||||||
|
available: boolean;
|
||||||
|
semantics: 'independent-records';
|
||||||
|
strategies: number | null;
|
||||||
|
warnum: number | null;
|
||||||
|
wins: number | null;
|
||||||
|
winRate: number | null;
|
||||||
|
occupied: number | null;
|
||||||
|
killCrew: number | null;
|
||||||
|
killRate: number | null;
|
||||||
|
killCrewPerson: number | null;
|
||||||
|
killRatePerson: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const legacyHallBattleSummary = (rows: LegacyGeneralHallRow[]): LegacyHallBattleSummary => {
|
||||||
|
const values = new Map(rows.map((row) => [row.type, row.value]));
|
||||||
|
const value = (type: LegacyGeneralHallRow['type']): number | null => values.get(type) ?? null;
|
||||||
|
const percent = (type: LegacyGeneralHallRow['type']): number | null => {
|
||||||
|
const raw = value(type);
|
||||||
|
return raw === null ? null : raw * 100;
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
available: rows.length > 0,
|
||||||
|
semantics: 'independent-records',
|
||||||
|
strategies: value('firenum'),
|
||||||
|
warnum: value('warnum'),
|
||||||
|
wins: value('killnum'),
|
||||||
|
winRate: percent('winrate'),
|
||||||
|
occupied: value('occupied'),
|
||||||
|
killCrew: value('killcrew'),
|
||||||
|
killRate: percent('killrate'),
|
||||||
|
killCrewPerson: value('killcrew_person'),
|
||||||
|
killRatePerson: percent('killrate_person'),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
const key = (source: ArchiveSource, sourceProfile: string, serverId: string): string =>
|
const key = (source: ArchiveSource, sourceProfile: string, serverId: string): string =>
|
||||||
`${source}:${sourceProfile}:${serverId}`;
|
`${source}:${sourceProfile}:${serverId}`;
|
||||||
|
|
||||||
@@ -190,16 +232,18 @@ const buildGeneralDetail = async (entry: GeneralArchiveEntry, nation: ReturnType
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const archiveRouter = router({
|
export const archiveRouter = router({
|
||||||
myPastPlays: readOnlyAuthedProcedure.query(async ({ ctx }) => {
|
myPastPlays: readOnlyAuthedProcedure.input(zPastPlaysInput).query(async ({ ctx, input }) => {
|
||||||
const owner = ctx.auth?.user.id;
|
const owner = ctx.auth?.user.id;
|
||||||
if (!owner) throw new Error('Authenticated archive query is missing its user identity');
|
if (!owner) throw new Error('Authenticated archive query is missing its user identity');
|
||||||
|
|
||||||
const [legacyRows, currentRows] = await Promise.all([
|
const [legacyRows, currentRows] = await Promise.all([
|
||||||
findLegacyGeneralsByOwner(ctx.db, owner),
|
findLegacyGeneralsByOwner(ctx.db, { owner, sourceProfile: input.sourceProfile }),
|
||||||
ctx.db.oldGeneral.findMany({
|
input.sourceProfile === ctx.profile.id
|
||||||
|
? ctx.db.oldGeneral.findMany({
|
||||||
where: { owner },
|
where: { owner },
|
||||||
orderBy: [{ lastYearMonth: 'desc' }, { serverId: 'desc' }, { generalNo: 'asc' }],
|
orderBy: [{ lastYearMonth: 'desc' }, { serverId: 'desc' }, { generalNo: 'asc' }],
|
||||||
}),
|
})
|
||||||
|
: [],
|
||||||
]);
|
]);
|
||||||
const legacyIdentity = new Set(
|
const legacyIdentity = new Set(
|
||||||
legacyRows.map((row) => `${row.sourceProfile}:${row.serverId}:${row.generalNo}`)
|
legacyRows.map((row) => `${row.sourceProfile}:${row.serverId}:${row.generalNo}`)
|
||||||
@@ -454,6 +498,7 @@ export const archiveRouter = router({
|
|||||||
let dynastyId: number | null = null;
|
let dynastyId: number | null = null;
|
||||||
let battleResultContent: string | null = null;
|
let battleResultContent: string | null = null;
|
||||||
let battleResultAvailable = false;
|
let battleResultAvailable = false;
|
||||||
|
let hallBattle = legacyHallBattleSummary([]);
|
||||||
if (input.source === 'legacy') {
|
if (input.source === 'legacy') {
|
||||||
if (!LEGACY_ARCHIVE_PROFILES.includes(sourceProfile as LegacyArchiveProfile)) {
|
if (!LEGACY_ARCHIVE_PROFILES.includes(sourceProfile as LegacyArchiveProfile)) {
|
||||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '지원하지 않는 이전 서버 프로필입니다.' });
|
throw new TRPCError({ code: 'BAD_REQUEST', message: '지원하지 않는 이전 서버 프로필입니다.' });
|
||||||
@@ -477,7 +522,7 @@ export const archiveRouter = router({
|
|||||||
snapshot: canonicalSnapshot(row.data, row.name),
|
snapshot: canonicalSnapshot(row.data, row.name),
|
||||||
};
|
};
|
||||||
const keyInput = [{ sourceProfile: profile, serverId: input.serverId }];
|
const keyInput = [{ sourceProfile: profile, serverId: input.serverId }];
|
||||||
const [nations, emperors, battleResult] = await Promise.all([
|
const [nations, emperors, battleResult, hallRows] = await Promise.all([
|
||||||
findLegacyNations(ctx.db, keyInput),
|
findLegacyNations(ctx.db, keyInput),
|
||||||
findLegacyEmperors(ctx.db, keyInput),
|
findLegacyEmperors(ctx.db, keyInput),
|
||||||
findLegacyGeneralBattleResult(ctx.db, {
|
findLegacyGeneralBattleResult(ctx.db, {
|
||||||
@@ -485,6 +530,11 @@ export const archiveRouter = router({
|
|||||||
serverId: input.serverId,
|
serverId: input.serverId,
|
||||||
generalNo: input.generalNo,
|
generalNo: input.generalNo,
|
||||||
}),
|
}),
|
||||||
|
findLegacyGeneralHallRows(ctx.db, {
|
||||||
|
sourceProfile: profile,
|
||||||
|
serverId: input.serverId,
|
||||||
|
generalNo: input.generalNo,
|
||||||
|
}),
|
||||||
]);
|
]);
|
||||||
nationRows = nations.map((nation) => ({
|
nationRows = nations.map((nation) => ({
|
||||||
source: 'legacy',
|
source: 'legacy',
|
||||||
@@ -497,6 +547,7 @@ export const archiveRouter = router({
|
|||||||
dynastyId = Number(emperors[0]?.id ?? 0) || null;
|
dynastyId = Number(emperors[0]?.id ?? 0) || null;
|
||||||
battleResultContent = battleResult?.content ?? null;
|
battleResultContent = battleResult?.content ?? null;
|
||||||
battleResultAvailable = battleResult !== null;
|
battleResultAvailable = battleResult !== null;
|
||||||
|
hallBattle = legacyHallBattleSummary(hallRows);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const row = await ctx.db.oldGeneral.findFirst({
|
const row = await ctx.db.oldGeneral.findFirst({
|
||||||
@@ -580,6 +631,7 @@ export const archiveRouter = router({
|
|||||||
killRate: snapshot.battle.killRate,
|
killRate: snapshot.battle.killRate,
|
||||||
recentWar: snapshot.battle.recentWar,
|
recentWar: snapshot.battle.recentWar,
|
||||||
},
|
},
|
||||||
|
hallBattle,
|
||||||
logs,
|
logs,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -42,6 +42,25 @@ export interface LegacyGeneralBattleResultRow {
|
|||||||
contentHash: string;
|
contentHash: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const LEGACY_GENERAL_HALL_TYPES = [
|
||||||
|
'firenum',
|
||||||
|
'warnum',
|
||||||
|
'killnum',
|
||||||
|
'winrate',
|
||||||
|
'occupied',
|
||||||
|
'killcrew',
|
||||||
|
'killrate',
|
||||||
|
'killcrew_person',
|
||||||
|
'killrate_person',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type LegacyGeneralHallType = (typeof LEGACY_GENERAL_HALL_TYPES)[number];
|
||||||
|
|
||||||
|
export interface LegacyGeneralHallRow {
|
||||||
|
type: LegacyGeneralHallType;
|
||||||
|
value: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface LegacyNationRow {
|
export interface LegacyNationRow {
|
||||||
sourceProfile: LegacyArchiveProfile;
|
sourceProfile: LegacyArchiveProfile;
|
||||||
legacyId: number;
|
legacyId: number;
|
||||||
@@ -79,7 +98,7 @@ export interface LegacyHallRow {
|
|||||||
|
|
||||||
export const findLegacyGeneralsByOwner = async (
|
export const findLegacyGeneralsByOwner = async (
|
||||||
db: LegacyArchiveDatabase,
|
db: LegacyArchiveDatabase,
|
||||||
owner: string
|
input: { owner: string; sourceProfile: LegacyArchiveProfile }
|
||||||
): Promise<LegacyGeneralRow[]> =>
|
): Promise<LegacyGeneralRow[]> =>
|
||||||
db.$queryRaw<LegacyGeneralRow[]>(GamePrisma.sql`
|
db.$queryRaw<LegacyGeneralRow[]>(GamePrisma.sql`
|
||||||
SELECT
|
SELECT
|
||||||
@@ -95,8 +114,9 @@ export const findLegacyGeneralsByOwner = async (
|
|||||||
"source_format" AS "sourceFormat",
|
"source_format" AS "sourceFormat",
|
||||||
"data"
|
"data"
|
||||||
FROM "legacy_archive"."general"
|
FROM "legacy_archive"."general"
|
||||||
WHERE "owner" = ${owner}
|
WHERE "owner" = ${input.owner}
|
||||||
ORDER BY "last_yearmonth" DESC, "source_profile", "server_id" DESC, "general_no"
|
AND "source_profile" = ${input.sourceProfile}
|
||||||
|
ORDER BY "last_yearmonth" DESC, "server_id" DESC, "general_no"
|
||||||
`);
|
`);
|
||||||
|
|
||||||
export const findLegacyGeneral = async (
|
export const findLegacyGeneral = async (
|
||||||
@@ -144,6 +164,22 @@ export const findLegacyGeneralBattleResult = async (
|
|||||||
return rows[0] ?? null;
|
return rows[0] ?? null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const findLegacyGeneralHallRows = async (
|
||||||
|
db: LegacyArchiveDatabase,
|
||||||
|
input: { sourceProfile: LegacyArchiveProfile; serverId: string; generalNo: number }
|
||||||
|
): Promise<LegacyGeneralHallRow[]> =>
|
||||||
|
db.$queryRaw<LegacyGeneralHallRow[]>(GamePrisma.sql`
|
||||||
|
SELECT
|
||||||
|
"type",
|
||||||
|
"value"
|
||||||
|
FROM "legacy_archive"."hall"
|
||||||
|
WHERE "source_profile" = ${input.sourceProfile}
|
||||||
|
AND "server_id" = ${input.serverId}
|
||||||
|
AND "general_no" = ${input.generalNo}
|
||||||
|
AND "type" IN (${GamePrisma.join(LEGACY_GENERAL_HALL_TYPES)})
|
||||||
|
ORDER BY "type"
|
||||||
|
`);
|
||||||
|
|
||||||
export const findLegacyGeneralsForServer = async (
|
export const findLegacyGeneralsForServer = async (
|
||||||
db: LegacyArchiveDatabase,
|
db: LegacyArchiveDatabase,
|
||||||
input: { sourceProfile: LegacyArchiveProfile; serverId: string; generalNos: number[] }
|
input: { sourceProfile: LegacyArchiveProfile; serverId: string; generalNos: number[] }
|
||||||
|
|||||||
@@ -31,9 +31,22 @@ const context = (
|
|||||||
includeCancellation = false
|
includeCancellation = false
|
||||||
): GameApiContext => {
|
): GameApiContext => {
|
||||||
const db = {
|
const db = {
|
||||||
$queryRaw: async (query: { strings?: readonly string[] }) => {
|
$queryRaw: async (query: { strings?: readonly string[]; values?: readonly unknown[] }) => {
|
||||||
if (!includeLegacy) return [];
|
if (!includeLegacy) return [];
|
||||||
const sql = query.strings?.join(' ') ?? '';
|
const sql = query.strings?.join(' ') ?? '';
|
||||||
|
if (sql.includes('legacy_archive"."hall')) {
|
||||||
|
return [
|
||||||
|
{ type: 'firenum', value: 4 },
|
||||||
|
{ type: 'warnum', value: 16 },
|
||||||
|
{ type: 'killnum', value: 10 },
|
||||||
|
{ type: 'winrate', value: 0.625 },
|
||||||
|
{ type: 'occupied', value: 3 },
|
||||||
|
{ type: 'killcrew', value: 12_000 },
|
||||||
|
{ type: 'killrate', value: 0.75 },
|
||||||
|
{ type: 'killcrew_person', value: 9_000 },
|
||||||
|
{ type: 'killrate_person', value: 0.5 },
|
||||||
|
];
|
||||||
|
}
|
||||||
if (sql.includes('legacy_archive"."general_battle_result')) {
|
if (sql.includes('legacy_archive"."general_battle_result')) {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
@@ -44,6 +57,7 @@ const context = (
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
if (sql.includes('legacy_archive"."general')) {
|
if (sql.includes('legacy_archive"."general')) {
|
||||||
|
if (!query.values?.includes('hwe')) return [];
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
sourceProfile: 'hwe',
|
sourceProfile: 'hwe',
|
||||||
@@ -300,10 +314,12 @@ const context = (
|
|||||||
|
|
||||||
describe('archive.myPastPlays', () => {
|
describe('archive.myPastPlays', () => {
|
||||||
it('requires authentication and returns only the authenticated owner archive', async () => {
|
it('requires authentication and returns only the authenticated owner archive', async () => {
|
||||||
await expect(appRouter.createCaller(context(null)).archive.myPastPlays()).rejects.toMatchObject({
|
await expect(
|
||||||
|
appRouter.createCaller(context(null)).archive.myPastPlays({ sourceProfile: 'che' })
|
||||||
|
).rejects.toMatchObject({
|
||||||
code: 'UNAUTHORIZED',
|
code: 'UNAUTHORIZED',
|
||||||
});
|
});
|
||||||
const result = await appRouter.createCaller(context(auth)).archive.myPastPlays();
|
const result = await appRouter.createCaller(context(auth)).archive.myPastPlays({ sourceProfile: 'che' });
|
||||||
expect(result.seasons).toEqual([
|
expect(result.seasons).toEqual([
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
source: 'current',
|
source: 'current',
|
||||||
@@ -381,7 +397,9 @@ describe('archive.myPastPlays', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('labels a retained cancellation as an unnumbered abandoned game without a dynasty link', async () => {
|
it('labels a retained cancellation as an unnumbered abandoned game without a dynasty link', async () => {
|
||||||
const result = await appRouter.createCaller(context(auth, false, true)).archive.myPastPlays();
|
const result = await appRouter
|
||||||
|
.createCaller(context(auth, false, true))
|
||||||
|
.archive.myPastPlays({ sourceProfile: 'che' });
|
||||||
|
|
||||||
expect(result.seasons).toEqual([
|
expect(result.seasons).toEqual([
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
@@ -397,7 +415,7 @@ describe('archive.myPastPlays', () => {
|
|||||||
|
|
||||||
it('returns normalized previous-server detail from the dedicated archive without exposing raw data', async () => {
|
it('returns normalized previous-server detail from the dedicated archive without exposing raw data', async () => {
|
||||||
const caller = appRouter.createCaller(context(auth, true));
|
const caller = appRouter.createCaller(context(auth, true));
|
||||||
const list = await caller.archive.myPastPlays();
|
const list = await caller.archive.myPastPlays({ sourceProfile: 'hwe' });
|
||||||
expect(list.seasons).toContainEqual(
|
expect(list.seasons).toContainEqual(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
source: 'legacy',
|
source: 'legacy',
|
||||||
@@ -427,6 +445,19 @@ describe('archive.myPastPlays', () => {
|
|||||||
progression: expect.objectContaining({ dex: [1000, 2000, 3000, 4000, 5000] }),
|
progression: expect.objectContaining({ dex: [1000, 2000, 3000, 4000, 5000] }),
|
||||||
}),
|
}),
|
||||||
battle: expect.objectContaining({ available: true, warnum: 10, wins: 6, winRate: 60 }),
|
battle: expect.objectContaining({ available: true, warnum: 10, wins: 6, winRate: 60 }),
|
||||||
|
hallBattle: {
|
||||||
|
available: true,
|
||||||
|
semantics: 'independent-records',
|
||||||
|
strategies: 4,
|
||||||
|
warnum: 16,
|
||||||
|
wins: 10,
|
||||||
|
winRate: 62.5,
|
||||||
|
occupied: 3,
|
||||||
|
killCrew: 12_000,
|
||||||
|
killRate: 75,
|
||||||
|
killCrewPerson: 9_000,
|
||||||
|
killRatePerson: 50,
|
||||||
|
},
|
||||||
logs: expect.objectContaining({
|
logs: expect.objectContaining({
|
||||||
generalHistory: { available: true, entries: [{ id: 1, text: '이전 서버 열전' }] },
|
generalHistory: { available: true, entries: [{ id: 1, text: '이전 서버 열전' }] },
|
||||||
battleDetail: { available: false, entries: [] },
|
battleDetail: { available: false, entries: [] },
|
||||||
@@ -441,4 +472,17 @@ describe('archive.myPastPlays', () => {
|
|||||||
});
|
});
|
||||||
expect(JSON.stringify(detail)).not.toContain('raw_data');
|
expect(JSON.stringify(detail)).not.toContain('raw_data');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('limits the archive list to the requested source profile', async () => {
|
||||||
|
const caller = appRouter.createCaller(context(auth, true));
|
||||||
|
|
||||||
|
const che = await caller.archive.myPastPlays({ sourceProfile: 'che' });
|
||||||
|
expect(che.seasons).toHaveLength(1);
|
||||||
|
expect(che.seasons[0]?.sourceProfile).toBe('che');
|
||||||
|
|
||||||
|
const hwe = await caller.archive.myPastPlays({ sourceProfile: 'hwe' });
|
||||||
|
expect(hwe.seasons).toHaveLength(1);
|
||||||
|
expect(hwe.seasons[0]?.sourceProfile).toBe('hwe');
|
||||||
|
expect(hwe.seasons[0]?.source).toBe('legacy');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,19 +9,25 @@ const operationNames = (route: Route) =>
|
|||||||
decodeURIComponent(new URL(route.request().url()).pathname.split('/trpc/')[1] ?? '').split(',');
|
decodeURIComponent(new URL(route.request().url()).pathname.split('/trpc/')[1] ?? '').split(',');
|
||||||
|
|
||||||
const installArchive = async (page: Page, options: { battleAvailable?: boolean; abandoned?: boolean } = {}) => {
|
const installArchive = async (page: Page, options: { battleAvailable?: boolean; abandoned?: boolean } = {}) => {
|
||||||
|
const requestedProfiles: string[] = [];
|
||||||
await page.addInitScript((profile) => {
|
await page.addInitScript((profile) => {
|
||||||
localStorage.setItem('sammo-game-token', 'ga_archive');
|
localStorage.setItem('sammo-game-token', 'ga_archive');
|
||||||
localStorage.setItem('sammo-game-profile', profile);
|
localStorage.setItem('sammo-game-profile', profile);
|
||||||
}, gameProfile);
|
}, gameProfile);
|
||||||
await page.route(gameTrpcRoute, async (route) => {
|
await page.route(gameTrpcRoute, async (route) => {
|
||||||
|
const requestBody = route.request().postData() ?? '';
|
||||||
|
const requestedProfile = ['che', 'kwe', 'pwe', 'twe', 'nya', 'pya', 'hwe'].find((profile) =>
|
||||||
|
requestBody.includes(`"sourceProfile":"${profile}"`)
|
||||||
|
);
|
||||||
const results = operationNames(route).map((operation) => {
|
const results = operationNames(route).map((operation) => {
|
||||||
if (operation === 'auth.status') return response({ ok: true });
|
if (operation === 'auth.status') return response({ ok: true });
|
||||||
if (operation === 'lobby.info') return response({ myGeneral: null });
|
if (operation === 'lobby.info') return response({ myGeneral: null });
|
||||||
if (operation === 'archive.myPastPlays') {
|
if (operation === 'archive.myPastPlays') {
|
||||||
|
requestedProfiles.push(requestedProfile ?? 'missing');
|
||||||
return response({
|
return response({
|
||||||
seasons: [
|
seasons: [
|
||||||
{
|
{
|
||||||
sourceProfile: 'che',
|
sourceProfile: requestedProfile ?? 'che',
|
||||||
source: options.abandoned ? 'current' : 'legacy',
|
source: options.abandoned ? 'current' : 'legacy',
|
||||||
serverId: 'che_2024_01',
|
serverId: 'che_2024_01',
|
||||||
openedAt: '2024-01-31T00:00:00.000Z',
|
openedAt: '2024-01-31T00:00:00.000Z',
|
||||||
@@ -109,6 +115,19 @@ const installArchive = async (page: Page, options: { battleAvailable?: boolean;
|
|||||||
killRate: 75,
|
killRate: 75,
|
||||||
recentWar: '2024-01-30T03:00:00.000Z',
|
recentWar: '2024-01-30T03:00:00.000Z',
|
||||||
},
|
},
|
||||||
|
hallBattle: {
|
||||||
|
available: true,
|
||||||
|
semantics: 'independent-records',
|
||||||
|
strategies: 4,
|
||||||
|
warnum: 18,
|
||||||
|
wins: 12,
|
||||||
|
winRate: 66.67,
|
||||||
|
occupied: 3,
|
||||||
|
killCrew: 14_000,
|
||||||
|
killRate: 80,
|
||||||
|
killCrewPerson: 9_000,
|
||||||
|
killRatePerson: 60,
|
||||||
|
},
|
||||||
logs: {
|
logs: {
|
||||||
generalHistory: {
|
generalHistory: {
|
||||||
available: true,
|
available: true,
|
||||||
@@ -136,8 +155,28 @@ const installArchive = async (page: Page, options: { battleAvailable?: boolean;
|
|||||||
});
|
});
|
||||||
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(results) });
|
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(results) });
|
||||||
});
|
});
|
||||||
|
return { requestedProfiles };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
test('과거 장수 기록을 체·퀘·풰·퉤·냐·퍄·훼 서버별로 나누어 조회한다', async ({ page }) => {
|
||||||
|
const state = await installArchive(page);
|
||||||
|
await page.goto('past-plays');
|
||||||
|
|
||||||
|
const tabs = page.getByRole('navigation', { name: '과거 장수 서버 선택' });
|
||||||
|
await expect(tabs.getByRole('button')).toHaveCount(7);
|
||||||
|
await expect(tabs.getByRole('button')).toHaveText(['체', '퀘', '풰', '퉤', '냐', '퍄', '훼']);
|
||||||
|
await expect(tabs.getByRole('button', { name: '체 서버' })).toHaveAttribute('aria-pressed', 'true');
|
||||||
|
await expect(tabs.getByRole('button', { name: '체 서버' })).toHaveCSS('color', 'rgb(135, 206, 235)');
|
||||||
|
await expect(page.locator('.season-identity').getByText('체', { exact: true })).toBeVisible();
|
||||||
|
|
||||||
|
await tabs.getByRole('button', { name: '훼 서버' }).hover();
|
||||||
|
await expect(tabs.getByRole('button', { name: '훼 서버' })).toHaveCSS('color', 'rgb(135, 206, 235)');
|
||||||
|
await tabs.getByRole('button', { name: '훼 서버' }).click();
|
||||||
|
await expect(tabs.getByRole('button', { name: '훼 서버' })).toHaveAttribute('aria-pressed', 'true');
|
||||||
|
await expect(page.locator('.season-identity').getByText('훼', { exact: true })).toBeVisible();
|
||||||
|
expect(state.requestedProfiles).toEqual(['che', 'hwe']);
|
||||||
|
});
|
||||||
|
|
||||||
test('지난 플레이 관직은 숫자 대신 저장된 Ref 표시명으로 나타난다', async ({ page }) => {
|
test('지난 플레이 관직은 숫자 대신 저장된 Ref 표시명으로 나타난다', async ({ page }) => {
|
||||||
await installArchive(page);
|
await installArchive(page);
|
||||||
await page.goto('past-plays');
|
await page.goto('past-plays');
|
||||||
@@ -208,7 +247,7 @@ test('past plays is available without a current general and preserves desktop in
|
|||||||
await expect(page.getByRole('heading', { name: '내 지난 플레이 보기' })).toBeVisible();
|
await expect(page.getByRole('heading', { name: '내 지난 플레이 보기' })).toBeVisible();
|
||||||
await expect(page.getByText('천하쟁패 · 51기')).toBeVisible();
|
await expect(page.getByText('천하쟁패 · 51기')).toBeVisible();
|
||||||
await expect(page.getByText('이전 서버 기록')).toBeVisible();
|
await expect(page.getByText('이전 서버 기록')).toBeVisible();
|
||||||
await expect(page.getByText('che', { exact: true })).toBeVisible();
|
await expect(page.locator('.season-identity').getByText('체', { exact: true })).toBeVisible();
|
||||||
await expect(page.getByText(/2024.*개장/)).toBeVisible();
|
await expect(page.getByText(/2024.*개장/)).toBeVisible();
|
||||||
await expect(page.locator('.general-name')).toHaveText('관우');
|
await expect(page.locator('.general-name')).toHaveText('관우');
|
||||||
await expect(page.locator('tbody tr').filter({ hasText: '관우' })).toContainText('황제');
|
await expect(page.locator('tbody tr').filter({ hasText: '관우' })).toContainText('황제');
|
||||||
@@ -226,6 +265,12 @@ test('past plays is available without a current general and preserves desktop in
|
|||||||
await expect(page.locator('.archive-general-card [role="progressbar"]')).toHaveCount(14);
|
await expect(page.locator('.archive-general-card [role="progressbar"]')).toHaveCount(14);
|
||||||
await expect(page.locator('[data-general-battle-summary]')).toContainText('승률62.5%');
|
await expect(page.locator('[data-general-battle-summary]')).toContainText('승률62.5%');
|
||||||
await expect(page.locator('[data-general-battle-summary]')).toContainText('살상률75.0%');
|
await expect(page.locator('[data-general-battle-summary]')).toContainText('살상률75.0%');
|
||||||
|
const hallBattle = page.locator('[data-hall-battle-record]');
|
||||||
|
await expect(hallBattle).toContainText('명예의 전당 보존 기록');
|
||||||
|
await expect(hallBattle).toContainText('항목별 기록 시점이 서로 다를 수 있습니다.');
|
||||||
|
await expect(hallBattle).toContainText('18');
|
||||||
|
await expect(hallBattle).toContainText('66.67%');
|
||||||
|
await expect(hallBattle).toContainText('대인 사살');
|
||||||
await expect(page.locator('[data-log-type="battleDetail"]')).toContainText(
|
await expect(page.locator('[data-log-type="battleDetail"]')).toContainText(
|
||||||
'이 기수에는 전투 기록이 보존되지 않았습니다.'
|
'이 기수에는 전투 기록이 보존되지 않았습니다.'
|
||||||
);
|
);
|
||||||
@@ -305,10 +350,20 @@ test('past plays keeps the legacy-width table scrollable on a mobile viewport',
|
|||||||
const detailMetrics = await page.locator('.detail-shell').evaluate((element) => ({
|
const detailMetrics = await page.locator('.detail-shell').evaluate((element) => ({
|
||||||
width: element.getBoundingClientRect().width,
|
width: element.getBoundingClientRect().width,
|
||||||
scrollWidth: element.scrollWidth,
|
scrollWidth: element.scrollWidth,
|
||||||
|
profileTabsWidth: document.querySelector('.profile-tabs')!.getBoundingClientRect().width,
|
||||||
|
profileTabsScrollWidth: document.querySelector('.profile-tabs')!.scrollWidth,
|
||||||
|
hallRecordWidth: element.querySelector('[data-hall-battle-record]')!.getBoundingClientRect().width,
|
||||||
|
hallRecordScrollWidth: element.querySelector('[data-hall-battle-record]')!.scrollWidth,
|
||||||
|
hallColumns: getComputedStyle(element.querySelector('[data-hall-battle-record] dl')!).gridTemplateColumns,
|
||||||
recordBottom: element.querySelector('[data-general-record-panels]')!.getBoundingClientRect().bottom,
|
recordBottom: element.querySelector('[data-general-record-panels]')!.getBoundingClientRect().bottom,
|
||||||
shellBottom: element.getBoundingClientRect().bottom,
|
shellBottom: element.getBoundingClientRect().bottom,
|
||||||
}));
|
}));
|
||||||
expect(detailMetrics.width).toBe(498);
|
expect(detailMetrics.width).toBe(498);
|
||||||
expect(detailMetrics.scrollWidth).toBe(498);
|
expect(detailMetrics.scrollWidth).toBe(498);
|
||||||
|
expect(detailMetrics.profileTabsWidth).toBe(500);
|
||||||
|
expect(detailMetrics.profileTabsScrollWidth).toBeLessThanOrEqual(detailMetrics.profileTabsWidth);
|
||||||
|
expect(detailMetrics.hallRecordWidth).toBeGreaterThan(0);
|
||||||
|
expect(detailMetrics.hallRecordScrollWidth).toBeLessThanOrEqual(detailMetrics.hallRecordWidth);
|
||||||
|
expect(detailMetrics.hallColumns.split(' ')).toHaveLength(3);
|
||||||
expect(detailMetrics.recordBottom).toBeLessThanOrEqual(detailMetrics.shellBottom);
|
expect(detailMetrics.recordBottom).toBeLessThanOrEqual(detailMetrics.shellBottom);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref } from 'vue';
|
import { computed, onMounted, ref } from 'vue';
|
||||||
|
|
||||||
|
import { isLegacyArchiveProfile, LEGACY_ARCHIVE_PROFILES, type LegacyArchiveProfile } from '@sammo-ts/common';
|
||||||
|
|
||||||
import GeneralBasicCard from '../components/main/GeneralBasicCard.vue';
|
import GeneralBasicCard from '../components/main/GeneralBasicCard.vue';
|
||||||
import GeneralBattleSummary, { type GeneralBattleSummaryData } from '../components/main/GeneralBattleSummary.vue';
|
import GeneralBattleSummary, { type GeneralBattleSummaryData } from '../components/main/GeneralBattleSummary.vue';
|
||||||
import GeneralRecordPanels from '../components/main/GeneralRecordPanels.vue';
|
import GeneralRecordPanels from '../components/main/GeneralRecordPanels.vue';
|
||||||
@@ -79,6 +81,19 @@ type PastPlayDetail = {
|
|||||||
winRate?: number | null;
|
winRate?: number | null;
|
||||||
killRate?: number | null;
|
killRate?: number | null;
|
||||||
};
|
};
|
||||||
|
hallBattle: {
|
||||||
|
available: boolean;
|
||||||
|
semantics: 'independent-records';
|
||||||
|
strategies: number | null;
|
||||||
|
warnum: number | null;
|
||||||
|
wins: number | null;
|
||||||
|
winRate: number | null;
|
||||||
|
occupied: number | null;
|
||||||
|
killCrew: number | null;
|
||||||
|
killRate: number | null;
|
||||||
|
killCrewPerson: number | null;
|
||||||
|
killRatePerson: number | null;
|
||||||
|
};
|
||||||
logs: Partial<Record<GeneralRecordType, ArchiveLogChannel>>;
|
logs: Partial<Record<GeneralRecordType, ArchiveLogChannel>>;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -93,6 +108,21 @@ const queryPastPlayDetail = trpc.archive.myPastPlayDetail.query as unknown as (
|
|||||||
input: PastPlayDetailInput
|
input: PastPlayDetailInput
|
||||||
) => Promise<PastPlayDetail>;
|
) => Promise<PastPlayDetail>;
|
||||||
|
|
||||||
|
const profileLabels: Record<LegacyArchiveProfile, string> = {
|
||||||
|
che: '체',
|
||||||
|
kwe: '퀘',
|
||||||
|
pwe: '풰',
|
||||||
|
twe: '퉤',
|
||||||
|
nya: '냐',
|
||||||
|
pya: '퍄',
|
||||||
|
hwe: '훼',
|
||||||
|
};
|
||||||
|
const profileOptions = LEGACY_ARCHIVE_PROFILES.map((profile) => ({ profile, label: profileLabels[profile] }));
|
||||||
|
const configuredProfile = import.meta.env.VITE_GAME_PROFILE?.trim();
|
||||||
|
const selectedProfile = ref<LegacyArchiveProfile>(
|
||||||
|
configuredProfile && isLegacyArchiveProfile(configuredProfile) ? configuredProfile : 'che'
|
||||||
|
);
|
||||||
|
|
||||||
const archive = ref<Archive | null>(null);
|
const archive = ref<Archive | null>(null);
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const error = ref<string | null>(null);
|
const error = ref<string | null>(null);
|
||||||
@@ -100,24 +130,44 @@ const selectedKey = ref<string | null>(null);
|
|||||||
const detail = ref<PastPlayDetail | null>(null);
|
const detail = ref<PastPlayDetail | null>(null);
|
||||||
const detailLoading = ref(false);
|
const detailLoading = ref(false);
|
||||||
const detailError = ref<string | null>(null);
|
const detailError = ref<string | null>(null);
|
||||||
|
let archiveRequestId = 0;
|
||||||
|
|
||||||
const loadArchive = async () => {
|
const loadArchive = async () => {
|
||||||
if (loading.value) return;
|
const requestId = ++archiveRequestId;
|
||||||
|
const sourceProfile = selectedProfile.value;
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
error.value = null;
|
error.value = null;
|
||||||
try {
|
try {
|
||||||
archive.value = await trpc.archive.myPastPlays.query();
|
const result = await trpc.archive.myPastPlays.query({ sourceProfile });
|
||||||
|
if (requestId === archiveRequestId) archive.value = result;
|
||||||
} catch (cause) {
|
} catch (cause) {
|
||||||
|
if (requestId === archiveRequestId) {
|
||||||
error.value = cause instanceof Error ? cause.message : '지난 플레이를 불러오지 못했습니다.';
|
error.value = cause instanceof Error ? cause.message : '지난 플레이를 불러오지 못했습니다.';
|
||||||
} finally {
|
|
||||||
loading.value = false;
|
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
|
if (requestId === archiveRequestId) loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectProfile = (profile: LegacyArchiveProfile): void => {
|
||||||
|
if (selectedProfile.value === profile) return;
|
||||||
|
selectedProfile.value = profile;
|
||||||
|
archive.value = null;
|
||||||
|
selectedKey.value = null;
|
||||||
|
detail.value = null;
|
||||||
|
detailError.value = null;
|
||||||
|
void loadArchive();
|
||||||
};
|
};
|
||||||
|
|
||||||
const yearMonth = (value: number): string => `${Math.floor(value / 100)}년 ${value % 100}월`;
|
const yearMonth = (value: number): string => `${Math.floor(value / 100)}년 ${value % 100}월`;
|
||||||
const valueOrDash = (value: number | string | null): string => (value === null || value === '' ? '-' : String(value));
|
const valueOrDash = (value: number | string | null): string => (value === null || value === '' ? '-' : String(value));
|
||||||
|
const hallNumber = (value: number | null): string =>
|
||||||
|
value === null ? '-' : new Intl.NumberFormat('ko-KR', { maximumFractionDigits: 0 }).format(value);
|
||||||
|
const hallPercent = (value: number | null): string => (value === null ? '-' : `${value.toFixed(2)}%`);
|
||||||
const plainLog = (value: string): string => value.replace(/<[^>]+>/g, '');
|
const plainLog = (value: string): string => value.replace(/<[^>]+>/g, '');
|
||||||
const seasonSourceProfile = (season: ArchiveSeason): string => season.sourceProfile ?? '현재 서버';
|
const seasonSourceProfile = (season: ArchiveSeason): string => season.sourceProfile ?? '현재 서버';
|
||||||
|
const profileDisplayName = (profile: string): string =>
|
||||||
|
isLegacyArchiveProfile(profile) ? profileLabels[profile] : profile;
|
||||||
const seasonSource = (season: ArchiveSeason): string => season.source ?? 'current';
|
const seasonSource = (season: ArchiveSeason): string => season.source ?? 'current';
|
||||||
const detailKey = (season: ArchiveSeason, generalNo: number): string =>
|
const detailKey = (season: ArchiveSeason, generalNo: number): string =>
|
||||||
`${seasonSourceProfile(season)}:${seasonSource(season)}:${season.serverId}:${generalNo}`;
|
`${seasonSourceProfile(season)}:${seasonSource(season)}:${season.serverId}:${generalNo}`;
|
||||||
@@ -207,6 +257,20 @@ onMounted(() => {
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<p class="page-note">종료된 기수와 관리자가 보존한 취소 게임의 내 장수 기록입니다.</p>
|
<p class="page-note">종료된 기수와 관리자가 보존한 취소 게임의 내 장수 기록입니다.</p>
|
||||||
|
<nav class="profile-tabs legacy-bg1" aria-label="과거 장수 서버 선택">
|
||||||
|
<button
|
||||||
|
v-for="option in profileOptions"
|
||||||
|
:key="option.profile"
|
||||||
|
class="profile-tab"
|
||||||
|
:class="{ selected: selectedProfile === option.profile }"
|
||||||
|
type="button"
|
||||||
|
:aria-label="`${option.label} 서버`"
|
||||||
|
:aria-pressed="selectedProfile === option.profile"
|
||||||
|
@click="selectProfile(option.profile)"
|
||||||
|
>
|
||||||
|
{{ option.label }}
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
<p v-if="error" class="error-row">{{ error }}</p>
|
<p v-if="error" class="error-row">{{ error }}</p>
|
||||||
<p v-else-if="loading && !archive" class="empty-row">불러오는 중...</p>
|
<p v-else-if="loading && !archive" class="empty-row">불러오는 중...</p>
|
||||||
<p v-else-if="archive?.seasons.length === 0" class="empty-row">보관된 지난 플레이가 없습니다.</p>
|
<p v-else-if="archive?.seasons.length === 0" class="empty-row">보관된 지난 플레이가 없습니다.</p>
|
||||||
@@ -217,7 +281,7 @@ onMounted(() => {
|
|||||||
<strong class="archive-label" :class="{ abandoned: season.status === 'ABANDONED' }">
|
<strong class="archive-label" :class="{ abandoned: season.status === 'ABANDONED' }">
|
||||||
{{ archiveLabel(season) }}
|
{{ archiveLabel(season) }}
|
||||||
</strong>
|
</strong>
|
||||||
<strong>{{ seasonSourceProfile(season) }}</strong>
|
<strong>{{ profileDisplayName(seasonSourceProfile(season)) }}</strong>
|
||||||
<span>{{ archiveIdentifier(season) }}</span>
|
<span>{{ archiveIdentifier(season) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="season-meta">
|
<div class="season-meta">
|
||||||
@@ -286,7 +350,7 @@ onMounted(() => {
|
|||||||
<p v-else-if="detailError" class="detail-error" role="alert">{{ detailError }}</p>
|
<p v-else-if="detailError" class="detail-error" role="alert">{{ detailError }}</p>
|
||||||
<div v-else-if="detail" class="detail-shell">
|
<div v-else-if="detail" class="detail-shell">
|
||||||
<div class="detail-source">
|
<div class="detail-source">
|
||||||
<span>{{ detail.sourceProfile }} · {{ detail.source }}</span>
|
<span>{{ profileDisplayName(detail.sourceProfile) }} · {{ detail.source }}</span>
|
||||||
<RouterLink
|
<RouterLink
|
||||||
v-if="detail.dynastyPath"
|
v-if="detail.dynastyPath"
|
||||||
class="legacy-button nation-archive-link"
|
class="legacy-button nation-archive-link"
|
||||||
@@ -305,6 +369,54 @@ onMounted(() => {
|
|||||||
>
|
>
|
||||||
<template #details>
|
<template #details>
|
||||||
<GeneralBattleSummary :summary="battleSummary" show-win-rate rate-scale="percent" />
|
<GeneralBattleSummary :summary="battleSummary" show-win-rate rate-scale="percent" />
|
||||||
|
<section
|
||||||
|
v-if="detail.hallBattle.available"
|
||||||
|
class="hall-battle-record"
|
||||||
|
data-hall-battle-record
|
||||||
|
>
|
||||||
|
<div class="hall-battle-record__heading">
|
||||||
|
<strong>명예의 전당 보존 기록</strong>
|
||||||
|
<span>항목별 기록 시점이 서로 다를 수 있습니다.</span>
|
||||||
|
</div>
|
||||||
|
<dl>
|
||||||
|
<div>
|
||||||
|
<dt>전투</dt>
|
||||||
|
<dd>{{ hallNumber(detail.hallBattle.warnum) }}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>승리</dt>
|
||||||
|
<dd>{{ hallNumber(detail.hallBattle.wins) }}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>승률</dt>
|
||||||
|
<dd>{{ hallPercent(detail.hallBattle.winRate) }}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>계략</dt>
|
||||||
|
<dd>{{ hallNumber(detail.hallBattle.strategies) }}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>점령</dt>
|
||||||
|
<dd>{{ hallNumber(detail.hallBattle.occupied) }}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>사살</dt>
|
||||||
|
<dd>{{ hallNumber(detail.hallBattle.killCrew) }}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>살상률</dt>
|
||||||
|
<dd>{{ hallPercent(detail.hallBattle.killRate) }}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>대인 사살</dt>
|
||||||
|
<dd>{{ hallNumber(detail.hallBattle.killCrewPerson) }}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>대인 살상률</dt>
|
||||||
|
<dd>{{ hallPercent(detail.hallBattle.killRatePerson) }}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
<LegacyGeneralProgress
|
<LegacyGeneralProgress
|
||||||
v-if="detail.masteryAvailable"
|
v-if="detail.masteryAvailable"
|
||||||
:general="detail.general"
|
:general="detail.general"
|
||||||
@@ -398,6 +510,7 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.page-note,
|
.page-note,
|
||||||
|
.profile-tabs,
|
||||||
.empty-row,
|
.empty-row,
|
||||||
.error-row {
|
.error-row {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@@ -406,6 +519,31 @@ onMounted(() => {
|
|||||||
border-bottom: 1px solid #666;
|
border-bottom: 1px solid #666;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.profile-tabs {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(7, minmax(0, 1fr));
|
||||||
|
gap: 4px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-tab {
|
||||||
|
min-height: 30px;
|
||||||
|
border: 1px solid #777;
|
||||||
|
color: #ddd;
|
||||||
|
background: #222;
|
||||||
|
font: inherit;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-tab:hover,
|
||||||
|
.profile-tab:focus-visible,
|
||||||
|
.profile-tab.selected {
|
||||||
|
border-color: skyblue;
|
||||||
|
color: skyblue;
|
||||||
|
background: #143a2a;
|
||||||
|
}
|
||||||
|
|
||||||
.page-note,
|
.page-note,
|
||||||
.season-heading span {
|
.season-heading span {
|
||||||
color: #bbb;
|
color: #bbb;
|
||||||
@@ -540,6 +678,53 @@ th {
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.hall-battle-record {
|
||||||
|
border-top: 1px solid #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hall-battle-record__heading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 5px 8px;
|
||||||
|
background: rgb(20 75 42 / 70%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hall-battle-record__heading span {
|
||||||
|
color: #bbb;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hall-battle-record dl {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hall-battle-record dl > div {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(70px, 1fr) 1fr;
|
||||||
|
min-height: 24px;
|
||||||
|
border-right: 1px solid #777;
|
||||||
|
border-bottom: 1px solid #777;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hall-battle-record dt,
|
||||||
|
.hall-battle-record dd {
|
||||||
|
margin: 0;
|
||||||
|
padding: 2px 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hall-battle-record dt {
|
||||||
|
background: rgb(20 75 42 / 45%);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hall-battle-record dd {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
.title-row,
|
.title-row,
|
||||||
.season-heading,
|
.season-heading,
|
||||||
@@ -556,5 +741,10 @@ th {
|
|||||||
.detail-grid {
|
.detail-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.hall-battle-record__heading {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user