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

This commit is contained in:
2026-08-22 05:19:47 +00:00
16 changed files with 437 additions and 10 deletions
+4
View File
@@ -553,6 +553,10 @@ export const getGeneralContext = async (ctx: GameApiContext) => {
specialDomestic: resolveTraitDisplayName(general.specialCode, domesticNames),
specialWar: resolveTraitDisplayName(general.special2Code, warNames),
},
traitAges: {
specialDomestic: readNumber(metaRecord.specage, 0),
specialWar: readNumber(metaRecord.specage2, 0),
},
traitInfo: {
personal: personalityNames.get(general.personalCode)?.info ?? '',
specialDomestic: domesticNames.get(general.specialCode)?.info ?? '',
@@ -510,6 +510,7 @@ describe('in-game my information ownership', () => {
personalCode: 'che_안전',
specialCode: 'che_상재',
special2Code: 'che_신산',
meta: { specage: 31, specage2: 35 },
}),
});
@@ -520,6 +521,10 @@ describe('in-game my information ownership', () => {
specialDomestic: '상재',
specialWar: '신산',
},
traitAges: {
specialDomestic: 31,
specialWar: 35,
},
traitInfo: {
personal: '사기 -5, 징·모병 비용 -20%',
specialDomestic: '[내정] 상업 투자 : 기본 보정 +10%, 성공률 +10%p, 비용 -20%',
@@ -543,6 +548,31 @@ describe('in-game my information ownership', () => {
expect(fixture.db.nation.findUnique).not.toHaveBeenCalled();
});
it('returns the scheduled acquisition ages when the owned general has no domestic or war trait', async () => {
const fixture = createContext({
me: buildGeneral({
age: 30,
specialCode: 'None',
special2Code: 'None',
meta: { specage: 35, specage2: 29 },
}),
});
await expect(appRouter.createCaller(fixture.context).general.me()).resolves.toMatchObject({
general: {
age: 30,
traits: {
specialDomestic: '-',
specialWar: '-',
},
traitAges: {
specialDomestic: 35,
specialWar: 29,
},
},
});
});
it('reads legacy top-level settings and dispatches only the session-owned general', async () => {
const requestCommand = vi.fn(async () => ({ type: 'setMySetting', ok: true, generalId: 7 }));
const fixture = createContext({ requestCommand });
@@ -0,0 +1,64 @@
import { expect, test, type Page } from '@playwright/test';
import { gamePath } from './gameTestPaths.js';
const currentCommitSha = process.env.PLAYWRIGHT_BUILD_COMMIT_SHA ?? '0123456789abcdef0123456789abcdef01234567';
const nextCommitSha = '89abcdef0123456789abcdef0123456789abcdef';
const noticeMessage = '새 버전이 준비되었습니다. 새로고침하면 변경사항이 반영됩니다.';
const installVersionFixture = async (page: Page) => {
let availableCommitSha = currentCommitSha;
let requests = 0;
await page.route('**/deployment-version.json*', async (route) => {
requests += 1;
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ commitSha: availableCommitSha }),
});
});
return {
deployNextVersion: () => {
availableCommitSha = nextCommitSha;
},
requestCount: () => requests,
};
};
for (const viewport of [
{ name: 'desktop', width: 1280, height: 800 },
{ name: 'mobile', width: 390, height: 844 },
]) {
test(`shows one quiet update toast without forcing reload on ${viewport.name}`, async ({ page }) => {
await page.setViewportSize({ width: viewport.width, height: viewport.height });
const fixture = await installVersionFixture(page);
await page.goto(gamePath('/version-notice-fixture'));
await expect(page.getByRole('heading', { name: 'Not Found' })).toBeVisible();
await expect.poll(fixture.requestCount).toBeGreaterThan(0);
await page.evaluate(() => {
Object.assign(window, { __versionNoticePageMarker: 'kept' });
});
fixture.deployNextVersion();
await expect
.poll(async () => {
await page.evaluate(() => window.dispatchEvent(new Event('online')));
return fixture.requestCount();
})
.toBeGreaterThan(1);
const toast = page.getByTestId('game-toast').filter({ hasText: noticeMessage });
await expect(toast).toBeVisible();
await expect(toast).toHaveAttribute('data-feedback-kind', 'info');
await expect(toast).toHaveCSS('transform', 'none');
const box = await toast.boundingBox();
expect(box).not.toBeNull();
expect(box!.x).toBeGreaterThanOrEqual(0);
expect(box!.y).toBeGreaterThanOrEqual(0);
expect(box!.x + box!.width).toBeLessThanOrEqual(viewport.width);
expect(box!.y + box!.height).toBeLessThanOrEqual(viewport.height);
expect(await page.evaluate(() => Reflect.get(window, '__versionNoticePageMarker'))).toBe('kept');
await page.getByRole('button', { name: '알림 닫기' }).click();
await page.evaluate(() => window.dispatchEvent(new Event('online')));
await expect(toast).toHaveCount(0);
});
}
+43
View File
@@ -84,6 +84,7 @@ type FixtureState = {
joinConfig?: Record<string, unknown>;
createGeneralInputs?: Array<Record<string, unknown>>;
mainTraits?: { personal: string; specialDomestic: string; specialWar: string };
mainTraitAges?: { specialDomestic: number; specialWar: number };
richMyInfo?: boolean;
hiddenSeedLogText?: string;
recentRecords?: {
@@ -137,6 +138,7 @@ const myGeneral = (state: FixtureState) => ({
}
: null,
traits: state.mainTraits ?? { personal: '-', specialDomestic: '-', specialWar: '-' },
traitAges: state.mainTraitAges ?? { specialDomestic: 31, specialWar: 31 },
traitInfo: state.richMyInfo
? {
personal: '부상당할 확률이 감소합니다.',
@@ -732,6 +734,47 @@ test('재야 메인은 국가 틀과 성격·특기 표기명을 Chromium에 표
await persistParityArtifact(page, 'main-neutral-trait-display', geometry);
});
test('메인 장수 정보는 없는 내정·전투 특기의 Ref 획득 나이를 Chromium에 표시한다', async ({ page }) => {
const state: FixtureState = {
permission: 'member',
myset: 0,
mainTraits: { personal: '안전', specialDomestic: '-', specialWar: '-' },
mainTraitAges: { specialDomestic: 35, specialWar: 29 },
settingMutations: [],
accessPages: [],
};
await install(page, state);
for (const viewport of [
{ width: 1000, height: 900 },
{ width: 390, height: 844 },
]) {
await page.setViewportSize(viewport);
await page.goto('');
const specialValue = page.locator('.general-card .special-value');
await expect(specialValue).toHaveText(/35\s*\/\s*31/u);
await expect(specialValue).toHaveAttribute('aria-label', '35세 / 31세');
const geometry = await specialValue.evaluate((element) => {
const rect = element.getBoundingClientRect();
const documentWidth = document.documentElement.scrollWidth;
return {
text: element.textContent?.replace(/\s+/gu, ' ').trim(),
left: rect.left,
right: rect.right,
width: rect.width,
documentWidth,
viewportWidth: window.innerWidth,
};
});
expect(geometry.width).toBeGreaterThan(0);
expect(geometry.left).toBeGreaterThanOrEqual(0);
expect(geometry.right).toBeLessThanOrEqual(geometry.documentWidth);
expect(geometry.documentWidth).toBe(Math.max(viewport.width, 500));
await persistParityArtifact(page, `main-speciality-age-${viewport.width}`, geometry);
}
});
test('메인 카드의 국가·수도·관직·계급·병종은 Ref 출력명으로 표시된다', async ({ page }) => {
const state: FixtureState = {
permission: 'head',
@@ -42,6 +42,7 @@ export default defineConfig({
'session-auth.spec.ts',
'npcPossession.spec.ts',
'joinLayout.spec.ts',
'deploymentVersionNotice.spec.ts',
],
fullyParallel: false,
workers: 1,
+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;
},
};
};
@@ -0,0 +1,105 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
createDeploymentVersionChecker,
deploymentVersionAssetSource,
parseDeploymentCommitSha,
} from '../src/config/deploymentVersion.ts';
const currentCommitSha = '0123456789abcdef0123456789abcdef01234567';
const nextCommitSha = '89abcdef0123456789abcdef0123456789abcdef';
const laterCommitSha = 'fedcba9876543210fedcba9876543210fedcba98';
const jsonResponse = (commitSha: string): Response =>
new Response(JSON.stringify({ commitSha }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
void test('accepts only full hexadecimal deployment commit values', () => {
assert.equal(parseDeploymentCommitSha({ commitSha: nextCommitSha.toUpperCase() }), nextCommitSha);
assert.equal(parseDeploymentCommitSha({ commitSha: 'main' }), null);
assert.equal(parseDeploymentCommitSha({ version: nextCommitSha }), null);
assert.equal(parseDeploymentCommitSha(null), null);
});
void test('exposes only the read-only build commit in the deployment version asset', () => {
assert.deepEqual(JSON.parse(deploymentVersionAssetSource(currentCommitSha)), { commitSha: currentCommitSha });
});
void test('notifies once per available version and bypasses browser caches', async () => {
const requests: Array<{ url: string; init?: RequestInit }> = [];
const stored = new Map<string, string>();
const notifications: string[] = [];
let availableCommitSha = currentCommitSha;
const checker = createDeploymentVersionChecker({
currentCommitSha,
versionUrl: '/che/deployment-version.json',
now: () => 1234,
storage: {
getItem: (key) => stored.get(key) ?? null,
setItem: (key, value) => void stored.set(key, value),
},
fetchVersion: async (url, init) => {
requests.push({ url: String(url), init });
return jsonResponse(availableCommitSha);
},
onVersionChanged: (commitSha) => notifications.push(commitSha),
});
await checker.check();
availableCommitSha = nextCommitSha;
await checker.check();
await checker.check();
availableCommitSha = laterCommitSha;
await checker.check();
assert.deepEqual(notifications, [nextCommitSha, laterCommitSha]);
assert.equal(requests.every(({ url }) => url === '/che/deployment-version.json?t=1234'), true);
assert.equal(requests.every(({ init }) => init?.cache === 'no-store'), true);
assert.equal(requests.every(({ init }) => new Headers(init?.headers).get('Cache-Control') === 'no-cache'), true);
});
void test('shares the once-only notice within the current tab session', async () => {
const stored = new Map<string, string>();
const storage = {
getItem: (key: string) => stored.get(key) ?? null,
setItem: (key: string, value: string) => void stored.set(key, value),
};
let notifications = 0;
const options = {
currentCommitSha,
versionUrl: '/hwe/deployment-version.json',
storage,
fetchVersion: async () => jsonResponse(nextCommitSha),
onVersionChanged: () => notifications++,
};
await createDeploymentVersionChecker(options).check();
await createDeploymentVersionChecker(options).check();
assert.equal(notifications, 1);
});
void test('ignores unavailable or malformed version documents without disrupting the page', async () => {
let notifications = 0;
const unavailable = createDeploymentVersionChecker({
currentCommitSha,
versionUrl: '/che/deployment-version.json',
fetchVersion: async () => {
throw new Error('offline');
},
onVersionChanged: () => notifications++,
});
const malformed = createDeploymentVersionChecker({
currentCommitSha,
versionUrl: '/che/deployment-version.json',
fetchVersion: async () => new Response('{', { status: 200 }),
onVersionChanged: () => notifications++,
});
await unavailable.check();
await malformed.check();
assert.equal(notifications, 0);
});
+11
View File
@@ -48,6 +48,17 @@ void describe('game frontend Vite config', () => {
loaded?.config.define?.['import.meta.env.VITE_BUILD_COMMIT_SHA'],
JSON.stringify(commitSha.toLowerCase())
);
assert.equal(
loaded?.config.plugins?.some(
(plugin) =>
plugin !== null &&
typeof plugin === 'object' &&
!Array.isArray(plugin) &&
'name' in plugin &&
plugin.name === 'sammo-deployment-version'
),
true
);
} finally {
if (previousCommitSha === undefined) delete process.env.VITE_BUILD_COMMIT_SHA;
else process.env.VITE_BUILD_COMMIT_SHA = previousCommitSha;
+15 -3
View File
@@ -1,9 +1,10 @@
import { defineConfig, loadEnv } from 'vite';
import { defineConfig, loadEnv, type Plugin } from 'vite';
import vue from '@vitejs/plugin-vue';
import tailwindcss from '@tailwindcss/vite';
import { execFileSync } from 'node:child_process';
import path from 'path';
import { mergeViteEnv } from './src/config/viteEnv';
import { deploymentVersionAssetSource } from './src/config/deploymentVersion.ts';
import { mergeViteEnv } from './src/config/viteEnv.ts';
const fullCommitShaPattern = /^[0-9a-f]{40,64}$/iu;
@@ -24,6 +25,17 @@ export const resolveBuildCommitSha = (explicitSha: string | undefined, repositor
}
};
export const createDeploymentVersionPlugin = (buildCommitSha: string): Plugin => ({
name: 'sammo-deployment-version',
generateBundle() {
this.emitFile({
type: 'asset',
fileName: 'deployment-version.json',
source: deploymentVersionAssetSource(buildCommitSha),
});
},
});
const normalizeBasePath = (value: string | undefined): string => {
const pathValue = (value ?? '/').trim();
if (!pathValue || pathValue === '/') {
@@ -50,7 +62,7 @@ export default defineConfig(({ mode }) => {
const buildCommitSha = resolveBuildCommitSha(env.VITE_BUILD_COMMIT_SHA, path.resolve(import.meta.dirname, '../..'));
return {
base: normalizeBasePath(env.VITE_APP_BASE_PATH),
plugins: [vue(), tailwindcss()],
plugins: [vue(), tailwindcss(), createDeploymentVersionPlugin(buildCommitSha)],
define: {
'import.meta.env.VITE_BUILD_COMMIT_SHA': JSON.stringify(buildCommitSha),
},
@@ -23,11 +23,15 @@ describe('profile frontend materializer', () => {
await mkdir(source, { recursive: true });
await mkdir(target, { recursive: true });
await writeFile(path.join(source, 'index.html'), 'new release');
await writeFile(path.join(source, 'deployment-version.json'), '{"commitSha":"new"}\n');
await writeFile(path.join(target, 'index.html'), 'old release');
await execFileAsync(process.execPath, [materializer, 'che:2'], { cwd: workspaceRoot });
await expect(readFile(path.join(target, 'index.html'), 'utf8')).resolves.toBe('new release');
await expect(readFile(path.join(target, 'deployment-version.json'), 'utf8')).resolves.toBe(
'{"commitSha":"new"}\n'
);
});
it('keeps an existing artifact untouched when the cached build is missing', async () => {