diff --git a/README.md b/README.md index 43b5ad73..db4c9008 100644 --- a/README.md +++ b/README.md @@ -85,7 +85,8 @@ Node.js는 `.nvmrc`에서 24.x로 고정합니다. 의존성 설치와 검증에 `package.json`과 `pnpm-lock.yaml`을 함께 사용해 주세요. 모든 workspace package는 내부 전용(`private`)이므로 manifest의 `0.0.0`은 배포 버전이 아닙니다. 배포 source는 full Git commit으로 고정하고, 실험 릴리스 같은 milestone은 annotated Git tag로 -식별합니다. +식별합니다. Profile frontend는 같은 commit의 정적 버전 문서를 주기적으로 확인하고, +현재 열린 bundle과 달라졌을 때만 강제 reload 없이 공용 toast로 한 번 안내합니다. ## 개발 환경 diff --git a/app/game-api/src/router/general/index.ts b/app/game-api/src/router/general/index.ts index 23e6d3c1..a09371d8 100644 --- a/app/game-api/src/router/general/index.ts +++ b/app/game-api/src/router/general/index.ts @@ -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 ?? '', diff --git a/app/game-api/test/inGameMenuPermissions.test.ts b/app/game-api/test/inGameMenuPermissions.test.ts index 9cd5752f..32cad590 100644 --- a/app/game-api/test/inGameMenuPermissions.test.ts +++ b/app/game-api/test/inGameMenuPermissions.test.ts @@ -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 }); diff --git a/app/game-frontend/e2e/deploymentVersionNotice.spec.ts b/app/game-frontend/e2e/deploymentVersionNotice.spec.ts new file mode 100644 index 00000000..f8a40e43 --- /dev/null +++ b/app/game-frontend/e2e/deploymentVersionNotice.spec.ts @@ -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); + }); +} diff --git a/app/game-frontend/e2e/inGameMenus.spec.ts b/app/game-frontend/e2e/inGameMenus.spec.ts index 959529c1..4b7294d0 100644 --- a/app/game-frontend/e2e/inGameMenus.spec.ts +++ b/app/game-frontend/e2e/inGameMenus.spec.ts @@ -84,6 +84,7 @@ type FixtureState = { joinConfig?: Record; createGeneralInputs?: Array>; 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', diff --git a/app/game-frontend/e2e/playwright.config.mjs b/app/game-frontend/e2e/playwright.config.mjs index 55766660..5f422500 100644 --- a/app/game-frontend/e2e/playwright.config.mjs +++ b/app/game-frontend/e2e/playwright.config.mjs @@ -42,6 +42,7 @@ export default defineConfig({ 'session-auth.spec.ts', 'npcPossession.spec.ts', 'joinLayout.spec.ts', + 'deploymentVersionNotice.spec.ts', ], fullyParallel: false, workers: 1, diff --git a/app/game-frontend/src/App.vue b/app/game-frontend/src/App.vue index 6baf484f..eae06717 100644 --- a/app/game-frontend/src/App.vue +++ b/app/game-frontend/src/App.vue @@ -1,6 +1,9 @@