merge: 최신 main을 사령부 고급모드 제어행 수정에 통합한다
This commit is contained in:
@@ -17,8 +17,8 @@ import { ConflictingTurnDaemonCommandError } from '../../daemon/databaseTranspor
|
|||||||
import { resolveAccessWindows } from '../../services/generalAccess.js';
|
import { resolveAccessWindows } from '../../services/generalAccess.js';
|
||||||
import { adjustAccountIconForUser } from '../../services/accountIconSync.js';
|
import { adjustAccountIconForUser } from '../../services/accountIconSync.js';
|
||||||
import {
|
import {
|
||||||
loadCrewTypeDisplayNames,
|
loadCrewTypeDisplayDetails,
|
||||||
loadItemDisplayNames,
|
loadItemDisplayDetails,
|
||||||
resolveCityLevelName,
|
resolveCityLevelName,
|
||||||
resolveDedicationLevelName,
|
resolveDedicationLevelName,
|
||||||
resolveNationLevelName,
|
resolveNationLevelName,
|
||||||
@@ -428,13 +428,13 @@ export const getGeneralContext = async (ctx: GameApiContext) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const [personalityNames, domesticNames, warNames, nationTypeNames, crewTypeNames, itemNames] = await Promise.all([
|
const [personalityNames, domesticNames, warNames, nationTypeNames, crewTypeDetails, itemDetails] = await Promise.all([
|
||||||
loadTraitNames([general.personalCode], 'personality'),
|
loadTraitNames([general.personalCode], 'personality'),
|
||||||
loadTraitNames([general.specialCode], 'domestic'),
|
loadTraitNames([general.specialCode], 'domestic'),
|
||||||
loadTraitNames([general.special2Code], 'war'),
|
loadTraitNames([general.special2Code], 'war'),
|
||||||
loadTraitNames([nation.typeCode], 'nation'),
|
loadTraitNames([nation.typeCode], 'nation'),
|
||||||
loadCrewTypeDisplayNames(worldState, ctx.profile.id),
|
loadCrewTypeDisplayDetails(worldState, ctx.profile.id),
|
||||||
loadItemDisplayNames([general.horseCode, general.weaponCode, general.bookCode, general.itemCode]),
|
loadItemDisplayDetails([general.horseCode, general.weaponCode, general.bookCode, general.itemCode]),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const worldConfig = asRecord(worldState?.config);
|
const worldConfig = asRecord(worldState?.config);
|
||||||
@@ -461,7 +461,11 @@ export const getGeneralContext = async (ctx: GameApiContext) => {
|
|||||||
);
|
);
|
||||||
const itemName = (code: string | null): string | null => {
|
const itemName = (code: string | null): string | null => {
|
||||||
const normalized = normalizeItemCode(code);
|
const normalized = normalizeItemCode(code);
|
||||||
return normalized ? (itemNames.get(normalized) ?? sanitizeInternalDisplayCode(normalized)) : null;
|
return normalized ? (itemDetails.get(normalized)?.name ?? sanitizeInternalDisplayCode(normalized)) : null;
|
||||||
|
};
|
||||||
|
const itemInfo = (code: string | null): string | null => {
|
||||||
|
const normalized = normalizeItemCode(code);
|
||||||
|
return normalized ? (itemDetails.get(normalized)?.info ?? '') : null;
|
||||||
};
|
};
|
||||||
const worldMeta = asRecord(worldState?.meta);
|
const worldMeta = asRecord(worldState?.meta);
|
||||||
const rawLastExecuted = worldMeta.lastTurnTime ?? worldMeta.turntime;
|
const rawLastExecuted = worldMeta.lastTurnTime ?? worldMeta.turntime;
|
||||||
@@ -523,12 +527,18 @@ export const getGeneralContext = async (ctx: GameApiContext) => {
|
|||||||
worldState?.tickSeconds ?? 0
|
worldState?.tickSeconds ?? 0
|
||||||
),
|
),
|
||||||
crewTypeId: general.crewTypeId,
|
crewTypeId: general.crewTypeId,
|
||||||
crewTypeName: crewTypeNames.get(general.crewTypeId) ?? '-',
|
crewTypeName: crewTypeDetails.get(general.crewTypeId)?.name ?? '-',
|
||||||
|
crewTypeInfo: crewTypeDetails.get(general.crewTypeId) ?? null,
|
||||||
traits: {
|
traits: {
|
||||||
personal: resolveTraitDisplayName(general.personalCode, personalityNames),
|
personal: resolveTraitDisplayName(general.personalCode, personalityNames),
|
||||||
specialDomestic: resolveTraitDisplayName(general.specialCode, domesticNames),
|
specialDomestic: resolveTraitDisplayName(general.specialCode, domesticNames),
|
||||||
specialWar: resolveTraitDisplayName(general.special2Code, warNames),
|
specialWar: resolveTraitDisplayName(general.special2Code, warNames),
|
||||||
},
|
},
|
||||||
|
traitInfo: {
|
||||||
|
personal: personalityNames.get(general.personalCode)?.info ?? '',
|
||||||
|
specialDomestic: domesticNames.get(general.specialCode)?.info ?? '',
|
||||||
|
specialWar: warNames.get(general.special2Code)?.info ?? '',
|
||||||
|
},
|
||||||
progression: {
|
progression: {
|
||||||
experienceLevel: readNumber(metaRecord.explevel, 0),
|
experienceLevel: readNumber(metaRecord.explevel, 0),
|
||||||
dedicationLevel,
|
dedicationLevel,
|
||||||
@@ -562,6 +572,12 @@ export const getGeneralContext = async (ctx: GameApiContext) => {
|
|||||||
book: itemName(general.bookCode),
|
book: itemName(general.bookCode),
|
||||||
item: itemName(general.itemCode),
|
item: itemName(general.itemCode),
|
||||||
},
|
},
|
||||||
|
itemInfo: {
|
||||||
|
horse: itemInfo(general.horseCode),
|
||||||
|
weapon: itemInfo(general.weaponCode),
|
||||||
|
book: itemInfo(general.bookCode),
|
||||||
|
item: itemInfo(general.itemCode),
|
||||||
|
},
|
||||||
troop: troop
|
troop: troop
|
||||||
? {
|
? {
|
||||||
name: troop.name,
|
name: troop.name,
|
||||||
@@ -603,6 +619,7 @@ export const getGeneralContext = async (ctx: GameApiContext) => {
|
|||||||
capitalCityId: nation.capitalCityId,
|
capitalCityId: nation.capitalCityId,
|
||||||
levelName: resolveNationLevelName(nation.level),
|
levelName: resolveNationLevelName(nation.level),
|
||||||
typeName: nation.id === 0 ? '-' : (nationType?.name ?? sanitizeInternalDisplayCode(nation.typeCode)),
|
typeName: nation.id === 0 ? '-' : (nationType?.name ?? sanitizeInternalDisplayCode(nation.typeCode)),
|
||||||
|
typeInfo: nation.id === 0 ? '' : (nationType?.info ?? ''),
|
||||||
typePros: nationTypeEffects.pros,
|
typePros: nationTypeEffects.pros,
|
||||||
typeCons: nationTypeEffects.cons,
|
typeCons: nationTypeEffects.cons,
|
||||||
capitalCityName: nation.id === 0 ? null : (capitalCity?.name ?? null),
|
capitalCityName: nation.id === 0 ? null : (capitalCity?.name ?? null),
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { asRecord } from '@sammo-ts/common';
|
import { asRecord } from '@sammo-ts/common';
|
||||||
import { isItemKey, ItemLoader } from '@sammo-ts/logic';
|
import { isItemKey, ItemLoader } from '@sammo-ts/logic';
|
||||||
import { loadUnitSetDefinitionByName } from '@sammo-ts/game-engine/scenario/unitSetLoader.js';
|
import { loadUnitSetDefinitionByName } from '@sammo-ts/game-engine/scenario/unitSetLoader.js';
|
||||||
|
import type { CrewTypeDefinition, CrewTypeRequirement } from '@sammo-ts/logic/world/types.js';
|
||||||
|
|
||||||
import type { WorldStateRow } from '../context.js';
|
import type { WorldStateRow } from '../context.js';
|
||||||
|
|
||||||
@@ -103,36 +104,172 @@ const resolveUnitSetName = (world: Pick<WorldStateRow, 'config'> | null, fallbac
|
|||||||
return typeof environment.unitSet === 'string' && environment.unitSet.trim() ? environment.unitSet : fallback;
|
return typeof environment.unitSet === 'string' && environment.unitSet.trim() ? environment.unitSet : fallback;
|
||||||
};
|
};
|
||||||
|
|
||||||
const crewTypeNameCache = new Map<string, Promise<Map<number, string>>>();
|
export interface CrewTypeDisplayDetails {
|
||||||
|
name: string;
|
||||||
|
info: string[];
|
||||||
|
requirements: string[];
|
||||||
|
stats: {
|
||||||
|
attack: number;
|
||||||
|
defence: number;
|
||||||
|
speed: number;
|
||||||
|
avoid: number;
|
||||||
|
magicCoef: number;
|
||||||
|
cost: number;
|
||||||
|
rice: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export const loadCrewTypeDisplayNames = (
|
const KNOWN_NATION_AUX_LABELS: Readonly<Record<string, string>> = {
|
||||||
|
can_대검병사용: '대검병 연구',
|
||||||
|
can_극병사용: '극병 연구',
|
||||||
|
can_화시병사용: '화시병 연구',
|
||||||
|
can_원융노병사용: '원융노병 연구',
|
||||||
|
can_산저병사용: '산저병 연구',
|
||||||
|
can_상병사용: '상병 연구',
|
||||||
|
can_음귀병사용: '음귀병 연구',
|
||||||
|
can_무희사용: '무희 연구',
|
||||||
|
can_화륜차사용: '화륜차 연구',
|
||||||
|
did_특성초토화: '특성 초토화',
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatNationAuxRequirement = (requirement: {
|
||||||
|
type: 'ReqNationAux';
|
||||||
|
key: string;
|
||||||
|
op: string;
|
||||||
|
value: number | string;
|
||||||
|
}): string => {
|
||||||
|
const knownLabel = KNOWN_NATION_AUX_LABELS[requirement.key];
|
||||||
|
if (knownLabel && requirement.key !== 'did_특성초토화' && requirement.op === '==' && requirement.value === 1) {
|
||||||
|
return `${knownLabel} 시 가능`;
|
||||||
|
}
|
||||||
|
if (requirement.key === 'did_특성초토화' && requirement.op === '>=' && requirement.value === 1) {
|
||||||
|
return `${knownLabel ?? requirement.key} 시 가능`;
|
||||||
|
}
|
||||||
|
if (requirement.op === '==' && requirement.value === 0) return `${requirement.key} 없을 때`;
|
||||||
|
if (requirement.op === '==' && requirement.value === 1) return `${requirement.key} 있을 때`;
|
||||||
|
if (requirement.op === '!=' && requirement.value === 0) return `${requirement.key} 없을 때`;
|
||||||
|
if (requirement.op === '!=' && requirement.value === 1) return `${requirement.key} 있을 때`;
|
||||||
|
const operator = requirement.op === '==' ? '=' : requirement.op;
|
||||||
|
return `${requirement.key} ${operator} ${String(requirement.value)} 일 때`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const formatCrewTypeRequirement = (requirement: CrewTypeRequirement): string => {
|
||||||
|
switch (requirement.type) {
|
||||||
|
case 'ReqTech': {
|
||||||
|
const detail = requirement as { type: 'ReqTech'; tech: number };
|
||||||
|
return `기술력 ${detail.tech} 이상 필요`;
|
||||||
|
}
|
||||||
|
case 'ReqRegions': {
|
||||||
|
const detail = requirement as { type: 'ReqRegions'; regions: string[] };
|
||||||
|
return `${detail.regions.join(', ')} 지역 소유시 가능`;
|
||||||
|
}
|
||||||
|
case 'ReqCities': {
|
||||||
|
const detail = requirement as { type: 'ReqCities'; cities: string[] };
|
||||||
|
return `${detail.cities.join(', ')} 소유시 가능`;
|
||||||
|
}
|
||||||
|
case 'ReqCitiesWithCityLevel': {
|
||||||
|
const detail = requirement as { type: 'ReqCitiesWithCityLevel'; level: number; cities: string[] };
|
||||||
|
return `${detail.cities.join(', ')} ${resolveCityLevelName(detail.level)}성 소유시 가능`;
|
||||||
|
}
|
||||||
|
case 'ReqHighLevelCities': {
|
||||||
|
const detail = requirement as { type: 'ReqHighLevelCities'; level: number; count: number };
|
||||||
|
return `${resolveCityLevelName(detail.level)}성 ${detail.count}개 이상 소유시 가능`;
|
||||||
|
}
|
||||||
|
case 'ReqNationAux':
|
||||||
|
return formatNationAuxRequirement(
|
||||||
|
requirement as { type: 'ReqNationAux'; key: string; op: string; value: number | string }
|
||||||
|
);
|
||||||
|
case 'ReqMinRelYear': {
|
||||||
|
const detail = requirement as { type: 'ReqMinRelYear'; year: number };
|
||||||
|
return `${detail.year}년 경과 후 사용 가능`;
|
||||||
|
}
|
||||||
|
case 'ReqChief':
|
||||||
|
return '군주 및 수뇌부만 가능';
|
||||||
|
case 'ReqNotChief':
|
||||||
|
return '군주 및 수뇌부는 불가';
|
||||||
|
case 'Impossible':
|
||||||
|
return '불가능';
|
||||||
|
default:
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const crewTypeDetailsCache = new Map<string, Promise<Map<number, CrewTypeDisplayDetails>>>();
|
||||||
|
|
||||||
|
const toCrewTypeDisplayDetails = (crewType: CrewTypeDefinition): CrewTypeDisplayDetails => ({
|
||||||
|
name: crewType.name,
|
||||||
|
info: crewType.info.filter(Boolean),
|
||||||
|
requirements: crewType.requirements.map(formatCrewTypeRequirement).filter(Boolean),
|
||||||
|
stats: {
|
||||||
|
attack: crewType.attack,
|
||||||
|
defence: crewType.defence,
|
||||||
|
speed: crewType.speed,
|
||||||
|
avoid: crewType.avoid,
|
||||||
|
magicCoef: crewType.magicCoef,
|
||||||
|
cost: crewType.cost,
|
||||||
|
rice: crewType.rice,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const loadCrewTypeDisplayDetails = (
|
||||||
world: Pick<WorldStateRow, 'config'> | null,
|
world: Pick<WorldStateRow, 'config'> | null,
|
||||||
fallback: string
|
fallback: string
|
||||||
): Promise<Map<number, string>> => {
|
): Promise<Map<number, CrewTypeDisplayDetails>> => {
|
||||||
const unitSetName = resolveUnitSetName(world, fallback);
|
const unitSetName = resolveUnitSetName(world, fallback);
|
||||||
const cached = crewTypeNameCache.get(unitSetName);
|
const cached = crewTypeDetailsCache.get(unitSetName);
|
||||||
if (cached) {
|
if (cached) {
|
||||||
return cached;
|
return cached;
|
||||||
}
|
}
|
||||||
const pending = loadUnitSetDefinitionByName(unitSetName)
|
const pending = loadUnitSetDefinitionByName(unitSetName)
|
||||||
.then((definition) => new Map((definition.crewTypes ?? []).map((crewType) => [crewType.id, crewType.name])))
|
.then(
|
||||||
.catch(() => new Map<number, string>());
|
(definition) =>
|
||||||
crewTypeNameCache.set(unitSetName, pending);
|
new Map(
|
||||||
|
(definition.crewTypes ?? []).map((crewType) => [crewType.id, toCrewTypeDisplayDetails(crewType)])
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.catch(() => new Map<number, CrewTypeDisplayDetails>());
|
||||||
|
crewTypeDetailsCache.set(unitSetName, pending);
|
||||||
return pending;
|
return pending;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const loadCrewTypeDisplayNames = (
|
||||||
|
world: Pick<WorldStateRow, 'config'> | null,
|
||||||
|
fallback: string
|
||||||
|
): Promise<Map<number, string>> =>
|
||||||
|
loadCrewTypeDisplayDetails(world, fallback).then(
|
||||||
|
(details) => new Map(Array.from(details, ([id, detail]) => [id, detail.name]))
|
||||||
|
);
|
||||||
|
|
||||||
const itemLoader = new ItemLoader();
|
const itemLoader = new ItemLoader();
|
||||||
|
|
||||||
export const loadItemDisplayNames = async (values: Array<string | null | undefined>): Promise<Map<string, string>> => {
|
export interface ItemDisplayDetails {
|
||||||
|
name: string;
|
||||||
|
info: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const loadItemDisplayDetails = async (
|
||||||
|
values: Array<string | null | undefined>
|
||||||
|
): Promise<Map<string, ItemDisplayDetails>> => {
|
||||||
const keys = Array.from(new Set(values.filter((value): value is string => Boolean(value) && value !== 'None')));
|
const keys = Array.from(new Set(values.filter((value): value is string => Boolean(value) && value !== 'None')));
|
||||||
const entries = await Promise.all(
|
const entries = await Promise.all(
|
||||||
keys.map(async (key) => {
|
keys.map(async (key) => {
|
||||||
if (!isItemKey(key)) {
|
if (!isItemKey(key)) {
|
||||||
return [key, sanitizeInternalDisplayCode(key)] as const;
|
return [key, { name: sanitizeInternalDisplayCode(key), info: '' }] as const;
|
||||||
}
|
}
|
||||||
const item = await itemLoader.load(key).catch(() => null);
|
const item = await itemLoader.load(key).catch(() => null);
|
||||||
return [key, item?.name ?? sanitizeInternalDisplayCode(key)] as const;
|
return [
|
||||||
|
key,
|
||||||
|
{
|
||||||
|
name: item?.name ?? sanitizeInternalDisplayCode(key),
|
||||||
|
info: item?.info ?? '',
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
return new Map(entries);
|
return new Map(entries);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const loadItemDisplayNames = async (values: Array<string | null | undefined>): Promise<Map<string, string>> => {
|
||||||
|
const details = await loadItemDisplayDetails(values);
|
||||||
|
return new Map(Array.from(details, ([key, detail]) => [key, detail.name]));
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
formatCrewTypeRequirement,
|
||||||
resolveCityLevelName,
|
resolveCityLevelName,
|
||||||
resolveDedicationLevelName,
|
resolveDedicationLevelName,
|
||||||
resolveNationLevelName,
|
resolveNationLevelName,
|
||||||
@@ -29,4 +30,27 @@ describe('Ref GUI display names', () => {
|
|||||||
expect(resolveCityLevelName(99)).toBe('-');
|
expect(resolveCityLevelName(99)).toBe('-');
|
||||||
expect(resolveRegionName(99)).toBe('-');
|
expect(resolveRegionName(99)).toBe('-');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('formats crew-type requirements with the same text as Ref tooltips', () => {
|
||||||
|
expect(formatCrewTypeRequirement({ type: 'ReqTech', tech: 1_000 })).toBe('기술력 1000 이상 필요');
|
||||||
|
expect(formatCrewTypeRequirement({ type: 'ReqRegions', regions: ['중원', '오월'] })).toBe(
|
||||||
|
'중원, 오월 지역 소유시 가능'
|
||||||
|
);
|
||||||
|
expect(formatCrewTypeRequirement({ type: 'ReqCitiesWithCityLevel', level: 8, cities: ['완'] })).toBe(
|
||||||
|
'완 특성 소유시 가능'
|
||||||
|
);
|
||||||
|
expect(formatCrewTypeRequirement({ type: 'ReqHighLevelCities', level: 7, count: 4 })).toBe(
|
||||||
|
'대성 4개 이상 소유시 가능'
|
||||||
|
);
|
||||||
|
expect(formatCrewTypeRequirement({ type: 'ReqNationAux', key: 'can_대검병사용', op: '==', value: 1 })).toBe(
|
||||||
|
'대검병 연구 시 가능'
|
||||||
|
);
|
||||||
|
expect(formatCrewTypeRequirement({ type: 'ReqNationAux', key: 'did_특성초토화', op: '>=', value: 1 })).toBe(
|
||||||
|
'특성 초토화 시 가능'
|
||||||
|
);
|
||||||
|
expect(formatCrewTypeRequirement({ type: 'ReqMinRelYear', year: 3 })).toBe('3년 경과 후 사용 가능');
|
||||||
|
expect(formatCrewTypeRequirement({ type: 'ReqChief' })).toBe('군주 및 수뇌부만 가능');
|
||||||
|
expect(formatCrewTypeRequirement({ type: 'ReqNotChief' })).toBe('군주 및 수뇌부는 불가');
|
||||||
|
expect(formatCrewTypeRequirement({ type: 'Impossible' })).toBe('불가능');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -424,13 +424,21 @@ describe('in-game my information ownership', () => {
|
|||||||
general: {
|
general: {
|
||||||
officerLevelText: '간의대부',
|
officerLevelText: '간의대부',
|
||||||
crewTypeName: '보병',
|
crewTypeName: '보병',
|
||||||
|
crewTypeInfo: {
|
||||||
|
name: '보병',
|
||||||
|
info: ['표준적인 보병입니다.', '보병은 방어특화이며,', '상대가 회피하기 어렵습니다.'],
|
||||||
|
requirements: [],
|
||||||
|
stats: { attack: 100, defence: 150, speed: 7, avoid: 10, magicCoef: 0, cost: 9, rice: 9 },
|
||||||
|
},
|
||||||
progression: { experienceLevel: 4, dedicationLevel: 2, dedicationText: '29품관' },
|
progression: { experienceLevel: 4, dedicationLevel: 2, dedicationText: '29품관' },
|
||||||
itemNames: { horse: '노새(+3)' },
|
itemNames: { horse: '노새(+3)' },
|
||||||
|
itemInfo: { horse: '통솔 +3' },
|
||||||
},
|
},
|
||||||
city: { levelName: '특', regionName: '중원', nationName: '위' },
|
city: { levelName: '특', regionName: '중원', nationName: '위' },
|
||||||
nation: {
|
nation: {
|
||||||
levelName: '주자사',
|
levelName: '주자사',
|
||||||
typeName: '법가',
|
typeName: '법가',
|
||||||
|
typeInfo: '금수입↑ 치안↑ 인구↓ 민심↓',
|
||||||
capitalCityName: '업',
|
capitalCityName: '업',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -508,6 +516,12 @@ describe('in-game my information ownership', () => {
|
|||||||
specialDomestic: '상재',
|
specialDomestic: '상재',
|
||||||
specialWar: '신산',
|
specialWar: '신산',
|
||||||
},
|
},
|
||||||
|
traitInfo: {
|
||||||
|
personal: '사기 -5, 징·모병 비용 -20%',
|
||||||
|
specialDomestic: '[내정] 상업 투자 : 기본 보정 +10%, 성공률 +10%p, 비용 -20%',
|
||||||
|
specialWar:
|
||||||
|
'[계략] 화계·탈취·파괴·선동 : 성공률 +10%p<br>[전투] 계략 시도 확률 +20%p, 계략 성공 확률 +20%p',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
nation: {
|
nation: {
|
||||||
id: 0,
|
id: 0,
|
||||||
@@ -518,6 +532,7 @@ describe('in-game my information ownership', () => {
|
|||||||
rice: 0,
|
rice: 0,
|
||||||
tech: 0,
|
tech: 0,
|
||||||
typeCode: 'None',
|
typeCode: 'None',
|
||||||
|
typeInfo: '',
|
||||||
capitalCityId: null,
|
capitalCityId: null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ type FixtureState = {
|
|||||||
joinConfig?: Record<string, unknown>;
|
joinConfig?: Record<string, unknown>;
|
||||||
createGeneralInputs?: Array<Record<string, unknown>>;
|
createGeneralInputs?: Array<Record<string, unknown>>;
|
||||||
mainTraits?: { personal: string; specialDomestic: string; specialWar: string };
|
mainTraits?: { personal: string; specialDomestic: string; specialWar: string };
|
||||||
|
richMyInfo?: boolean;
|
||||||
hiddenSeedLogText?: string;
|
hiddenSeedLogText?: string;
|
||||||
recentRecords?: {
|
recentRecords?: {
|
||||||
global: Array<{ id: number; text: string; createdAt?: string }>;
|
global: Array<{ id: number; text: string; createdAt?: string }>;
|
||||||
@@ -103,7 +104,22 @@ const myGeneral = (state: FixtureState) => ({
|
|||||||
turnTime: '2026-01-01 00:10:00',
|
turnTime: '2026-01-01 00:10:00',
|
||||||
crewTypeId: 1,
|
crewTypeId: 1,
|
||||||
crewTypeName: '보병',
|
crewTypeName: '보병',
|
||||||
|
crewTypeInfo: state.richMyInfo
|
||||||
|
? {
|
||||||
|
name: '보병',
|
||||||
|
info: ['표준적인 보병입니다.', '보병은 방어특화이며,', '상대가 회피하기 어렵습니다.'],
|
||||||
|
requirements: ['기술력 1000 이상 필요'],
|
||||||
|
stats: { attack: 100, defence: 150, speed: 7, avoid: 10, magicCoef: 0, cost: 9, rice: 9 },
|
||||||
|
}
|
||||||
|
: null,
|
||||||
traits: state.mainTraits ?? { personal: '-', specialDomestic: '-', specialWar: '-' },
|
traits: state.mainTraits ?? { personal: '-', specialDomestic: '-', specialWar: '-' },
|
||||||
|
traitInfo: state.richMyInfo
|
||||||
|
? {
|
||||||
|
personal: '부상당할 확률이 감소합니다.',
|
||||||
|
specialDomestic: '상업 내정 효율이 증가합니다.',
|
||||||
|
specialWar: '계략 성공률이 증가합니다.<br>발동 순서는 레거시와 같습니다.',
|
||||||
|
}
|
||||||
|
: { personal: '', specialDomestic: '', specialWar: '' },
|
||||||
progression: {
|
progression: {
|
||||||
experienceLevel: 1,
|
experienceLevel: 1,
|
||||||
dedicationLevel: 2,
|
dedicationLevel: 2,
|
||||||
@@ -121,8 +137,20 @@ const myGeneral = (state: FixtureState) => ({
|
|||||||
killedCrew: 12_345,
|
killedCrew: 12_345,
|
||||||
lostCrew: 6_789,
|
lostCrew: 6_789,
|
||||||
},
|
},
|
||||||
items: { horse: 'che_명마', weapon: null, book: null, item: null },
|
items: state.richMyInfo
|
||||||
itemNames: { horse: '명마', weapon: null, book: null, item: null },
|
? { horse: 'che_명마', weapon: 'che_단도', book: 'che_효경전', item: 'che_납금박산로' }
|
||||||
|
: { horse: 'che_명마', weapon: null, book: null, item: null },
|
||||||
|
itemNames: state.richMyInfo
|
||||||
|
? { horse: '명마', weapon: '단도', book: '효경전', item: '납금박산로' }
|
||||||
|
: { horse: '명마', weapon: null, book: null, item: null },
|
||||||
|
itemInfo: state.richMyInfo
|
||||||
|
? {
|
||||||
|
horse: '통솔 +3',
|
||||||
|
weapon: '무력 +1',
|
||||||
|
book: '지력 +1',
|
||||||
|
item: '내정 실행 시 성공률이 증가합니다.<br>소모되지 않습니다.',
|
||||||
|
}
|
||||||
|
: { horse: null, weapon: null, book: null, item: null },
|
||||||
},
|
},
|
||||||
city: {
|
city: {
|
||||||
id: 1,
|
id: 1,
|
||||||
@@ -201,6 +229,7 @@ const myGeneral = (state: FixtureState) => ({
|
|||||||
capitalCityName: '업',
|
capitalCityName: '업',
|
||||||
typePros: '금수입↑ 치안↑',
|
typePros: '금수입↑ 치안↑',
|
||||||
typeCons: '인구↓ 민심↓',
|
typeCons: '인구↓ 민심↓',
|
||||||
|
typeInfo: state.richMyInfo ? '법과 질서를 중시하여 국가 운영을 안정시킵니다.' : '',
|
||||||
population: { cityCount: 1, current: 1_000, max: 2_000 },
|
population: { cityCount: 1, current: 1_000, max: 2_000 },
|
||||||
crew: { generalCount: 2, current: 500, max: 7_000 },
|
crew: { generalCount: 2, current: 500, max: 7_000 },
|
||||||
power: 1_234,
|
power: 1_234,
|
||||||
@@ -1349,6 +1378,106 @@ test('내 정보&설정 keeps desktop density and becomes a 390px horizontal-ide
|
|||||||
await persistParityArtifact(page, 'core-my-page-mobile', mobile);
|
await persistParityArtifact(page, 'core-my-page-mobile', mobile);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('내 정보 항목과 국가 성향은 HTML 리치 툴팁을 마우스와 키보드로 표시한다', async ({ page }) => {
|
||||||
|
const state: FixtureState = {
|
||||||
|
permission: 'head',
|
||||||
|
myset: 3,
|
||||||
|
richMyInfo: true,
|
||||||
|
mainTraits: { personal: '안전', specialDomestic: '상재', specialWar: '신산' },
|
||||||
|
settingMutations: [],
|
||||||
|
accessPages: [],
|
||||||
|
};
|
||||||
|
await install(page, state);
|
||||||
|
|
||||||
|
const visibleTooltip = page.locator('.tippy-box[data-theme~="sammo-rich"][data-state="visible"]');
|
||||||
|
const showWithMouse = async (testId: string, expectedTexts: readonly string[]) => {
|
||||||
|
const trigger = page.locator(`[data-rich-tooltip="${testId}"]`);
|
||||||
|
await expect(trigger).toHaveAttribute('tabindex', '0');
|
||||||
|
await trigger.hover();
|
||||||
|
await expect(visibleTooltip).toHaveCount(1);
|
||||||
|
await expect(visibleTooltip).toHaveAttribute('role', 'tooltip');
|
||||||
|
for (const expectedText of expectedTexts) {
|
||||||
|
await expect(visibleTooltip).toContainText(expectedText);
|
||||||
|
}
|
||||||
|
await expect(trigger).toHaveAttribute('aria-describedby', /tippy-/u);
|
||||||
|
await page.mouse.move(1, 1);
|
||||||
|
await expect(visibleTooltip).toHaveCount(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
await page.setViewportSize({ width: 1000, height: 900 });
|
||||||
|
await page.goto('my-page');
|
||||||
|
await expect(page.locator('[data-general-basic-card]')).toBeVisible();
|
||||||
|
|
||||||
|
const cardBefore = await page.locator('[data-general-basic-card]').boundingBox();
|
||||||
|
await showWithMouse('horse', ['명마', '통솔 +3']);
|
||||||
|
await showWithMouse('weapon', ['단도', '무력 +1']);
|
||||||
|
await showWithMouse('book', ['효경전', '지력 +1']);
|
||||||
|
await showWithMouse('item', ['납금박산로', '내정 실행 시 성공률이 증가합니다.', '소모되지 않습니다.']);
|
||||||
|
await showWithMouse('crew-type', [
|
||||||
|
'보병',
|
||||||
|
'표준적인 보병입니다.',
|
||||||
|
'전투 정보',
|
||||||
|
'공격 100 · 방어 150',
|
||||||
|
'병사 100명 기준 금 9 · 쌀 9',
|
||||||
|
'생성 조건',
|
||||||
|
'기술력 1000 이상 필요',
|
||||||
|
]);
|
||||||
|
await showWithMouse('personality', ['안전', '부상당할 확률이 감소합니다.']);
|
||||||
|
await showWithMouse('special-domestic', ['내정특기 · 상재', '상업 내정 효율이 증가합니다.']);
|
||||||
|
await showWithMouse('special-war', [
|
||||||
|
'전투특기 · 신산',
|
||||||
|
'계략 성공률이 증가합니다.',
|
||||||
|
'발동 순서는 레거시와 같습니다.',
|
||||||
|
]);
|
||||||
|
expect(await page.locator('[data-general-basic-card]').boundingBox()).toEqual(cardBefore);
|
||||||
|
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe(1000);
|
||||||
|
|
||||||
|
const warTrigger = page.locator('[data-rich-tooltip="special-war"]');
|
||||||
|
await warTrigger.focus();
|
||||||
|
await expect(warTrigger).toBeFocused();
|
||||||
|
await expect(visibleTooltip).toHaveCount(1);
|
||||||
|
await expect(visibleTooltip.locator('.rich-tooltip-content__line')).toHaveCount(2);
|
||||||
|
expect(await visibleTooltip.locator('.tippy-content').innerHTML()).not.toContain('<br');
|
||||||
|
await persistParityArtifact(page, 'core-my-info-rich-tooltip-desktop', {
|
||||||
|
trigger: await warTrigger.boundingBox(),
|
||||||
|
tooltip: await visibleTooltip.boundingBox(),
|
||||||
|
scrollWidth: await page.evaluate(() => document.documentElement.scrollWidth),
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.setViewportSize({ width: 390, height: 844 });
|
||||||
|
await page.reload();
|
||||||
|
const crewTrigger = page.locator('[data-rich-tooltip="crew-type"]');
|
||||||
|
await crewTrigger.focus();
|
||||||
|
await expect(visibleTooltip).toHaveCount(1);
|
||||||
|
const mobileTooltip = await visibleTooltip.boundingBox();
|
||||||
|
expect(mobileTooltip).not.toBeNull();
|
||||||
|
expect(mobileTooltip!.x).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(mobileTooltip!.x + mobileTooltip!.width).toBeLessThanOrEqual(390);
|
||||||
|
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe(390);
|
||||||
|
await persistParityArtifact(page, 'core-my-info-rich-tooltip-mobile', {
|
||||||
|
trigger: await crewTrigger.boundingBox(),
|
||||||
|
tooltip: mobileTooltip,
|
||||||
|
scrollWidth: await page.evaluate(() => document.documentElement.scrollWidth),
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.setViewportSize({ width: 1000, height: 900 });
|
||||||
|
await page.goto('');
|
||||||
|
const nationType = page.locator('[data-rich-tooltip="nation-type"]');
|
||||||
|
await nationType.hover();
|
||||||
|
await expect(visibleTooltip).toHaveCount(1);
|
||||||
|
await expect(visibleTooltip).toContainText('국가 성향 · 법가');
|
||||||
|
await expect(visibleTooltip).toContainText('법과 질서를 중시하여 국가 운영을 안정시킵니다.');
|
||||||
|
await expect(visibleTooltip).toContainText('장점 금수입↑ 치안↑');
|
||||||
|
await expect(visibleTooltip).toContainText('단점 인구↓ 민심↓');
|
||||||
|
await nationType.focus();
|
||||||
|
await expect(nationType).toBeFocused();
|
||||||
|
await expect(visibleTooltip).toHaveCount(1);
|
||||||
|
await persistParityArtifact(page, 'core-nation-type-rich-tooltip-desktop', {
|
||||||
|
trigger: await nationType.boundingBox(),
|
||||||
|
tooltip: await visibleTooltip.boundingBox(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test('내 정보&설정의 지난 플레이는 기본 탐색과 분리되어 오른쪽에 정렬된다', async ({ page }) => {
|
test('내 정보&설정의 지난 플레이는 기본 탐색과 분리되어 오른쪽에 정렬된다', async ({ page }) => {
|
||||||
const state: FixtureState = { permission: 'head', myset: 3, settingMutations: [], accessPages: [] };
|
const state: FixtureState = { permission: 'head', myset: 3, settingMutations: [], accessPages: [] };
|
||||||
await install(page, state);
|
await install(page, state);
|
||||||
@@ -1528,7 +1657,9 @@ test('실제 모바일 터치로 메인 패널 순서를 재정렬한다', async
|
|||||||
await dialog.screenshot({ path: testInfo.outputPath('mobile-main-panel-touch-dialog.png') });
|
await dialog.screenshot({ path: testInfo.outputPath('mobile-main-panel-touch-dialog.png') });
|
||||||
await dialog.getByRole('button', { name: '적용', exact: true }).click();
|
await dialog.getByRole('button', { name: '적용', exact: true }).click();
|
||||||
await expect
|
await expect
|
||||||
.poll(() => mobilePage.evaluate(() => JSON.parse(localStorage.getItem('sam.mobileMainPanelOrder.v1') ?? '[]')))
|
.poll(() =>
|
||||||
|
mobilePage.evaluate(() => JSON.parse(localStorage.getItem('sam.mobileMainPanelOrder.v1') ?? '[]'))
|
||||||
|
)
|
||||||
.toEqual([
|
.toEqual([
|
||||||
'nation-menu',
|
'nation-menu',
|
||||||
'commands',
|
'commands',
|
||||||
|
|||||||
@@ -90,6 +90,20 @@ const matches = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
const response = (data: unknown) => ({ result: { data } });
|
const response = (data: unknown) => ({ result: { data } });
|
||||||
|
const asRecord = (value: unknown): Record<string, unknown> | null =>
|
||||||
|
typeof value === 'object' && value !== null && !Array.isArray(value) ? (value as Record<string, unknown>) : null;
|
||||||
|
const findBetInput = (value: unknown): { targetId: number; amount: number } | null => {
|
||||||
|
const record = asRecord(value);
|
||||||
|
if (!record) return null;
|
||||||
|
if (typeof record.targetId === 'number' && typeof record.amount === 'number') {
|
||||||
|
return { targetId: record.targetId, amount: record.amount };
|
||||||
|
}
|
||||||
|
for (const child of Object.values(record)) {
|
||||||
|
const result = findBetInput(child);
|
||||||
|
if (result) return result;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
const operationNames = (route: Route): string[] => {
|
const operationNames = (route: Route): string[] => {
|
||||||
const url = new URL(route.request().url());
|
const url = new URL(route.request().url());
|
||||||
return decodeURIComponent(url.pathname.slice(url.pathname.lastIndexOf('/trpc/') + 6)).split(',');
|
return decodeURIComponent(url.pathname.slice(url.pathname.lastIndexOf('/trpc/') + 6)).split(',');
|
||||||
@@ -126,8 +140,17 @@ const persistScreenshot = async (page: Page, name: string, fallbackPath: string)
|
|||||||
await page.screenshot({ path: resolve(responsiveArtifactDir, `${name}.webp`), fullPage: true });
|
await page.screenshot({ path: resolve(responsiveArtifactDir, `${name}.webp`), fullPage: true });
|
||||||
};
|
};
|
||||||
|
|
||||||
const installFixture = async (page: Page, options: { applicationOpen?: boolean; tournamentType?: number } = {}) => {
|
const installFixture = async (
|
||||||
|
page: Page,
|
||||||
|
options: {
|
||||||
|
applicationOpen?: boolean;
|
||||||
|
tournamentType?: number;
|
||||||
|
tournamentStage?: number;
|
||||||
|
joinedGroupId?: number;
|
||||||
|
} = {}
|
||||||
|
) => {
|
||||||
let joined = false;
|
let joined = false;
|
||||||
|
const placedBets: Array<{ targetId: number; amount: number }> = [];
|
||||||
await page.addInitScript((profile) => {
|
await page.addInitScript((profile) => {
|
||||||
window.localStorage.setItem('sammo-game-token', 'ga_tournament_bracket_playwright');
|
window.localStorage.setItem('sammo-game-token', 'ga_tournament_bracket_playwright');
|
||||||
window.localStorage.setItem('sammo-game-profile', profile);
|
window.localStorage.setItem('sammo-game-profile', profile);
|
||||||
@@ -148,9 +171,11 @@ const installFixture = async (page: Page, options: { applicationOpen?: boolean;
|
|||||||
if (operation === 'general.me') return response({ general: { id: 1, name: names[0] } });
|
if (operation === 'general.me') return response({ general: { id: 1, name: names[0] } });
|
||||||
if (operation === 'tournament.getAdminStatus') return response({ ok: false });
|
if (operation === 'tournament.getAdminStatus') return response({ ok: false });
|
||||||
if (operation === 'tournament.getSnapshot') {
|
if (operation === 'tournament.getSnapshot') {
|
||||||
|
const tournamentStage = options.tournamentStage ?? (options.applicationOpen ? 1 : 0);
|
||||||
|
const joinedGroupId = options.joinedGroupId ?? 0;
|
||||||
return response({
|
return response({
|
||||||
state: {
|
state: {
|
||||||
stage: options.applicationOpen ? 1 : 0,
|
stage: tournamentStage,
|
||||||
phase: 0,
|
phase: 0,
|
||||||
type: options.tournamentType ?? 0,
|
type: options.tournamentType ?? 0,
|
||||||
auto: false,
|
auto: false,
|
||||||
@@ -158,7 +183,7 @@ const installFixture = async (page: Page, options: { applicationOpen?: boolean;
|
|||||||
openMonth: 1,
|
openMonth: 1,
|
||||||
termSeconds: 60,
|
termSeconds: 60,
|
||||||
nextAt: '2026-08-02T00:00:00.000Z',
|
nextAt: '2026-08-02T00:00:00.000Z',
|
||||||
winnerId: 1,
|
winnerId: tournamentStage === 0 ? 1 : undefined,
|
||||||
},
|
},
|
||||||
participants:
|
participants:
|
||||||
options.applicationOpen && !joined
|
options.applicationOpen && !joined
|
||||||
@@ -167,8 +192,10 @@ const installFixture = async (page: Page, options: { applicationOpen?: boolean;
|
|||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
...participants[0],
|
...participants[0],
|
||||||
groupId: 0,
|
groupId: joinedGroupId,
|
||||||
groupNo: 0,
|
groupNo: 0,
|
||||||
|
preliminaryGroupId: joinedGroupId,
|
||||||
|
preliminaryGroupNo: 0,
|
||||||
win: 0,
|
win: 0,
|
||||||
draw: 0,
|
draw: 0,
|
||||||
lose: 0,
|
lose: 0,
|
||||||
@@ -196,6 +223,12 @@ const installFixture = async (page: Page, options: { applicationOpen?: boolean;
|
|||||||
myAmount: 160,
|
myAmount: 160,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (operation === 'tournament.placeBet') {
|
||||||
|
const input = findBetInput(route.request().postDataJSON());
|
||||||
|
if (!input) throw new Error('베팅 요청에서 targetId와 amount를 찾을 수 없습니다.');
|
||||||
|
placedBets.push(input);
|
||||||
|
return response({ ok: true });
|
||||||
|
}
|
||||||
if (operation === 'tournament.getRankings') {
|
if (operation === 'tournament.getRankings') {
|
||||||
return response(
|
return response(
|
||||||
[
|
[
|
||||||
@@ -229,6 +262,7 @@ const installFixture = async (page: Page, options: { applicationOpen?: 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 { placedBets };
|
||||||
};
|
};
|
||||||
|
|
||||||
const openTournament = async (page: Page) => {
|
const openTournament = async (page: Page) => {
|
||||||
@@ -322,20 +356,36 @@ test('desktop bracket connects every real general slot to the next round', async
|
|||||||
await persistScreenshot(page, 'tournament-desktop', testInfo.outputPath('tournament-bracket-desktop.webp'));
|
await persistScreenshot(page, 'tournament-desktop', testInfo.outputPath('tournament-bracket-desktop.webp'));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('join refresh shows the assigned preliminary group immediately with accessible controls', async ({ page }) => {
|
test('join refresh shows the assigned preliminary group immediately with accessible controls', async ({
|
||||||
|
page,
|
||||||
|
}, testInfo) => {
|
||||||
await page.setViewportSize({ width: 390, height: 844 });
|
await page.setViewportSize({ width: 390, height: 844 });
|
||||||
await installFixture(page, { applicationOpen: true });
|
await installFixture(page, { applicationOpen: true, joinedGroupId: 5 });
|
||||||
await page.goto('tournament');
|
await page.goto('tournament');
|
||||||
|
|
||||||
const refresh = page.getByRole('button', { name: '갱신' });
|
const refresh = page.getByRole('button', { name: '갱신' });
|
||||||
const join = page.getByRole('button', { name: '참가' });
|
const join = page.getByRole('button', { name: '참가' });
|
||||||
const close = page.getByRole('button', { name: '창 닫기' }).first();
|
const close = page.getByRole('button', { name: '창 닫기' }).first();
|
||||||
await expect(join).toBeEnabled();
|
await expect(join).toBeEnabled();
|
||||||
|
await expect(page.getByText('조별 예선 순위')).toBeVisible();
|
||||||
|
await expect(page.getByText('조별 본선 순위')).toHaveCount(0);
|
||||||
|
await expect(page.getByLabel('토너먼트 대진표')).toHaveCount(0);
|
||||||
await join.click();
|
await join.click();
|
||||||
|
|
||||||
await expect(page.getByRole('status')).toHaveText('참가 신청이 반영되었습니다.');
|
await expect(page.getByRole('status')).toHaveText('참가 신청이 반영되었습니다. 六조에 배정되었습니다.');
|
||||||
await expect(join).toBeDisabled();
|
await expect(join).toBeDisabled();
|
||||||
await expect(page.locator('.preliminary-grid .general-identity', { hasText: names[0] })).toBeVisible();
|
const preliminaryTabs = page.getByRole('tablist', { name: '예선 조 선택' });
|
||||||
|
await expect(preliminaryTabs.getByRole('tab').nth(5)).toHaveAttribute('aria-selected', 'true');
|
||||||
|
const assignedGroup = page.locator('[data-preliminary-group="5"]');
|
||||||
|
await expect(assignedGroup.locator('.general-identity', { hasText: names[0] })).toBeVisible();
|
||||||
|
const assignedGroupBounds = await assignedGroup.boundingBox();
|
||||||
|
expect(assignedGroupBounds?.y).toBeLessThan(844);
|
||||||
|
expect((assignedGroupBounds?.y ?? 0) + (assignedGroupBounds?.height ?? 0)).toBeGreaterThan(0);
|
||||||
|
await persistScreenshot(
|
||||||
|
page,
|
||||||
|
'tournament-joined-group-mobile',
|
||||||
|
testInfo.outputPath('tournament-joined-group.webp')
|
||||||
|
);
|
||||||
|
|
||||||
for (const control of [refresh, join, close]) {
|
for (const control of [refresh, join, close]) {
|
||||||
const box = await control.boundingBox();
|
const box = await control.boundingBox();
|
||||||
@@ -349,6 +399,41 @@ test('join refresh shows the assigned preliminary group immediately with accessi
|
|||||||
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(390);
|
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(390);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('desktop join scrolls the assigned preliminary group into view without future sections', async ({
|
||||||
|
page,
|
||||||
|
}, testInfo) => {
|
||||||
|
await page.setViewportSize({ width: 1365, height: 900 });
|
||||||
|
await installFixture(page, { applicationOpen: true, joinedGroupId: 7 });
|
||||||
|
await page.goto('tournament');
|
||||||
|
|
||||||
|
await expect(page.getByText('조별 본선 순위')).toHaveCount(0);
|
||||||
|
await expect(page.getByLabel('토너먼트 대진표')).toHaveCount(0);
|
||||||
|
await page.getByRole('button', { name: '참가' }).click();
|
||||||
|
|
||||||
|
await expect(page.getByRole('status')).toHaveText('참가 신청이 반영되었습니다. 八조에 배정되었습니다.');
|
||||||
|
const assignedGroup = page.locator('[data-preliminary-group="7"]');
|
||||||
|
await expect(assignedGroup.locator('.general-identity', { hasText: names[0] })).toBeVisible();
|
||||||
|
const bounds = await assignedGroup.boundingBox();
|
||||||
|
expect(bounds?.y).toBeLessThan(900);
|
||||||
|
expect((bounds?.y ?? 0) + (bounds?.height ?? 0)).toBeGreaterThan(0);
|
||||||
|
await persistScreenshot(
|
||||||
|
page,
|
||||||
|
'tournament-joined-group-desktop',
|
||||||
|
testInfo.outputPath('tournament-joined-group.webp')
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('final group section appears before the later knockout section', async ({ page }, testInfo) => {
|
||||||
|
await page.setViewportSize({ width: 390, height: 844 });
|
||||||
|
await installFixture(page, { tournamentStage: 3 });
|
||||||
|
await page.goto('tournament');
|
||||||
|
|
||||||
|
await expect(page.getByText('조별 예선 순위')).toBeVisible();
|
||||||
|
await expect(page.getByText('조별 본선 순위')).toBeVisible();
|
||||||
|
await expect(page.getByLabel('토너먼트 대진표')).toHaveCount(0);
|
||||||
|
await persistScreenshot(page, 'tournament-final-stage-mobile', testInfo.outputPath('tournament-final-stage.webp'));
|
||||||
|
});
|
||||||
|
|
||||||
test('mobile bracket exposes every round through tabs with standard horizontal identities', async ({
|
test('mobile bracket exposes every round through tabs with standard horizontal identities', async ({
|
||||||
page,
|
page,
|
||||||
}, testInfo) => {
|
}, testInfo) => {
|
||||||
@@ -480,12 +565,49 @@ test('tournament and betting pages expose same-row navigation tabs beside close'
|
|||||||
|
|
||||||
test('mobile betting rankings use tabs and keep dedicated icons beside general names', async ({ page }, testInfo) => {
|
test('mobile betting rankings use tabs and keep dedicated icons beside general names', async ({ page }, testInfo) => {
|
||||||
await page.setViewportSize({ width: 390, height: 844 });
|
await page.setViewportSize({ width: 390, height: 844 });
|
||||||
await installFixture(page);
|
const { placedBets } = await installFixture(page, { tournamentStage: 6 });
|
||||||
await page.goto('betting');
|
await page.goto('betting');
|
||||||
|
|
||||||
await expect(page.locator('.candidate-card')).toHaveCount(16);
|
await expect(page.locator('.candidate-table')).toHaveCount(0);
|
||||||
|
const betButtons = page.locator('.mobile-bracket .bracket-bet-button:visible');
|
||||||
|
await expect(betButtons).toHaveCount(16);
|
||||||
await expect(page.locator('.betting-bracket .bracket-core-stat').first()).toHaveText('종합 240');
|
await expect(page.locator('.betting-bracket .bracket-core-stat').first()).toHaveText('종합 240');
|
||||||
await expect(page.locator('.betting-bracket .bracket-my-bet').first()).toHaveText('내 투자 금120');
|
await expect(page.locator('.betting-bracket .bracket-my-bet').first()).toHaveText('내 투자 금120');
|
||||||
|
|
||||||
|
const firstCard = page.locator('.mobile-bracket-name[data-general-id="1"]');
|
||||||
|
const firstBetButton = page.getByRole('button', { name: '관우에게 베팅하기' });
|
||||||
|
const corner = await firstCard.evaluate((card) => {
|
||||||
|
const own = card.getBoundingClientRect();
|
||||||
|
const button = card.querySelector<HTMLElement>('.bracket-bet-button')!.getBoundingClientRect();
|
||||||
|
return {
|
||||||
|
topOffset: button.top - own.top,
|
||||||
|
rightOffset: own.right - button.right,
|
||||||
|
contained: button.top >= own.top && button.right <= own.right && button.bottom <= own.bottom,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
expect(corner.topOffset).toBeGreaterThanOrEqual(2);
|
||||||
|
expect(corner.topOffset).toBeLessThanOrEqual(4);
|
||||||
|
expect(corner.rightOffset).toBeGreaterThanOrEqual(2);
|
||||||
|
expect(corner.rightOffset).toBeLessThanOrEqual(4);
|
||||||
|
expect(corner.contained).toBe(true);
|
||||||
|
|
||||||
|
await firstBetButton.hover();
|
||||||
|
await expect(firstBetButton).toHaveCSS('filter', 'brightness(1.25)');
|
||||||
|
await firstBetButton.focus();
|
||||||
|
await expect(firstBetButton).toBeFocused();
|
||||||
|
await firstBetButton.click();
|
||||||
|
const dialog = page.getByRole('dialog', { name: '베팅하기' });
|
||||||
|
await expect(dialog).toBeVisible();
|
||||||
|
await expect(dialog.getByText('배당 28.00')).toBeVisible();
|
||||||
|
await expect(dialog.getByText('예상 환수금 280')).toBeVisible();
|
||||||
|
await dialog.getByLabel('베팅 금액').selectOption('50');
|
||||||
|
await expect(dialog.getByText('예상 환수금 1,400')).toBeVisible();
|
||||||
|
await persistScreenshot(page, 'tournament-betting-dialog-mobile', testInfo.outputPath('betting-dialog-mobile.webp'));
|
||||||
|
await dialog.getByRole('button', { name: '베팅 등록' }).click();
|
||||||
|
await expect(dialog).not.toBeVisible();
|
||||||
|
await expect(page.getByRole('status')).toHaveText('베팅이 등록되었습니다.');
|
||||||
|
expect(placedBets).toEqual([{ targetId: 1, amount: 50 }]);
|
||||||
|
|
||||||
await expect(page.getByRole('tablist', { name: '토너먼트 랭킹 종목 선택' })).toBeVisible();
|
await expect(page.getByRole('tablist', { name: '토너먼트 랭킹 종목 선택' })).toBeVisible();
|
||||||
await expect(page.locator('.ranking-table:visible')).toHaveCount(1);
|
await expect(page.locator('.ranking-table:visible')).toHaveCount(1);
|
||||||
await page.getByRole('tab', { name: '통솔전' }).click();
|
await page.getByRole('tab', { name: '통솔전' }).click();
|
||||||
@@ -509,7 +631,7 @@ test('mobile betting rankings use tabs and keep dedicated icons beside general n
|
|||||||
|
|
||||||
test('betting bracket shows intelligence for debate tournament candidates', async ({ page }) => {
|
test('betting bracket shows intelligence for debate tournament candidates', async ({ page }) => {
|
||||||
await page.setViewportSize({ width: 390, height: 844 });
|
await page.setViewportSize({ width: 390, height: 844 });
|
||||||
await installFixture(page, { tournamentType: 3 });
|
await installFixture(page, { tournamentType: 3, tournamentStage: 6 });
|
||||||
await page.goto('betting');
|
await page.goto('betting');
|
||||||
|
|
||||||
await expect(page.locator('.betting-bracket .bracket-core-stat').first()).toHaveText('지력 80');
|
await expect(page.locator('.betting-bracket .bracket-core-stat').first()).toHaveText('지력 80');
|
||||||
@@ -522,17 +644,30 @@ test('desktop betting presents icon-and-name cards and all four rankings without
|
|||||||
page,
|
page,
|
||||||
}, testInfo) => {
|
}, testInfo) => {
|
||||||
await page.setViewportSize({ width: 1365, height: 900 });
|
await page.setViewportSize({ width: 1365, height: 900 });
|
||||||
await installFixture(page);
|
await installFixture(page, { tournamentStage: 6 });
|
||||||
await page.goto('betting');
|
await page.goto('betting');
|
||||||
|
|
||||||
await expect(page.locator('.candidate-card')).toHaveCount(16);
|
await expect(page.locator('.candidate-table')).toHaveCount(0);
|
||||||
|
await expect(page.locator('.desktop-bracket .bracket-bet-button:visible')).toHaveCount(16);
|
||||||
await expect(page.locator('.ranking-table:visible')).toHaveCount(4);
|
await expect(page.locator('.ranking-table:visible')).toHaveCount(4);
|
||||||
await expect(page.locator('.general-identity-icon').first()).toHaveCSS('width', '64px');
|
await expect(page.locator('.general-identity-icon').first()).toHaveCSS('width', '64px');
|
||||||
await expect(page.locator('.general-identity-icon').first()).toHaveCSS('height', '64px');
|
await expect(page.locator('.general-identity-icon').first()).toHaveCSS('height', '64px');
|
||||||
const columns = await page
|
const firstCardCorner = await page
|
||||||
.locator('.candidate-grid')
|
.locator('.desktop-bracket-name.betting-target[data-general-id="1"]')
|
||||||
.evaluate((element) => getComputedStyle(element).gridTemplateColumns.split(' ').length);
|
.evaluate((card) => {
|
||||||
expect(columns).toBe(4);
|
const own = card.getBoundingClientRect();
|
||||||
|
const button = card.querySelector<HTMLElement>('.bracket-bet-button')!.getBoundingClientRect();
|
||||||
|
return {
|
||||||
|
topOffset: button.top - own.top,
|
||||||
|
rightOffset: own.right - button.right,
|
||||||
|
contained: button.top >= own.top && button.right <= own.right && button.bottom <= own.bottom,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
expect(firstCardCorner.topOffset).toBeGreaterThanOrEqual(2);
|
||||||
|
expect(firstCardCorner.topOffset).toBeLessThanOrEqual(4);
|
||||||
|
expect(firstCardCorner.rightOffset).toBeGreaterThanOrEqual(2);
|
||||||
|
expect(firstCardCorner.rightOffset).toBeLessThanOrEqual(4);
|
||||||
|
expect(firstCardCorner.contained).toBe(true);
|
||||||
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(1365);
|
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(1365);
|
||||||
await persistScreenshot(page, 'tournament-ranking-desktop', testInfo.outputPath('tournament-ranking-desktop.webp'));
|
await persistScreenshot(page, 'tournament-ranking-desktop', testInfo.outputPath('tournament-ranking-desktop.webp'));
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -48,6 +48,7 @@
|
|||||||
"es-toolkit": "^1.43.0",
|
"es-toolkit": "^1.43.0",
|
||||||
"mitt": "^3.0.1",
|
"mitt": "^3.0.1",
|
||||||
"pinia": "^3.0.4",
|
"pinia": "^3.0.4",
|
||||||
|
"tippy.js": "6.3.7",
|
||||||
"vue": "^3.5.26",
|
"vue": "^3.5.26",
|
||||||
"vue-draggable-plus": "0.6.1",
|
"vue-draggable-plus": "0.6.1",
|
||||||
"vue-router": "^4.6.4",
|
"vue-router": "^4.6.4",
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { computed } from 'vue';
|
|||||||
|
|
||||||
import SkeletonLines from '../ui/SkeletonLines.vue';
|
import SkeletonLines from '../ui/SkeletonLines.vue';
|
||||||
import LegacyProgressBar from '../ui/LegacyProgressBar.vue';
|
import LegacyProgressBar from '../ui/LegacyProgressBar.vue';
|
||||||
|
import RichTooltip from '../ui/RichTooltip.vue';
|
||||||
import { formatLocalTimeSeconds } from '../../utils/legacyDateTime';
|
import { formatLocalTimeSeconds } from '../../utils/legacyDateTime';
|
||||||
import { legacyExperiencePercent, ratioPercent } from '../../utils/legacyProgress';
|
import { legacyExperiencePercent, ratioPercent } from '../../utils/legacyProgress';
|
||||||
import { DEFAULT_GENERAL_ICON_URL, resolveGeneralIconBackgroundImage } from '../../utils/generalIcon';
|
import { DEFAULT_GENERAL_ICON_URL, resolveGeneralIconBackgroundImage } from '../../utils/generalIcon';
|
||||||
@@ -30,6 +31,28 @@ interface ItemDisplayNames {
|
|||||||
item?: string | null;
|
item?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ItemDisplayInfo {
|
||||||
|
horse?: string | null;
|
||||||
|
weapon?: string | null;
|
||||||
|
book?: string | null;
|
||||||
|
item?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CrewTypeDisplayInfo {
|
||||||
|
name: string;
|
||||||
|
info: string[];
|
||||||
|
requirements: string[];
|
||||||
|
stats: {
|
||||||
|
attack: number;
|
||||||
|
defence: number;
|
||||||
|
speed: number;
|
||||||
|
avoid: number;
|
||||||
|
magicCoef: number;
|
||||||
|
cost: number;
|
||||||
|
rice: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
interface GeneralTroopDisplay {
|
interface GeneralTroopDisplay {
|
||||||
name: string;
|
name: string;
|
||||||
status: 'inactive' | 'present' | 'away';
|
status: 'inactive' | 'present' | 'away';
|
||||||
@@ -73,9 +96,12 @@ interface GeneralInfo {
|
|||||||
refreshScore?: GeneralRefreshScore;
|
refreshScore?: GeneralRefreshScore;
|
||||||
crewTypeId?: number;
|
crewTypeId?: number;
|
||||||
crewTypeName?: string;
|
crewTypeName?: string;
|
||||||
|
crewTypeInfo?: CrewTypeDisplayInfo | null;
|
||||||
traits?: { personal: string; specialWar: string; specialDomestic: string };
|
traits?: { personal: string; specialWar: string; specialDomestic: string };
|
||||||
|
traitInfo?: { personal: string; specialWar: string; specialDomestic: string };
|
||||||
progression?: GeneralProgression;
|
progression?: GeneralProgression;
|
||||||
itemNames?: ItemDisplayNames;
|
itemNames?: ItemDisplayNames;
|
||||||
|
itemInfo?: ItemDisplayInfo;
|
||||||
equipmentNames?: ItemDisplayNames;
|
equipmentNames?: ItemDisplayNames;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -243,9 +269,36 @@ const specialText = computed(() => {
|
|||||||
</strong>
|
</strong>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<span class="cell-label">명마</span><strong>{{ itemNames.horse ?? '-' }}</strong>
|
<span class="cell-label">명마</span>
|
||||||
<span class="cell-label">무기</span><strong>{{ itemNames.weapon ?? '-' }}</strong>
|
<strong>
|
||||||
<span class="cell-label">서적</span><strong>{{ itemNames.book ?? '-' }}</strong>
|
<RichTooltip
|
||||||
|
:title="itemNames.horse ?? ''"
|
||||||
|
:description="props.general.itemInfo?.horse"
|
||||||
|
test-id="horse"
|
||||||
|
>
|
||||||
|
{{ itemNames.horse ?? '-' }}
|
||||||
|
</RichTooltip>
|
||||||
|
</strong>
|
||||||
|
<span class="cell-label">무기</span>
|
||||||
|
<strong>
|
||||||
|
<RichTooltip
|
||||||
|
:title="itemNames.weapon ?? ''"
|
||||||
|
:description="props.general.itemInfo?.weapon"
|
||||||
|
test-id="weapon"
|
||||||
|
>
|
||||||
|
{{ itemNames.weapon ?? '-' }}
|
||||||
|
</RichTooltip>
|
||||||
|
</strong>
|
||||||
|
<span class="cell-label">서적</span>
|
||||||
|
<strong>
|
||||||
|
<RichTooltip
|
||||||
|
:title="itemNames.book ?? ''"
|
||||||
|
:description="props.general.itemInfo?.book"
|
||||||
|
test-id="book"
|
||||||
|
>
|
||||||
|
{{ itemNames.book ?? '-' }}
|
||||||
|
</RichTooltip>
|
||||||
|
</strong>
|
||||||
|
|
||||||
<span
|
<span
|
||||||
class="general-image general-crew-type-icon"
|
class="general-image general-crew-type-icon"
|
||||||
@@ -255,15 +308,91 @@ const specialText = computed(() => {
|
|||||||
/>
|
/>
|
||||||
<span class="cell-label">자금</span><strong>{{ props.general.gold.toLocaleString('ko-KR') }}</strong>
|
<span class="cell-label">자금</span><strong>{{ props.general.gold.toLocaleString('ko-KR') }}</strong>
|
||||||
<span class="cell-label">군량</span><strong>{{ props.general.rice.toLocaleString('ko-KR') }}</strong>
|
<span class="cell-label">군량</span><strong>{{ props.general.rice.toLocaleString('ko-KR') }}</strong>
|
||||||
<span class="cell-label">도구</span><strong>{{ itemNames.item ?? '-' }}</strong>
|
<span class="cell-label">도구</span>
|
||||||
|
<strong>
|
||||||
|
<RichTooltip
|
||||||
|
:title="itemNames.item ?? ''"
|
||||||
|
:description="props.general.itemInfo?.item"
|
||||||
|
test-id="item"
|
||||||
|
>
|
||||||
|
{{ itemNames.item ?? '-' }}
|
||||||
|
</RichTooltip>
|
||||||
|
</strong>
|
||||||
|
|
||||||
<span class="cell-label">병종</span><strong>{{ props.general.crewTypeName ?? '-' }}</strong>
|
<span class="cell-label">병종</span>
|
||||||
|
<strong>
|
||||||
|
<RichTooltip
|
||||||
|
:title="props.general.crewTypeName ?? ''"
|
||||||
|
:description="props.general.crewTypeInfo?.info"
|
||||||
|
test-id="crew-type"
|
||||||
|
>
|
||||||
|
{{ props.general.crewTypeName ?? '-' }}
|
||||||
|
<template v-if="props.general.crewTypeInfo" #content>
|
||||||
|
<span class="rich-tooltip-content__title">{{ props.general.crewTypeInfo.name }}</span>
|
||||||
|
<span
|
||||||
|
v-for="(line, index) in props.general.crewTypeInfo.info"
|
||||||
|
:key="`crew-info:${index}`"
|
||||||
|
class="rich-tooltip-content__line"
|
||||||
|
>
|
||||||
|
{{ line }}
|
||||||
|
</span>
|
||||||
|
<span class="rich-tooltip-content__section">전투 정보</span>
|
||||||
|
<span class="rich-tooltip-content__meta">
|
||||||
|
공격 {{ props.general.crewTypeInfo.stats.attack }} · 방어
|
||||||
|
{{ props.general.crewTypeInfo.stats.defence }} · 속도
|
||||||
|
{{ props.general.crewTypeInfo.stats.speed }} · 회피
|
||||||
|
{{ props.general.crewTypeInfo.stats.avoid }}% · 계략
|
||||||
|
{{ props.general.crewTypeInfo.stats.magicCoef }}%
|
||||||
|
</span>
|
||||||
|
<span class="rich-tooltip-content__meta">
|
||||||
|
병사 100명 기준 금 {{ props.general.crewTypeInfo.stats.cost }} · 쌀
|
||||||
|
{{ props.general.crewTypeInfo.stats.rice }}
|
||||||
|
</span>
|
||||||
|
<template v-if="props.general.crewTypeInfo.requirements.length">
|
||||||
|
<span class="rich-tooltip-content__section">생성 조건</span>
|
||||||
|
<span
|
||||||
|
v-for="(requirement, index) in props.general.crewTypeInfo.requirements"
|
||||||
|
:key="`crew-requirement:${index}`"
|
||||||
|
class="rich-tooltip-content__line"
|
||||||
|
>
|
||||||
|
{{ requirement }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</RichTooltip>
|
||||||
|
</strong>
|
||||||
<span class="cell-label">병사</span><strong>{{ props.general.crew.toLocaleString('ko-KR') }}</strong>
|
<span class="cell-label">병사</span><strong>{{ props.general.crew.toLocaleString('ko-KR') }}</strong>
|
||||||
<span class="cell-label">성격</span><strong>{{ props.general.traits?.personal ?? '-' }}</strong>
|
<span class="cell-label">성격</span>
|
||||||
|
<strong>
|
||||||
|
<RichTooltip
|
||||||
|
:title="props.general.traits?.personal ?? ''"
|
||||||
|
:description="props.general.traitInfo?.personal"
|
||||||
|
test-id="personality"
|
||||||
|
>
|
||||||
|
{{ props.general.traits?.personal ?? '-' }}
|
||||||
|
</RichTooltip>
|
||||||
|
</strong>
|
||||||
|
|
||||||
<span class="cell-label">훈련</span><strong>{{ props.general.train }}</strong>
|
<span class="cell-label">훈련</span><strong>{{ props.general.train }}</strong>
|
||||||
<span class="cell-label">사기</span><strong>{{ props.general.atmos }}</strong>
|
<span class="cell-label">사기</span><strong>{{ props.general.atmos }}</strong>
|
||||||
<span class="cell-label">특기</span><strong :title="specialText">{{ specialText }}</strong>
|
<span class="cell-label">특기</span>
|
||||||
|
<strong class="special-value" :aria-label="specialText">
|
||||||
|
<RichTooltip
|
||||||
|
:title="`내정특기 · ${props.general.traits?.specialDomestic ?? '-'}`"
|
||||||
|
:description="props.general.traitInfo?.specialDomestic"
|
||||||
|
test-id="special-domestic"
|
||||||
|
>
|
||||||
|
{{ props.general.traits?.specialDomestic ?? '-' }}
|
||||||
|
</RichTooltip>
|
||||||
|
/
|
||||||
|
<RichTooltip
|
||||||
|
:title="`전투특기 · ${props.general.traits?.specialWar ?? '-'}`"
|
||||||
|
:description="props.general.traitInfo?.specialWar"
|
||||||
|
test-id="special-war"
|
||||||
|
>
|
||||||
|
{{ props.general.traits?.specialWar ?? '-' }}
|
||||||
|
</RichTooltip>
|
||||||
|
</strong>
|
||||||
|
|
||||||
<span class="cell-label level-label">Lv</span>
|
<span class="cell-label level-label">Lv</span>
|
||||||
<strong class="level-value">{{ props.general.progression?.experienceLevel ?? 0 }}</strong>
|
<strong class="level-value">{{ props.general.progression?.experienceLevel ?? 0 }}</strong>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import SkeletonLines from '../ui/SkeletonLines.vue';
|
import SkeletonLines from '../ui/SkeletonLines.vue';
|
||||||
|
import RichTooltip from '../ui/RichTooltip.vue';
|
||||||
import { legacyLuminanceTextColor } from '../../utils/legacyNationColor';
|
import { legacyLuminanceTextColor } from '../../utils/legacyNationColor';
|
||||||
import { formatOfficerLevelText } from '../../utils/nationFormat';
|
import { formatOfficerLevelText } from '../../utils/nationFormat';
|
||||||
import { getNpcColor } from '../../utils/npcColor';
|
import { getNpcColor } from '../../utils/npcColor';
|
||||||
@@ -19,6 +20,7 @@ interface NationInfo {
|
|||||||
rice: number;
|
rice: number;
|
||||||
tech: number;
|
tech: number;
|
||||||
typeName: string;
|
typeName: string;
|
||||||
|
typeInfo?: string;
|
||||||
typePros: string;
|
typePros: string;
|
||||||
typeCons: string;
|
typeCons: string;
|
||||||
population: { cityCount: number; current: number; max: number };
|
population: { cityCount: number; current: number; max: number };
|
||||||
@@ -67,9 +69,37 @@ const displayChiefName = (chief: NationChief | undefined): string => {
|
|||||||
|
|
||||||
<span class="head">성향</span>
|
<span class="head">성향</span>
|
||||||
<strong class="body type-body">
|
<strong class="body type-body">
|
||||||
|
<RichTooltip
|
||||||
|
v-if="props.nation.typeInfo"
|
||||||
|
:title="`국가 성향 · ${props.nation.typeName}`"
|
||||||
|
:description="props.nation.typeInfo"
|
||||||
|
test-id="nation-type"
|
||||||
|
>
|
||||||
{{ props.nation.typeName }} (<span class="pros">{{ props.nation.typePros }}</span>
|
{{ props.nation.typeName }} (<span class="pros">{{ props.nation.typePros }}</span>
|
||||||
<span class="cons">{{ props.nation.typeCons }}</span
|
<span class="cons">{{ props.nation.typeCons }}</span
|
||||||
>)
|
>)
|
||||||
|
<template #content="{ descriptionLines }">
|
||||||
|
<span class="rich-tooltip-content__title">국가 성향 · {{ props.nation.typeName }}</span>
|
||||||
|
<span
|
||||||
|
v-for="(line, index) in descriptionLines"
|
||||||
|
:key="`nation-type-info:${index}`"
|
||||||
|
class="rich-tooltip-content__line"
|
||||||
|
>
|
||||||
|
{{ line }}
|
||||||
|
</span>
|
||||||
|
<span class="rich-tooltip-content__line rich-tooltip-content__pros">
|
||||||
|
장점 {{ props.nation.typePros || '-' }}
|
||||||
|
</span>
|
||||||
|
<span class="rich-tooltip-content__line rich-tooltip-content__cons">
|
||||||
|
단점 {{ props.nation.typeCons || '-' }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</RichTooltip>
|
||||||
|
<template v-else>
|
||||||
|
{{ props.nation.typeName }} (<span class="pros">{{ props.nation.typePros }}</span>
|
||||||
|
<span class="cons">{{ props.nation.typeCons }}</span
|
||||||
|
>)
|
||||||
|
</template>
|
||||||
</strong>
|
</strong>
|
||||||
|
|
||||||
<span class="head">{{ formatOfficerLevelText(12, props.nation.level) }}</span>
|
<span class="head">{{ formatOfficerLevelText(12, props.nation.level) }}</span>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
resolveTournamentCoreStat,
|
resolveTournamentCoreStat,
|
||||||
type TournamentBracketMatch,
|
type TournamentBracketMatch,
|
||||||
type TournamentBracketParticipant,
|
type TournamentBracketParticipant,
|
||||||
|
type TournamentBracketSlot,
|
||||||
} from '../../utils/tournamentBracket';
|
} from '../../utils/tournamentBracket';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@@ -17,6 +18,11 @@ const props = defineProps<{
|
|||||||
totalBet: number;
|
totalBet: number;
|
||||||
tournamentType?: number;
|
tournamentType?: number;
|
||||||
showLegend?: boolean;
|
showLegend?: boolean;
|
||||||
|
bettingOpen?: boolean;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
requestBet: [slot: TournamentBracketSlot];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const bracket = computed(() => buildTournamentBracket(props.participants, props.matches, props.winnerId));
|
const bracket = computed(() => buildTournamentBracket(props.participants, props.matches, props.winnerId));
|
||||||
@@ -72,6 +78,10 @@ const odds = (id: number | null) => {
|
|||||||
const myBet = (id: number | null) => (id === null ? 0 : (props.myBetTotals?.[id] ?? 0));
|
const myBet = (id: number | null) => (id === null ? 0 : (props.myBetTotals?.[id] ?? 0));
|
||||||
const coreStat = (slot: (typeof bracket.value.top16.slots)[number]) =>
|
const coreStat = (slot: (typeof bracket.value.top16.slots)[number]) =>
|
||||||
resolveTournamentCoreStat(slot, props.tournamentType ?? 0);
|
resolveTournamentCoreStat(slot, props.tournamentType ?? 0);
|
||||||
|
const requestBet = (slot: TournamentBracketSlot) => {
|
||||||
|
if (!props.bettingOpen || slot.id === null) return;
|
||||||
|
emit('requestBet', slot);
|
||||||
|
};
|
||||||
const mobilePairs = computed(() => {
|
const mobilePairs = computed(() => {
|
||||||
const column = roundColumns.value[activeMobileRound.value] ?? [];
|
const column = roundColumns.value[activeMobileRound.value] ?? [];
|
||||||
if (activeMobileRound.value === roundColumns.value.length - 1) return column.map((slot) => [slot]);
|
if (activeMobileRound.value === roundColumns.value.length - 1) return column.map((slot) => [slot]);
|
||||||
@@ -114,7 +124,10 @@ const mobilePairs = computed(() => {
|
|||||||
v-for="(slot, slotIndex) in column"
|
v-for="(slot, slotIndex) in column"
|
||||||
:key="`${columnIndex}-${slot.id ?? 'empty'}-${slotIndex}`"
|
:key="`${columnIndex}-${slot.id ?? 'empty'}-${slotIndex}`"
|
||||||
class="desktop-bracket-name"
|
class="desktop-bracket-name"
|
||||||
:class="{ advanced: slot.advanced }"
|
:class="{
|
||||||
|
advanced: slot.advanced,
|
||||||
|
'betting-target': columnIndex === 0 && bettingOpen && slot.id !== null,
|
||||||
|
}"
|
||||||
:data-general-id="slot.id ?? undefined"
|
:data-general-id="slot.id ?? undefined"
|
||||||
:style="{
|
:style="{
|
||||||
left: `${(desktopX[columnIndex]! / 1200) * 100}%`,
|
left: `${(desktopX[columnIndex]! / 1200) * 100}%`,
|
||||||
@@ -122,6 +135,15 @@ const mobilePairs = computed(() => {
|
|||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<GeneralIdentity :name="slot.name" :picture="slot.picture" :image-server="slot.imageServer" />
|
<GeneralIdentity :name="slot.name" :picture="slot.picture" :image-server="slot.imageServer" />
|
||||||
|
<button
|
||||||
|
v-if="columnIndex === 0 && bettingOpen && slot.id !== null"
|
||||||
|
type="button"
|
||||||
|
class="bracket-bet-button"
|
||||||
|
:aria-label="`${slot.name}에게 베팅하기`"
|
||||||
|
@click="requestBet(slot)"
|
||||||
|
>
|
||||||
|
베팅하기
|
||||||
|
</button>
|
||||||
<div v-if="columnIndex === 0" class="bracket-bet-summary">
|
<div v-if="columnIndex === 0" class="bracket-bet-summary">
|
||||||
<small v-if="coreStat(slot)" class="bracket-core-stat">
|
<small v-if="coreStat(slot)" class="bracket-core-stat">
|
||||||
{{ coreStat(slot)?.label }} {{ coreStat(slot)?.value }}
|
{{ coreStat(slot)?.label }} {{ coreStat(slot)?.value }}
|
||||||
@@ -154,10 +176,22 @@ const mobilePairs = computed(() => {
|
|||||||
v-for="(slot, slotIndex) in pair"
|
v-for="(slot, slotIndex) in pair"
|
||||||
:key="`${slot.id ?? 'empty'}-${slotIndex}`"
|
:key="`${slot.id ?? 'empty'}-${slotIndex}`"
|
||||||
class="mobile-bracket-name"
|
class="mobile-bracket-name"
|
||||||
:class="{ advanced: slot.advanced }"
|
:class="{
|
||||||
|
advanced: slot.advanced,
|
||||||
|
'betting-target': activeMobileRound === 0 && bettingOpen && slot.id !== null,
|
||||||
|
}"
|
||||||
:data-general-id="slot.id ?? undefined"
|
:data-general-id="slot.id ?? undefined"
|
||||||
>
|
>
|
||||||
<GeneralIdentity :name="slot.name" :picture="slot.picture" :image-server="slot.imageServer" />
|
<GeneralIdentity :name="slot.name" :picture="slot.picture" :image-server="slot.imageServer" />
|
||||||
|
<button
|
||||||
|
v-if="activeMobileRound === 0 && bettingOpen && slot.id !== null"
|
||||||
|
type="button"
|
||||||
|
class="bracket-bet-button"
|
||||||
|
:aria-label="`${slot.name}에게 베팅하기`"
|
||||||
|
@click="requestBet(slot)"
|
||||||
|
>
|
||||||
|
베팅하기
|
||||||
|
</button>
|
||||||
<div v-if="activeMobileRound === 0" class="bracket-bet-summary">
|
<div v-if="activeMobileRound === 0" class="bracket-bet-summary">
|
||||||
<small v-if="coreStat(slot)" class="bracket-core-stat">
|
<small v-if="coreStat(slot)" class="bracket-core-stat">
|
||||||
{{ coreStat(slot)?.label }} {{ coreStat(slot)?.value }}
|
{{ coreStat(slot)?.label }} {{ coreStat(slot)?.value }}
|
||||||
@@ -228,6 +262,36 @@ const mobilePairs = computed(() => {
|
|||||||
color: #fff;
|
color: #fff;
|
||||||
padding: 1px 3px;
|
padding: 1px 3px;
|
||||||
}
|
}
|
||||||
|
.betting-target {
|
||||||
|
position: absolute;
|
||||||
|
}
|
||||||
|
.mobile-bracket-name.betting-target {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.bracket-bet-button {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 2;
|
||||||
|
top: 3px;
|
||||||
|
right: 3px;
|
||||||
|
min-width: 58px;
|
||||||
|
height: 24px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 2px 5px;
|
||||||
|
border: 1px solid #9a7632;
|
||||||
|
border-radius: 3px;
|
||||||
|
color: #fff3cd;
|
||||||
|
background: #59400e;
|
||||||
|
font: 700 11px/1 var(--sammo-font-sans);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.bracket-bet-button:hover,
|
||||||
|
.bracket-bet-button:focus {
|
||||||
|
filter: brightness(1.25);
|
||||||
|
}
|
||||||
|
.bracket-bet-button:focus-visible {
|
||||||
|
outline: 2px solid #f39c12;
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
.desktop-bracket-name.advanced,
|
.desktop-bracket-name.advanced,
|
||||||
.mobile-bracket-name.advanced {
|
.mobile-bracket-name.advanced {
|
||||||
border-color: #ff4b4b;
|
border-color: #ff4b4b;
|
||||||
|
|||||||
@@ -0,0 +1,179 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, nextTick, onBeforeUnmount, onMounted, ref, useSlots, watch } from 'vue';
|
||||||
|
import tippy, { type Instance, type Placement } from 'tippy.js';
|
||||||
|
import 'tippy.js/dist/tippy.css';
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
title?: string;
|
||||||
|
description?: string | readonly string[] | null;
|
||||||
|
placement?: Placement;
|
||||||
|
maxWidth?: number;
|
||||||
|
testId?: string;
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
title: '',
|
||||||
|
description: null,
|
||||||
|
placement: 'top',
|
||||||
|
maxWidth: 360,
|
||||||
|
testId: undefined,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const slots = useSlots();
|
||||||
|
const triggerElement = ref<HTMLElement | null>(null);
|
||||||
|
const contentElement = ref<HTMLElement | null>(null);
|
||||||
|
let instance: Instance | null = null;
|
||||||
|
|
||||||
|
const descriptionLines = computed(() => {
|
||||||
|
const source = Array.isArray(props.description) ? props.description : [props.description ?? ''];
|
||||||
|
return source
|
||||||
|
.flatMap((line) => line.split(/<br\s*\/?\s*>|\r?\n/giu))
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
});
|
||||||
|
|
||||||
|
const hasContent = computed(() => Boolean(slots.content) || descriptionLines.value.length > 0);
|
||||||
|
|
||||||
|
const destroyTooltip = () => {
|
||||||
|
instance?.destroy();
|
||||||
|
instance = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const installTooltip = async () => {
|
||||||
|
destroyTooltip();
|
||||||
|
await nextTick();
|
||||||
|
if (!hasContent.value || !triggerElement.value || !contentElement.value) return;
|
||||||
|
|
||||||
|
instance = tippy(triggerElement.value, {
|
||||||
|
allowHTML: true,
|
||||||
|
appendTo: () => document.body,
|
||||||
|
content: () => contentElement.value?.innerHTML ?? '',
|
||||||
|
maxWidth: props.maxWidth,
|
||||||
|
placement: props.placement,
|
||||||
|
theme: 'sammo-rich',
|
||||||
|
trigger: 'mouseenter focus',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => void installTooltip());
|
||||||
|
onBeforeUnmount(destroyTooltip);
|
||||||
|
watch(
|
||||||
|
() => [props.title, props.description, props.placement, props.maxWidth],
|
||||||
|
() => void installTooltip(),
|
||||||
|
{ deep: true }
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<span
|
||||||
|
ref="triggerElement"
|
||||||
|
class="rich-tooltip-trigger"
|
||||||
|
:class="{ 'rich-tooltip-trigger--enabled': hasContent }"
|
||||||
|
:tabindex="hasContent ? 0 : undefined"
|
||||||
|
:data-rich-tooltip="props.testId"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</span>
|
||||||
|
<span ref="contentElement" class="rich-tooltip-template" hidden aria-hidden="true">
|
||||||
|
<slot name="content" :description-lines="descriptionLines">
|
||||||
|
<span v-if="props.title" class="rich-tooltip-content__title">{{ props.title }}</span>
|
||||||
|
<span
|
||||||
|
v-for="(line, index) in descriptionLines"
|
||||||
|
:key="`${index}:${line}`"
|
||||||
|
class="rich-tooltip-content__line"
|
||||||
|
>
|
||||||
|
{{ line }}
|
||||||
|
</span>
|
||||||
|
</slot>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.rich-tooltip-trigger {
|
||||||
|
display: inline;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-tooltip-trigger--enabled {
|
||||||
|
cursor: help;
|
||||||
|
text-decoration: underline dotted rgb(150 210 255 / 85%);
|
||||||
|
text-underline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-tooltip-trigger--enabled:focus-visible {
|
||||||
|
border-radius: 2px;
|
||||||
|
outline: 1px solid #6fc7ff;
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tippy-box[data-theme~='sammo-rich'] {
|
||||||
|
border: 1px solid #8c8c8c;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: #101010;
|
||||||
|
box-shadow: 0 3px 12px rgb(0 0 0 / 65%);
|
||||||
|
color: #f5f5f5;
|
||||||
|
font-family: Pretendard, sans-serif;
|
||||||
|
font-size: 12.5px;
|
||||||
|
line-height: 1.45;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tippy-box[data-theme~='sammo-rich'][data-placement^='top'] > .tippy-arrow::before {
|
||||||
|
border-top-color: #101010;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tippy-box[data-theme~='sammo-rich'][data-placement^='bottom'] > .tippy-arrow::before {
|
||||||
|
border-bottom-color: #101010;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tippy-box[data-theme~='sammo-rich'][data-placement^='left'] > .tippy-arrow::before {
|
||||||
|
border-left-color: #101010;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tippy-box[data-theme~='sammo-rich'][data-placement^='right'] > .tippy-arrow::before {
|
||||||
|
border-right-color: #101010;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tippy-box[data-theme~='sammo-rich'] .tippy-content {
|
||||||
|
padding: 7px 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-tooltip-content__title,
|
||||||
|
.rich-tooltip-content__line,
|
||||||
|
.rich-tooltip-content__section,
|
||||||
|
.rich-tooltip-content__meta {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-tooltip-content__title {
|
||||||
|
margin-bottom: 4px;
|
||||||
|
color: #7fd4ff;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-tooltip-content__line + .rich-tooltip-content__line {
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-tooltip-content__section {
|
||||||
|
margin-top: 5px;
|
||||||
|
border-top: 1px solid #4d4d4d;
|
||||||
|
padding-top: 4px;
|
||||||
|
color: #ffdc76;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-tooltip-content__meta {
|
||||||
|
color: #d5d5d5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-tooltip-content__pros {
|
||||||
|
color: cyan;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-tooltip-content__cons {
|
||||||
|
color: magenta;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -13,3 +13,18 @@ export const tournamentStageNames = [
|
|||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export const resolveTournamentStageName = (stage: number): string => tournamentStageNames[stage] ?? '상태 확인 중';
|
export const resolveTournamentStageName = (stage: number): string => tournamentStageNames[stage] ?? '상태 확인 중';
|
||||||
|
|
||||||
|
export interface TournamentSectionVisibility {
|
||||||
|
preliminary: boolean;
|
||||||
|
final: boolean;
|
||||||
|
knockout: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const resolveTournamentSectionVisibility = (stage: number, winnerId?: number): TournamentSectionVisibility => {
|
||||||
|
const completed = stage === 0 && winnerId !== undefined;
|
||||||
|
return {
|
||||||
|
preliminary: stage >= 1 || completed,
|
||||||
|
final: stage >= 3 || completed,
|
||||||
|
knockout: stage >= 5 || completed,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { formatServerDateTime } from '@sammo-ts/common/time/ServerDateTime';
|
import { formatServerDateTime } from '@sammo-ts/common/time/ServerDateTime';
|
||||||
import { computed, 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 TournamentPageHeader from '../components/tournament/TournamentPageHeader.vue';
|
import TournamentPageHeader from '../components/tournament/TournamentPageHeader.vue';
|
||||||
import GeneralIdentity from '../components/ui/GeneralIdentity.vue';
|
import GeneralIdentity from '../components/ui/GeneralIdentity.vue';
|
||||||
|
import type { TournamentBracketSlot } from '../utils/tournamentBracket';
|
||||||
import { trpc } from '../utils/trpc';
|
import { trpc } from '../utils/trpc';
|
||||||
|
|
||||||
type Snapshot = Awaited<ReturnType<typeof trpc.tournament.getSnapshot.query>>;
|
type Snapshot = Awaited<ReturnType<typeof trpc.tournament.getSnapshot.query>>;
|
||||||
@@ -15,6 +16,11 @@ const loading = ref(false);
|
|||||||
const error = ref<string | null>(null);
|
const error = ref<string | null>(null);
|
||||||
const message = ref<string | null>(null);
|
const message = ref<string | null>(null);
|
||||||
const amounts = ref<Record<number, number>>({});
|
const amounts = ref<Record<number, number>>({});
|
||||||
|
const selectedTarget = ref<TournamentBracketSlot | null>(null);
|
||||||
|
const betDialog = ref<HTMLDialogElement | null>(null);
|
||||||
|
const betAmountSelect = ref<HTMLSelectElement | null>(null);
|
||||||
|
const placingBet = ref(false);
|
||||||
|
const betError = ref<string | null>(null);
|
||||||
const activeRankingPrefix = ref('tt');
|
const activeRankingPrefix = ref('tt');
|
||||||
const typeNames = ['전력전', '통솔전', '일기토', '설전'];
|
const typeNames = ['전력전', '통솔전', '일기토', '설전'];
|
||||||
const stageNames = [
|
const stageNames = [
|
||||||
@@ -49,27 +55,6 @@ const load = async () => {
|
|||||||
};
|
};
|
||||||
onMounted(() => void load());
|
onMounted(() => void load());
|
||||||
|
|
||||||
const participantMap = computed(
|
|
||||||
() => new Map((snapshot.value?.participants ?? []).map((participant) => [participant.id, participant]))
|
|
||||||
);
|
|
||||||
const final16Ids = computed(() =>
|
|
||||||
(snapshot.value?.matches ?? [])
|
|
||||||
.filter((match) => match.stage === 7)
|
|
||||||
.sort((a, b) => a.roundIndex - b.roundIndex)
|
|
||||||
.flatMap((match) => [match.attackerId, match.defenderId])
|
|
||||||
);
|
|
||||||
const candidates = computed(() =>
|
|
||||||
Array.from({ length: 16 }, (_, index) => {
|
|
||||||
const id = final16Ids.value[index] ?? 0;
|
|
||||||
const participant = id ? participantMap.value.get(id) : null;
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
name: id ? (participant?.name ?? `#${id}`) : '-',
|
|
||||||
picture: participant?.picture ?? null,
|
|
||||||
imageServer: participant?.imageServer ?? 0,
|
|
||||||
};
|
|
||||||
})
|
|
||||||
);
|
|
||||||
const totalAmount = computed(() => summary.value?.totalAmount ?? 0);
|
const totalAmount = computed(() => summary.value?.totalAmount ?? 0);
|
||||||
const myAmount = computed(() => summary.value?.myAmount ?? 0);
|
const myAmount = computed(() => summary.value?.myAmount ?? 0);
|
||||||
const betTotals = computed(() => summary.value?.totals as Record<number, number> | undefined);
|
const betTotals = computed(() => summary.value?.totals as Record<number, number> | undefined);
|
||||||
@@ -82,12 +67,25 @@ const ratio = (id: number) => {
|
|||||||
const openingTime = computed(() =>
|
const openingTime = computed(() =>
|
||||||
formatServerDateTime(snapshot.value?.state?.nextAt, { format: 'hourMinute', fallback: '--:--' })
|
formatServerDateTime(snapshot.value?.state?.nextAt, { format: 'hourMinute', fallback: '--:--' })
|
||||||
);
|
);
|
||||||
const expected = (id: number) => {
|
const selectedAmount = computed({
|
||||||
const myTotals = summary.value?.myTotals as Record<number, number> | undefined;
|
get: () => {
|
||||||
const current = myTotals?.[id] ?? 0;
|
const targetId = selectedTarget.value?.id;
|
||||||
const numericRatio = Number(ratio(id));
|
return targetId === null || targetId === undefined ? 10 : (amounts.value[targetId] ?? 10);
|
||||||
return Number.isFinite(numericRatio) ? Math.floor(current * numericRatio) : 0;
|
},
|
||||||
};
|
set: (amount: number) => {
|
||||||
|
const targetId = selectedTarget.value?.id;
|
||||||
|
if (targetId === null || targetId === undefined) return;
|
||||||
|
amounts.value[targetId] = amount;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const selectedRatio = computed(() => {
|
||||||
|
const targetId = selectedTarget.value?.id;
|
||||||
|
return targetId === null || targetId === undefined ? '0' : ratio(targetId);
|
||||||
|
});
|
||||||
|
const selectedExpectedReturn = computed(() => {
|
||||||
|
const numericRatio = Number(selectedRatio.value);
|
||||||
|
return Number.isFinite(numericRatio) ? Math.round(selectedAmount.value * numericRatio) : 0;
|
||||||
|
});
|
||||||
const bettingOpen = computed(() => {
|
const bettingOpen = computed(() => {
|
||||||
const state = snapshot.value?.state;
|
const state = snapshot.value?.state;
|
||||||
if (!state || state.stage !== 6) return false;
|
if (!state || state.stage !== 6) return false;
|
||||||
@@ -95,17 +93,35 @@ const bettingOpen = computed(() => {
|
|||||||
return new Date(state.bettingCloseAt).getTime() > Date.now();
|
return new Date(state.bettingCloseAt).getTime() > Date.now();
|
||||||
});
|
});
|
||||||
|
|
||||||
const placeBet = async (targetId: number) => {
|
const openBetDialog = async (target: TournamentBracketSlot) => {
|
||||||
if (!targetId) return;
|
if (target.id === null || !bettingOpen.value) return;
|
||||||
const amount = amounts.value[targetId] ?? 10;
|
selectedTarget.value = target;
|
||||||
|
betError.value = null;
|
||||||
|
if (amounts.value[target.id] === undefined) amounts.value[target.id] = 10;
|
||||||
|
await nextTick();
|
||||||
|
betDialog.value?.showModal();
|
||||||
|
betAmountSelect.value?.focus();
|
||||||
|
};
|
||||||
|
const closeBetDialog = () => {
|
||||||
|
betDialog.value?.close();
|
||||||
|
};
|
||||||
|
const placeBet = async () => {
|
||||||
|
const targetId = selectedTarget.value?.id;
|
||||||
|
if (targetId === null || targetId === undefined || placingBet.value) return;
|
||||||
|
const amount = selectedAmount.value;
|
||||||
message.value = null;
|
message.value = null;
|
||||||
|
betError.value = null;
|
||||||
|
placingBet.value = true;
|
||||||
try {
|
try {
|
||||||
await trpc.tournament.placeBet.mutate({ targetId, amount });
|
await trpc.tournament.placeBet.mutate({ targetId, amount });
|
||||||
message.value = '베팅이 등록되었습니다.';
|
message.value = '베팅이 등록되었습니다.';
|
||||||
} catch (value) {
|
|
||||||
message.value = errorText(value);
|
|
||||||
} finally {
|
|
||||||
await load();
|
await load();
|
||||||
|
closeBetDialog();
|
||||||
|
} catch (value) {
|
||||||
|
betError.value = errorText(value);
|
||||||
|
message.value = betError.value;
|
||||||
|
} finally {
|
||||||
|
placingBet.value = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
@@ -139,47 +155,57 @@ const placeBet = async (targetId: number) => {
|
|||||||
:total-bet="totalAmount"
|
:total-bet="totalAmount"
|
||||||
:tournament-type="snapshot?.state?.type ?? 0"
|
:tournament-type="snapshot?.state?.type ?? 0"
|
||||||
:show-legend="false"
|
:show-legend="false"
|
||||||
|
:betting-open="bettingOpen"
|
||||||
|
@request-bet="openBetDialog"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<section class="candidate-table bg0">
|
<dialog
|
||||||
<div class="candidate-grid">
|
ref="betDialog"
|
||||||
<article v-for="candidate in candidates" :key="candidate.id || candidate.name" class="candidate-card">
|
class="bet-dialog"
|
||||||
<GeneralIdentity
|
aria-labelledby="bet-dialog-title"
|
||||||
:name="candidate.name"
|
@close="selectedTarget = null"
|
||||||
:picture="candidate.picture"
|
|
||||||
:image-server="candidate.imageServer"
|
|
||||||
/>
|
|
||||||
<div class="candidate-return">
|
|
||||||
<span class="ratio-color">{{ ratio(candidate.id) }}</span>
|
|
||||||
<span aria-hidden="true">×</span>
|
|
||||||
<span class="gold-color">{{ amounts[candidate.id] ?? 10 }}</span>
|
|
||||||
<span aria-hidden="true">=</span>
|
|
||||||
<strong class="return-color">{{ expected(candidate.id) }}</strong>
|
|
||||||
</div>
|
|
||||||
<div v-if="bettingOpen" class="candidate-actions">
|
|
||||||
<select
|
|
||||||
v-model.number="amounts[candidate.id]"
|
|
||||||
:aria-label="`${candidate.name} 베팅 금액`"
|
|
||||||
:disabled="!candidate.id"
|
|
||||||
>
|
>
|
||||||
|
<form v-if="selectedTarget" class="bet-dialog-content" @submit.prevent="placeBet">
|
||||||
|
<header>
|
||||||
|
<h2 id="bet-dialog-title">베팅하기</h2>
|
||||||
|
<button type="button" aria-label="베팅 창 닫기" :disabled="placingBet" @click="closeBetDialog">
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
<GeneralIdentity
|
||||||
|
:name="selectedTarget.name"
|
||||||
|
:picture="selectedTarget.picture"
|
||||||
|
:image-server="selectedTarget.imageServer"
|
||||||
|
/>
|
||||||
|
<label class="bet-amount-field">
|
||||||
|
<span>베팅 금액</span>
|
||||||
|
<select ref="betAmountSelect" v-model.number="selectedAmount" :disabled="placingBet">
|
||||||
<option :value="10">금10</option>
|
<option :value="10">금10</option>
|
||||||
<option :value="20">금20</option>
|
<option :value="20">금20</option>
|
||||||
<option :value="50">금50</option>
|
<option :value="50">금50</option>
|
||||||
<option :value="100">금100</option>
|
<option :value="100">금100</option>
|
||||||
<option :value="200">금200</option>
|
<option :value="200">금200</option>
|
||||||
<option :value="500">금500</option>
|
<option :value="500">금500</option>
|
||||||
<option :value="1000">최대</option>
|
<option :value="1000">최대 금1000</option>
|
||||||
</select>
|
</select>
|
||||||
<button type="button" :disabled="!candidate.id" @click="placeBet(candidate.id)">베팅</button>
|
</label>
|
||||||
</div>
|
<output class="bet-return-preview" aria-live="polite">
|
||||||
</article>
|
<span class="ratio-color">배당 {{ selectedRatio }}</span>
|
||||||
</div>
|
<span aria-hidden="true">×</span>
|
||||||
<p class="candidate-help">
|
<span class="gold-color">금{{ selectedAmount }}</span>
|
||||||
<span class="ratio-color">배당률</span> × <span class="gold-color">베팅금</span> =
|
<span aria-hidden="true">=</span>
|
||||||
<span class="return-color">적중시 환수금</span><br />
|
<strong class="return-color">예상 환수금 {{ selectedExpectedReturn.toLocaleString('ko-KR') }}</strong>
|
||||||
<span class="ratio-color">( 베팅후 500원 이하일땐 베팅이 불가능합니다. )</span>
|
</output>
|
||||||
</p>
|
<p class="bet-preview-note">현재 배당 기준 예상값이며, 베팅 상황에 따라 최종 배당은 달라질 수 있습니다.</p>
|
||||||
</section>
|
<p v-if="betError" class="bet-dialog-error" role="alert">{{ betError }}</p>
|
||||||
|
<footer>
|
||||||
|
<button type="button" :disabled="placingBet" @click="closeBetDialog">취소</button>
|
||||||
|
<button type="submit" class="bet-submit" :disabled="placingBet">
|
||||||
|
{{ placingBet ? '등록 중...' : '베팅 등록' }}
|
||||||
|
</button>
|
||||||
|
</footer>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
<div class="legacy-table-signature" hidden>
|
<div class="legacy-table-signature" hidden>
|
||||||
<table v-for="tableIndex in 6" :key="tableIndex">
|
<table v-for="tableIndex in 6" :key="tableIndex">
|
||||||
@@ -336,36 +362,6 @@ const placeBet = async (targetId: number) => {
|
|||||||
color: orange;
|
color: orange;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
.candidate-table {
|
|
||||||
border: 1px solid gray;
|
|
||||||
padding: 10px;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
.candidate-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
.candidate-card {
|
|
||||||
min-width: 0;
|
|
||||||
padding: 8px;
|
|
||||||
border: 1px solid #5b504b;
|
|
||||||
background: rgb(0 0 0 / 26%);
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
.candidate-return {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr auto 1fr auto 1fr;
|
|
||||||
gap: 4px;
|
|
||||||
margin: 8px 0;
|
|
||||||
text-align: center;
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
}
|
|
||||||
.candidate-actions {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: minmax(0, 1fr) 64px;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
.ratio-color {
|
.ratio-color {
|
||||||
color: skyblue;
|
color: skyblue;
|
||||||
}
|
}
|
||||||
@@ -376,8 +372,7 @@ const placeBet = async (targetId: number) => {
|
|||||||
.gold-color {
|
.gold-color {
|
||||||
color: orange;
|
color: orange;
|
||||||
}
|
}
|
||||||
select,
|
select {
|
||||||
.candidate-actions button {
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-height: 27px;
|
min-height: 27px;
|
||||||
padding: 2px 1px;
|
padding: 2px 1px;
|
||||||
@@ -407,11 +402,84 @@ select:disabled {
|
|||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
}
|
}
|
||||||
.candidate-help {
|
.bet-dialog {
|
||||||
min-height: 20px;
|
width: min(420px, calc(100vw - 24px));
|
||||||
margin: 8px 0 0;
|
max-width: none;
|
||||||
font-size: 18px;
|
padding: 0;
|
||||||
line-height: 14px;
|
border: 1px solid #8d713d;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #fff;
|
||||||
|
background: #3a2118 var(--sammo-texture-walnut);
|
||||||
|
box-shadow: 0 18px 56px rgb(0 0 0 / 75%);
|
||||||
|
}
|
||||||
|
.bet-dialog::backdrop {
|
||||||
|
background: rgb(0 0 0 / 72%);
|
||||||
|
}
|
||||||
|
.bet-dialog-content {
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
.bet-dialog-content header,
|
||||||
|
.bet-dialog-content footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.bet-dialog-content h2 {
|
||||||
|
margin: 0;
|
||||||
|
color: #ffd25e;
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
.bet-dialog-content header button {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
padding: 0;
|
||||||
|
font-size: 22px;
|
||||||
|
}
|
||||||
|
.bet-dialog-content :deep(.general-identity) {
|
||||||
|
justify-content: flex-start;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.bet-amount-field {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 88px minmax(0, 1fr);
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.bet-return-preview {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto auto auto auto minmax(0, 1fr);
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid #66563c;
|
||||||
|
background: rgb(0 0 0 / 28%);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.bet-preview-note,
|
||||||
|
.bet-dialog-error {
|
||||||
|
margin: 0;
|
||||||
|
text-align: left;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.bet-preview-note {
|
||||||
|
color: #c9c1b2;
|
||||||
|
}
|
||||||
|
.bet-dialog-error {
|
||||||
|
color: #ff8080;
|
||||||
|
}
|
||||||
|
.bet-dialog-content footer {
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
.bet-dialog-content footer button {
|
||||||
|
min-width: 80px;
|
||||||
|
}
|
||||||
|
.bet-dialog-content .bet-submit {
|
||||||
|
border-color: #9a7632;
|
||||||
|
background: #59400e;
|
||||||
}
|
}
|
||||||
.ranking-title {
|
.ranking-title {
|
||||||
min-height: 50px;
|
min-height: 50px;
|
||||||
@@ -490,25 +558,12 @@ select:disabled {
|
|||||||
.ranking-title {
|
.ranking-title {
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
}
|
}
|
||||||
.candidate-grid {
|
.bet-return-preview {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: auto auto auto;
|
||||||
}
|
}
|
||||||
.candidate-card {
|
.bet-return-preview .return-color {
|
||||||
display: grid;
|
|
||||||
grid-template-columns: minmax(0, 1fr) 112px;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px 12px;
|
|
||||||
}
|
|
||||||
.candidate-return {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
.candidate-actions {
|
|
||||||
grid-column: 1 / -1;
|
grid-column: 1 / -1;
|
||||||
}
|
}
|
||||||
.candidate-help {
|
|
||||||
font-size: 14px;
|
|
||||||
line-height: 18px;
|
|
||||||
}
|
|
||||||
.ranking-placeholder {
|
.ranking-placeholder {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { formatServerDateTime } from '@sammo-ts/common/time/ServerDateTime';
|
import { formatServerDateTime } from '@sammo-ts/common/time/ServerDateTime';
|
||||||
import { computed, 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 TournamentPageHeader from '../components/tournament/TournamentPageHeader.vue';
|
import TournamentPageHeader from '../components/tournament/TournamentPageHeader.vue';
|
||||||
import GeneralIdentity from '../components/ui/GeneralIdentity.vue';
|
import GeneralIdentity from '../components/ui/GeneralIdentity.vue';
|
||||||
import { trpc } from '../utils/trpc';
|
import { trpc } from '../utils/trpc';
|
||||||
import { resolveTournamentStageName } from '../utils/tournamentStatus';
|
import { resolveTournamentSectionVisibility, resolveTournamentStageName } from '../utils/tournamentStatus';
|
||||||
|
|
||||||
type Snapshot = Awaited<ReturnType<typeof trpc.tournament.getSnapshot.query>>;
|
type Snapshot = Awaited<ReturnType<typeof trpc.tournament.getSnapshot.query>>;
|
||||||
|
|
||||||
@@ -18,6 +18,7 @@ const actionMessage = ref<string | null>(null);
|
|||||||
const adminEnabled = ref(false);
|
const adminEnabled = ref(false);
|
||||||
const activeFinalGroup = ref(0);
|
const activeFinalGroup = ref(0);
|
||||||
const activePreliminaryGroup = ref(0);
|
const activePreliminaryGroup = ref(0);
|
||||||
|
const tournamentContainer = ref<HTMLElement | null>(null);
|
||||||
|
|
||||||
const typeNames = ['전력전', '통솔전', '일기토', '설전'];
|
const typeNames = ['전력전', '통솔전', '일기토', '설전'];
|
||||||
const typeStatNames = ['종합', '통솔', '무력', '지력'];
|
const typeStatNames = ['종합', '통솔', '무력', '지력'];
|
||||||
@@ -63,6 +64,12 @@ const myBetTotals = computed(() => betting.value?.myTotals as Record<number, num
|
|||||||
const isParticipant = computed(() =>
|
const isParticipant = computed(() =>
|
||||||
(snapshot.value?.participants ?? []).some((participant) => participant.id === myGeneralId.value)
|
(snapshot.value?.participants ?? []).some((participant) => participant.id === myGeneralId.value)
|
||||||
);
|
);
|
||||||
|
const sectionVisibility = computed(() =>
|
||||||
|
resolveTournamentSectionVisibility(snapshot.value?.state?.stage ?? 0, snapshot.value?.state?.winnerId)
|
||||||
|
);
|
||||||
|
const preliminaryGroupIdOf = (participant: Snapshot['participants'][number]): number | undefined =>
|
||||||
|
participant.preliminaryGroupId ??
|
||||||
|
(participant.groupId !== undefined && participant.groupId < 8 ? participant.groupId : undefined);
|
||||||
const groups = computed(() =>
|
const groups = computed(() =>
|
||||||
Array.from({ length: 8 }, (_, index) =>
|
Array.from({ length: 8 }, (_, index) =>
|
||||||
(snapshot.value?.participants ?? [])
|
(snapshot.value?.participants ?? [])
|
||||||
@@ -74,10 +81,7 @@ const preliminaryGroups = computed(() =>
|
|||||||
Array.from({ length: 8 }, (_, index) =>
|
Array.from({ length: 8 }, (_, index) =>
|
||||||
(snapshot.value?.participants ?? [])
|
(snapshot.value?.participants ?? [])
|
||||||
.filter((participant) => {
|
.filter((participant) => {
|
||||||
const groupId =
|
return preliminaryGroupIdOf(participant) === index;
|
||||||
participant.preliminaryGroupId ??
|
|
||||||
(participant.groupId !== undefined && participant.groupId < 8 ? participant.groupId : undefined);
|
|
||||||
return groupId === index;
|
|
||||||
})
|
})
|
||||||
.map((participant) => ({
|
.map((participant) => ({
|
||||||
...participant,
|
...participant,
|
||||||
@@ -113,15 +117,38 @@ const currentMatch = computed(() => {
|
|||||||
return matchesAt(state.stage).find((match) => !match.winnerId) ?? matchesAt(state.stage)[state.phase] ?? null;
|
return matchesAt(state.stage).find((match) => !match.winnerId) ?? matchesAt(state.stage)[state.phase] ?? null;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const revealMyPreliminaryGroup = async (): Promise<number | undefined> => {
|
||||||
|
const participant = (snapshot.value?.participants ?? []).find((entry) => entry.id === myGeneralId.value);
|
||||||
|
if (!participant) return undefined;
|
||||||
|
|
||||||
|
const groupId = preliminaryGroupIdOf(participant);
|
||||||
|
if (groupId === undefined || groupId < 0 || groupId >= groupNames.length) return undefined;
|
||||||
|
|
||||||
|
activePreliminaryGroup.value = groupId;
|
||||||
|
await nextTick();
|
||||||
|
tournamentContainer.value
|
||||||
|
?.querySelector<HTMLElement>(`[data-preliminary-group="${groupId}"]`)
|
||||||
|
?.scrollIntoView({ block: 'center' });
|
||||||
|
return groupId;
|
||||||
|
};
|
||||||
|
|
||||||
const join = async () => {
|
const join = async () => {
|
||||||
actionMessage.value = null;
|
actionMessage.value = null;
|
||||||
|
let joined = false;
|
||||||
try {
|
try {
|
||||||
await trpc.tournament.join.mutate();
|
await trpc.tournament.join.mutate();
|
||||||
actionMessage.value = '참가 신청이 반영되었습니다.';
|
joined = true;
|
||||||
} catch (value) {
|
} catch (value) {
|
||||||
actionMessage.value = errorText(value);
|
actionMessage.value = errorText(value);
|
||||||
} finally {
|
} finally {
|
||||||
await load();
|
await load();
|
||||||
|
if (joined) {
|
||||||
|
const groupId = await revealMyPreliminaryGroup();
|
||||||
|
actionMessage.value =
|
||||||
|
groupId === undefined
|
||||||
|
? '참가 신청이 반영되었습니다.'
|
||||||
|
: `참가 신청이 반영되었습니다. ${groupNames[groupId]}조에 배정되었습니다.`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -159,7 +186,7 @@ const start = async () => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<main id="tournament-container" class="legacy-page">
|
<main id="tournament-container" ref="tournamentContainer" class="legacy-page">
|
||||||
<TournamentPageHeader class="bg0" active-page="tournament" title="삼모전 토너먼트" />
|
<TournamentPageHeader class="bg0" active-page="tournament" title="삼모전 토너먼트" />
|
||||||
|
|
||||||
<section class="toolbar bg0">
|
<section class="toolbar bg0">
|
||||||
@@ -183,6 +210,7 @@ const start = async () => {
|
|||||||
({{ resolveTournamentStageName(snapshot?.state?.stage ?? 0) }}, 개막시간 {{ openingTime }}, 경기당
|
({{ resolveTournamentStageName(snapshot?.state?.stage ?? 0) }}, 개막시간 {{ openingTime }}, 경기당
|
||||||
{{ snapshot?.state?.termSeconds ?? '-' }}초)
|
{{ snapshot?.state?.termSeconds ?? '-' }}초)
|
||||||
</section>
|
</section>
|
||||||
|
<template v-if="sectionVisibility.knockout">
|
||||||
<section class="section-title bg2">16강 승자전</section>
|
<section class="section-title bg2">16강 승자전</section>
|
||||||
|
|
||||||
<TournamentBracket
|
<TournamentBracket
|
||||||
@@ -200,7 +228,9 @@ const start = async () => {
|
|||||||
<h2>{{ nameOf(currentMatch.attackerId) }} vs {{ nameOf(currentMatch.defenderId) }}</h2>
|
<h2>{{ nameOf(currentMatch.attackerId) }} vs {{ nameOf(currentMatch.defenderId) }}</h2>
|
||||||
<p v-for="(line, index) in currentMatch.log ?? []" :key="index">{{ line }}</p>
|
<p v-for="(line, index) in currentMatch.log ?? []" :key="index">{{ line }}</p>
|
||||||
</section>
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-if="sectionVisibility.final">
|
||||||
<section class="section-title groups-title bg2">조별 본선 순위</section>
|
<section class="section-title groups-title bg2">조별 본선 순위</section>
|
||||||
<div class="group-tabs bg0" role="tablist" aria-label="본선 조 선택">
|
<div class="group-tabs bg0" role="tablist" aria-label="본선 조 선택">
|
||||||
<button
|
<button
|
||||||
@@ -261,7 +291,9 @@ const start = async () => {
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</section>
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-if="sectionVisibility.preliminary">
|
||||||
<section class="section-title groups-title bg2">조별 예선 순위</section>
|
<section class="section-title groups-title bg2">조별 예선 순위</section>
|
||||||
<div class="group-tabs bg0" role="tablist" aria-label="예선 조 선택">
|
<div class="group-tabs bg0" role="tablist" aria-label="예선 조 선택">
|
||||||
<button
|
<button
|
||||||
@@ -280,6 +312,7 @@ const start = async () => {
|
|||||||
<table
|
<table
|
||||||
v-for="(group, groupIndex) in preliminaryGroups"
|
v-for="(group, groupIndex) in preliminaryGroups"
|
||||||
:key="`preliminary-${groupIndex}`"
|
:key="`preliminary-${groupIndex}`"
|
||||||
|
:data-preliminary-group="groupIndex"
|
||||||
:class="{ 'mobile-active': activePreliminaryGroup === groupIndex }"
|
:class="{ 'mobile-active': activePreliminaryGroup === groupIndex }"
|
||||||
>
|
>
|
||||||
<caption>
|
<caption>
|
||||||
@@ -322,6 +355,7 @@ const start = async () => {
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</section>
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
<div class="legacy-bracket-table-signature" hidden>
|
<div class="legacy-bracket-table-signature" hidden>
|
||||||
<table v-for="(rowCount, tableIndex) in [11, 11, 11, 10]" :key="tableIndex">
|
<table v-for="(rowCount, tableIndex) in [11, 11, 11, 10]" :key="tableIndex">
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { describe, it } from 'node:test';
|
import { describe, it } from 'node:test';
|
||||||
import { resolveTournamentStageName } from '../src/utils/tournamentStatus.ts';
|
import { resolveTournamentSectionVisibility, resolveTournamentStageName } from '../src/utils/tournamentStatus.ts';
|
||||||
|
|
||||||
void describe('tournament status labels', () => {
|
void describe('tournament status labels', () => {
|
||||||
void it('describes inactive and active tournament stages', () => {
|
void it('describes inactive and active tournament stages', () => {
|
||||||
@@ -14,4 +14,32 @@ void describe('tournament status labels', () => {
|
|||||||
assert.equal(resolveTournamentStageName(11), '상태 확인 중');
|
assert.equal(resolveTournamentStageName(11), '상태 확인 중');
|
||||||
assert.equal(resolveTournamentStageName(-1), '상태 확인 중');
|
assert.equal(resolveTournamentStageName(-1), '상태 확인 중');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
void it('reveals tournament sections only after their stage begins and retains completed results', () => {
|
||||||
|
assert.deepEqual(resolveTournamentSectionVisibility(0), {
|
||||||
|
preliminary: false,
|
||||||
|
final: false,
|
||||||
|
knockout: false,
|
||||||
|
});
|
||||||
|
assert.deepEqual(resolveTournamentSectionVisibility(1), {
|
||||||
|
preliminary: true,
|
||||||
|
final: false,
|
||||||
|
knockout: false,
|
||||||
|
});
|
||||||
|
assert.deepEqual(resolveTournamentSectionVisibility(3), {
|
||||||
|
preliminary: true,
|
||||||
|
final: true,
|
||||||
|
knockout: false,
|
||||||
|
});
|
||||||
|
assert.deepEqual(resolveTournamentSectionVisibility(5), {
|
||||||
|
preliminary: true,
|
||||||
|
final: true,
|
||||||
|
knockout: true,
|
||||||
|
});
|
||||||
|
assert.deepEqual(resolveTournamentSectionVisibility(0, 7), {
|
||||||
|
preliminary: true,
|
||||||
|
final: true,
|
||||||
|
knockout: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Generated
+15
@@ -207,6 +207,9 @@ importers:
|
|||||||
pinia:
|
pinia:
|
||||||
specifier: ^3.0.4
|
specifier: ^3.0.4
|
||||||
version: 3.0.4(typescript@6.0.3)(vue@3.5.41(typescript@6.0.3))
|
version: 3.0.4(typescript@6.0.3)(vue@3.5.41(typescript@6.0.3))
|
||||||
|
tippy.js:
|
||||||
|
specifier: 6.3.7
|
||||||
|
version: 6.3.7
|
||||||
vue:
|
vue:
|
||||||
specifier: ^3.5.26
|
specifier: ^3.5.26
|
||||||
version: 3.5.41(typescript@6.0.3)
|
version: 3.5.41(typescript@6.0.3)
|
||||||
@@ -1347,6 +1350,9 @@ packages:
|
|||||||
'@pm2/pm2-version-check@1.0.4':
|
'@pm2/pm2-version-check@1.0.4':
|
||||||
resolution: {integrity: sha512-SXsM27SGH3yTWKc2fKR4SYNxsmnvuBQ9dd6QHtEWmiZ/VqaOYPAIlS8+vMcn27YLtAEBGvNRSh3TPNvtjZgfqA==}
|
resolution: {integrity: sha512-SXsM27SGH3yTWKc2fKR4SYNxsmnvuBQ9dd6QHtEWmiZ/VqaOYPAIlS8+vMcn27YLtAEBGvNRSh3TPNvtjZgfqA==}
|
||||||
|
|
||||||
|
'@popperjs/core@2.11.8':
|
||||||
|
resolution: {integrity: sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==}
|
||||||
|
|
||||||
'@prisma/adapter-pg@7.9.1':
|
'@prisma/adapter-pg@7.9.1':
|
||||||
resolution: {integrity: sha512-Ho2RK1KanQxLNSC0sR5bpiiVep10sWPLXCcxK+KXfI/Q69TMRbiafSvLPv3V9snimX72rMCqGlyJ4sBO4lKTAw==}
|
resolution: {integrity: sha512-Ho2RK1KanQxLNSC0sR5bpiiVep10sWPLXCcxK+KXfI/Q69TMRbiafSvLPv3V9snimX72rMCqGlyJ4sBO4lKTAw==}
|
||||||
|
|
||||||
@@ -4489,6 +4495,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==}
|
resolution: {integrity: sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==}
|
||||||
engines: {node: '>=14.0.0'}
|
engines: {node: '>=14.0.0'}
|
||||||
|
|
||||||
|
tippy.js@6.3.7:
|
||||||
|
resolution: {integrity: sha512-E1d3oP2emgJ9dRQZdf3Kkn0qJgI6ZLpyS5z6ZkY1DF3kaQaBsGZsndEpHwx+eC+tYM41HaSNvNtLx8tU57FzTQ==}
|
||||||
|
|
||||||
to-regex-range@5.0.1:
|
to-regex-range@5.0.1:
|
||||||
resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
|
resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
|
||||||
engines: {node: '>=8.0'}
|
engines: {node: '>=8.0'}
|
||||||
@@ -5496,6 +5505,8 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
|
'@popperjs/core@2.11.8': {}
|
||||||
|
|
||||||
'@prisma/adapter-pg@7.9.1':
|
'@prisma/adapter-pg@7.9.1':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@prisma/driver-adapter-utils': 7.9.1
|
'@prisma/driver-adapter-utils': 7.9.1
|
||||||
@@ -8517,6 +8528,10 @@ snapshots:
|
|||||||
|
|
||||||
tinyrainbow@3.1.1: {}
|
tinyrainbow@3.1.1: {}
|
||||||
|
|
||||||
|
tippy.js@6.3.7:
|
||||||
|
dependencies:
|
||||||
|
'@popperjs/core': 2.11.8
|
||||||
|
|
||||||
to-regex-range@5.0.1:
|
to-regex-range@5.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
is-number: 7.0.0
|
is-number: 7.0.0
|
||||||
|
|||||||
Reference in New Issue
Block a user