merge: 최신 main을 토너먼트 조별 순위 카드에 통합한다

This commit is contained in:
2026-08-22 05:19:47 +00:00
16 changed files with 437 additions and 10 deletions
+3
View File
@@ -1,6 +1,9 @@
<script setup lang="ts">
import { RouterView } from 'vue-router';
import GameFeedbackLayer from './components/ui/GameFeedbackLayer.vue';
import { useDeploymentVersionNotice } from './composables/useDeploymentVersionNotice';
useDeploymentVersionNotice();
</script>
<template>
@@ -99,6 +99,7 @@ export interface GeneralBasicCardData {
crewTypeName?: string;
crewTypeInfo?: CrewTypeDisplayInfo | null;
traits?: { personal: string; specialWar: string; specialDomestic: string };
traitAges?: { specialWar: number; specialDomestic: number };
traitInfo?: { personal: string; specialWar: string; specialDomestic: string };
progression?: GeneralProgression;
itemNames?: ItemDisplayNames;
@@ -226,9 +227,19 @@ const displayDefence = computed(() => {
});
const displayKillTurn = computed(() => props.general?.killTurn ?? props.killTurn);
const displayRemainingMinutes = computed(() => props.general?.remainingMinutes ?? props.remainingMinutes);
const resolveSpecialDisplayName = (kind: 'specialDomestic' | 'specialWar') => {
const general = props.general;
if (!general) return '-';
const traitName = general.traits?.[kind];
if (traitName && traitName !== '-') return traitName;
const scheduledAge = general.traitAges?.[kind];
if (general.age === undefined || scheduledAge === undefined) return '-';
return `${Math.max(general.age + 1, scheduledAge)}`;
};
const specialDomesticText = computed(() => resolveSpecialDisplayName('specialDomestic'));
const specialWarText = computed(() => resolveSpecialDisplayName('specialWar'));
const specialText = computed(() => {
const traits = props.general?.traits;
return traits ? `${traits.specialDomestic || '-'} / ${traits.specialWar || '-'}` : '-';
return `${specialDomesticText.value} / ${specialWarText.value}`;
});
</script>
@@ -379,19 +390,19 @@ const specialText = computed(() => {
<span class="cell-label">특기</span>
<strong class="special-value" :aria-label="specialText">
<RichTooltip
:title="`내정특기 · ${props.general.traits?.specialDomestic ?? '-'}`"
:title="`내정특기 · ${specialDomesticText}`"
:description="props.general.traitInfo?.specialDomestic"
test-id="special-domestic"
>
{{ props.general.traits?.specialDomestic ?? '-' }}
{{ specialDomesticText }}
</RichTooltip>
/
<RichTooltip
:title="`전투특기 · ${props.general.traits?.specialWar ?? '-'}`"
:title="`전투특기 · ${specialWarText}`"
:description="props.general.traitInfo?.specialWar"
test-id="special-war"
>
{{ props.general.traits?.specialWar ?? '-' }}
{{ specialWarText }}
</RichTooltip>
</strong>
@@ -0,0 +1,45 @@
import { onBeforeUnmount, onMounted } from 'vue';
import { createDeploymentVersionChecker } from '../config/deploymentVersion';
import { useGameFeedback } from './useGameFeedback';
const pollIntervalMs = 60_000;
const noticeMessage = '새 버전이 준비되었습니다. 새로고침하면 변경사항이 반영됩니다.';
const resolveSessionStorage = (): Pick<Storage, 'getItem' | 'setItem'> | undefined => {
try {
return window.sessionStorage;
} catch {
return undefined;
}
};
export const useDeploymentVersionNotice = (): void => {
const currentCommitSha = import.meta.env.VITE_BUILD_COMMIT_SHA?.trim() ?? '';
const versionUrl = `${import.meta.env.BASE_URL}deployment-version.json`;
const { info: showInfoToast } = useGameFeedback();
const checker = createDeploymentVersionChecker({
currentCommitSha,
versionUrl,
storage: resolveSessionStorage(),
onVersionChanged: () => showInfoToast(noticeMessage),
});
let pollTimer: ReturnType<typeof setInterval> | null = null;
const checkWhenVisible = (): void => {
if (document.visibilityState === 'visible') void checker.check();
};
onMounted(() => {
void checker.check();
pollTimer = setInterval(checkWhenVisible, pollIntervalMs);
document.addEventListener('visibilitychange', checkWhenVisible);
window.addEventListener('online', checkWhenVisible);
});
onBeforeUnmount(() => {
if (pollTimer) clearInterval(pollTimer);
pollTimer = null;
document.removeEventListener('visibilitychange', checkWhenVisible);
window.removeEventListener('online', checkWhenVisible);
});
};
@@ -0,0 +1,78 @@
const fullCommitShaPattern = /^[0-9a-f]{40,64}$/iu;
type VersionStorage = Pick<Storage, 'getItem' | 'setItem'>;
export type DeploymentVersionCheckerOptions = {
currentCommitSha: string;
versionUrl: string;
fetchVersion?: typeof fetch;
storage?: VersionStorage;
now?: () => number;
onVersionChanged: (availableCommitSha: string) => void;
};
export const deploymentVersionAssetSource = (buildCommitSha: string): string =>
`${JSON.stringify({ commitSha: buildCommitSha })}\n`;
export const parseDeploymentCommitSha = (payload: unknown): string | null => {
if (!payload || typeof payload !== 'object' || !('commitSha' in payload)) return null;
const commitSha = String(payload.commitSha).trim().toLowerCase();
return fullCommitShaPattern.test(commitSha) ? commitSha : null;
};
const notificationStorageKey = (versionUrl: string, availableCommitSha: string): string =>
`sammo:deployment-version-notice:${versionUrl}:${availableCommitSha}`;
export const createDeploymentVersionChecker = (options: DeploymentVersionCheckerOptions) => {
const currentCommitSha = options.currentCommitSha.trim().toLowerCase();
const fetchVersion = options.fetchVersion ?? fetch;
const now = options.now ?? Date.now;
let inFlight: Promise<void> | null = null;
let lastNotifiedCommitSha: string | null = null;
const wasNotified = (key: string): boolean => {
try {
return options.storage?.getItem(key) === '1';
} catch {
return false;
}
};
const rememberNotification = (key: string): void => {
try {
options.storage?.setItem(key, '1');
} catch {
// Session storage can be unavailable under restrictive browser policies.
}
};
const run = async (): Promise<void> => {
if (!fullCommitShaPattern.test(currentCommitSha)) return;
const separator = options.versionUrl.includes('?') ? '&' : '?';
const response = await fetchVersion(`${options.versionUrl}${separator}t=${now()}`, {
cache: 'no-store',
headers: { 'Cache-Control': 'no-cache' },
});
if (!response.ok) return;
const availableCommitSha = parseDeploymentCommitSha(await response.json());
if (!availableCommitSha || availableCommitSha === currentCommitSha) return;
const storageKey = notificationStorageKey(options.versionUrl, availableCommitSha);
if (lastNotifiedCommitSha === availableCommitSha || wasNotified(storageKey)) return;
lastNotifiedCommitSha = availableCommitSha;
rememberNotification(storageKey);
options.onVersionChanged(availableCommitSha);
};
return {
check: (): Promise<void> => {
if (inFlight) return inFlight;
inFlight = run()
.catch(() => undefined)
.finally(() => {
inFlight = null;
});
return inFlight;
},
};
};