From b8e9e6b5137e7e4c05eb075309b122e2b9edec69 Mon Sep 17 00:00:00 2001 From: hided62 Date: Sat, 22 Aug 2026 05:03:25 +0000 Subject: [PATCH 1/2] =?UTF-8?q?fix:=20=EB=B9=88=20=ED=8A=B9=EA=B8=B0?= =?UTF-8?q?=EC=9D=98=20=ED=9A=8D=EB=93=9D=20=EC=98=88=EC=A0=95=20=EB=82=98?= =?UTF-8?q?=EC=9D=B4=EB=A5=BC=20=ED=91=9C=EC=8B=9C=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 메인 장수 read model에 내정·전투 특기 예정 나이를 투영하고, Ref와 같은 현재 나이 다음 해 하한을 적용한다. API 회귀와 Chromium 데스크톱·모바일 검증을 추가한다. --- app/game-api/src/router/general/index.ts | 4 ++ .../test/inGameMenuPermissions.test.ts | 30 +++++++++++++ app/game-frontend/e2e/inGameMenus.spec.ts | 43 +++++++++++++++++++ .../src/components/main/GeneralBasicCard.vue | 23 +++++++--- 4 files changed, 94 insertions(+), 6 deletions(-) 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/inGameMenus.spec.ts b/app/game-frontend/e2e/inGameMenus.spec.ts index 7b51451d..2f2f0c37 100644 --- a/app/game-frontend/e2e/inGameMenus.spec.ts +++ b/app/game-frontend/e2e/inGameMenus.spec.ts @@ -77,6 +77,7 @@ type FixtureState = { joinConfig?: Record; createGeneralInputs?: Array>; mainTraits?: { personal: string; specialDomestic: string; specialWar: string }; + mainTraitAges?: { specialDomestic: number; specialWar: number }; richMyInfo?: boolean; hiddenSeedLogText?: string; recentRecords?: { @@ -130,6 +131,7 @@ const myGeneral = (state: FixtureState) => ({ } : null, traits: state.mainTraits ?? { personal: '-', specialDomestic: '-', specialWar: '-' }, + traitAges: state.mainTraitAges ?? { specialDomestic: 31, specialWar: 31 }, traitInfo: state.richMyInfo ? { personal: '부상당할 확률이 감소합니다.', @@ -725,6 +727,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/src/components/main/GeneralBasicCard.vue b/app/game-frontend/src/components/main/GeneralBasicCard.vue index 4fa8125e..64ff9d7c 100644 --- a/app/game-frontend/src/components/main/GeneralBasicCard.vue +++ b/app/game-frontend/src/components/main/GeneralBasicCard.vue @@ -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}`; }); @@ -379,19 +390,19 @@ const specialText = computed(() => { 특기 - {{ props.general.traits?.specialDomestic ?? '-' }} + {{ specialDomesticText }} / - {{ props.general.traits?.specialWar ?? '-' }} + {{ specialWarText }} From 5549f0c1e82f4be66a04010282d3c4dcd141c2f5 Mon Sep 17 00:00:00 2001 From: hided62 Date: Sat, 22 Aug 2026 05:11:50 +0000 Subject: [PATCH 2/2] =?UTF-8?q?feat:=20profile=20=EC=83=88=20=EB=B2=84?= =?UTF-8?q?=EC=A0=84=20=EC=95=88=EB=82=B4=20toast=EB=A5=BC=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 배포 bundle과 같은 commit의 정적 버전 문서를 생성하고, 열린 탭이 DB mutation 없이 변경을 감지하도록 한다. 동일 버전은 tab session에서 한 번만 안내하며 강제 새로고침은 하지 않는다. --- README.md | 3 +- .../e2e/deploymentVersionNotice.spec.ts | 64 +++++++++++ app/game-frontend/e2e/playwright.config.mjs | 1 + app/game-frontend/src/App.vue | 3 + .../composables/useDeploymentVersionNotice.ts | 45 ++++++++ .../src/config/deploymentVersion.ts | 78 +++++++++++++ .../test/deploymentVersionNotice.test.ts | 105 ++++++++++++++++++ app/game-frontend/test/viteConfig.test.ts | 11 ++ app/game-frontend/vite.config.ts | 18 ++- .../test/profileFrontendMaterializer.test.ts | 4 + docs/architecture/runtime.md | 10 ++ docs/release-operations.md | 5 + 12 files changed, 343 insertions(+), 4 deletions(-) create mode 100644 app/game-frontend/e2e/deploymentVersionNotice.spec.ts create mode 100644 app/game-frontend/src/composables/useDeploymentVersionNotice.ts create mode 100644 app/game-frontend/src/config/deploymentVersion.ts create mode 100644 app/game-frontend/test/deploymentVersionNotice.test.ts 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-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/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 @@