From 0e344e4db133fe60438aed0c952f2231c3f18bf8 Mon Sep 17 00:00:00 2001 From: hided62 Date: Sun, 26 Jul 2026 04:56:00 +0000 Subject: [PATCH 1/4] test(turn): cover general cooldown boundaries --- .../general-command-differential-testing.md | 11 +- .../turn-state-differential-testing.md | 4 + .../src/turn-differential/coreCommandTrace.ts | 31 +++++ ...rnCommandGeneralMatrix.integration.test.ts | 127 ++++++++++++++++++ 4 files changed, 169 insertions(+), 4 deletions(-) diff --git a/docs/architecture/general-command-differential-testing.md b/docs/architecture/general-command-differential-testing.md index 825af69..f33f40e 100644 --- a/docs/architecture/general-command-differential-testing.md +++ b/docs/architecture/general-command-differential-testing.md @@ -10,7 +10,7 @@ 현재 ref MariaDB와 core memory를 잇는 공통 runner, 성공 경로 55개, 실행 중 확률 실패 9개, full constraint fallback 12개와 모략 확률 clamp 8개, 모략 결과값 경계 5개, 부상 경계 3개, alternative 5개와 pre-required -turn 중간 경계 6개가 구현됐다. +turn 중간 경계 6개, post-required cooldown 경계 3개가 구현됐다. 실패 9개는 내정 critical `주민선정/정착장려/상업투자/기술연구/물자조달`과 모략 `화계/선동/파괴/탈취`이며 RNG 전체 trace, semantic state delta와 실패 @@ -27,9 +27,12 @@ state delta를 비교한다. 결과값 5개는 화계 농업·상업, 선동 치 비교한다. alternative 5개는 해산·랜덤임관·무작위건국·출병의 모든 대체 분기에서 최초 명령 RNG의 연속 소비와 최종 상태를 비교한다. pre-required turn 6개는 전투태세 1/2/3턴, 내정·전투 특기 초기화 1턴, 은퇴 1턴의 -`last_turn`과 진행 로그, RNG 무소비를 비교한다. 나머지 명령별 제약 -실패·값 경계와 전체 core PostgreSQL 재조회가 완료 기준을 통과하기 전까지 -55개 명령 전체의 동적 호환 상태를 `확인`으로 올리지 않는다. +`last_turn`과 진행 로그, RNG 무소비를 비교한다. cooldown 3개는 특기 +초기화 완료 직후 `current + 60 - preReq`, 1턴 전 차단, 경계 월 허용을 +ref `next_execute` KV와 core general meta의 공통 projection으로 비교한다. +나머지 명령별 제약 실패·값 경계와 전체 core PostgreSQL 재조회가 완료 +기준을 통과하기 전까지 55개 명령 전체의 동적 호환 상태를 `확인`으로 +올리지 않는다. ## 결정 요약 diff --git a/docs/architecture/turn-state-differential-testing.md b/docs/architecture/turn-state-differential-testing.md index 5abd042..f29f430 100644 --- a/docs/architecture/turn-state-differential-testing.md +++ b/docs/architecture/turn-state-differential-testing.md @@ -206,6 +206,10 @@ instance and current consumption position. Six pre-required-turn cases cover battle-preparation terms 1 through 3 plus the first intermediate turn of both trait resets and retirement. They compare the exact intermediate `lastTurn`, progress log, zero command-RNG consumption and semantic state delta. +Three post-required cooldown cases project the legacy `next_execute` KV and +core general meta into the same world-level cooldown record. They cover the +stored `current + 60 - preReq` value, rejection one turn before availability, +and successful execution exactly at the boundary. This is not yet a claim that every command-specific constraint, clamp and persistence boundary has been dynamically compared. diff --git a/tools/integration-tests/src/turn-differential/coreCommandTrace.ts b/tools/integration-tests/src/turn-differential/coreCommandTrace.ts index bf9dfa9..0db84a0 100644 --- a/tools/integration-tests/src/turn-differential/coreCommandTrace.ts +++ b/tools/integration-tests/src/turn-differential/coreCommandTrace.ts @@ -25,6 +25,11 @@ import { type CanonicalTurnSnapshot, } from './canonical.js'; +interface GeneralCooldownSelector { + generalId: number; + actionName: string; +} + export interface TurnCommandFixtureRequest { kind: 'general' | 'nation'; actorGeneralId: number; @@ -47,6 +52,7 @@ export interface TurnCommandFixtureRequest { troops?: Array>; diplomacy?: Array>; randomFoundingCandidateCityIds?: number[]; + generalCooldowns?: Array; }; observe?: { generalIds?: number[]; @@ -54,6 +60,7 @@ export interface TurnCommandFixtureRequest { nationIds?: number[]; logAfterId?: number; messageAfterId?: number; + generalCooldowns?: GeneralCooldownSelector[]; }; } @@ -288,6 +295,19 @@ const buildWorldInput = ( const month = readNumber(referenceBefore.world, 'month', request.setup?.world?.month ?? 1); const turnTime = new Date(`${String(year).padStart(4, '0')}-${String(month).padStart(2, '0')}-01T00:00:00.000Z`); const generals = referenceBefore.generals.map((row) => buildGeneral(row, turnTime)); + const referenceGeneralCooldowns = Array.isArray(referenceBefore.world.generalCooldowns) + ? referenceBefore.world.generalCooldowns + : []; + for (const rawCooldown of referenceGeneralCooldowns) { + const cooldown = asRecord(rawCooldown); + const generalId = readNumber(cooldown, 'generalId'); + const actionName = readString(cooldown, 'actionName', ''); + const nextAvailableTurn = cooldown.nextAvailableTurn; + const general = generals.find((entry) => entry.id === generalId); + if (general && actionName && typeof nextAvailableTurn === 'number' && Number.isFinite(nextAvailableTurn)) { + general.meta[`next_execute_${actionName}`] = nextAvailableTurn; + } + } const fixtureNations = new Map((request.setup?.nations ?? []).map((row) => [readNumber(row, 'id'), row] as const)); const nations = referenceBefore.nations.map((row) => buildNation( @@ -429,6 +449,7 @@ const projectWorld = ( generalIds: Set; cityIds: Set; nationIds: Set; + generalCooldowns: GeneralCooldownSelector[]; } ): CanonicalTurnSnapshot => { const state = world.getState(); @@ -489,6 +510,15 @@ const projectWorld = ( tickMinutes: Math.max(1, Math.round(state.tickSeconds / 60)), turnTime: state.lastTurnTime.toISOString(), isUnited: readNumber(state.meta, 'isUnited'), + generalCooldowns: selector.generalCooldowns.map(({ generalId, actionName }) => { + const general = world.getGeneralById(generalId); + const raw = general?.meta[`next_execute_${actionName}`]; + return { + generalId, + actionName, + nextAvailableTurn: typeof raw === 'number' && Number.isFinite(raw) ? raw : null, + }; + }), }, generals, cities: world @@ -595,6 +625,7 @@ export const runCoreTurnCommandTrace = async ( ]), cityIds: new Set(referenceBefore.cities.map((row) => readNumber(row, 'id'))), nationIds: new Set(referenceBefore.nations.map((row) => readNumber(row, 'id'))), + generalCooldowns: request.observe?.generalCooldowns ?? [], }; const reservedTurns = new InMemoryReservedTurnStore(emptyDatabaseClient as never, { maxGeneralTurns: 10, diff --git a/tools/integration-tests/test/turnCommandGeneralMatrix.integration.test.ts b/tools/integration-tests/test/turnCommandGeneralMatrix.integration.test.ts index 7f8865a..f6a139e 100644 --- a/tools/integration-tests/test/turnCommandGeneralMatrix.integration.test.ts +++ b/tools/integration-tests/test/turnCommandGeneralMatrix.integration.test.ts @@ -931,6 +931,133 @@ integration('general command pre-required turn boundary matrix', () => { ); }); +const readGeneralCooldown = ( + snapshot: { world: Record }, + generalId: number, + actionName: string +): number | null => { + const cooldowns = Array.isArray(snapshot.world.generalCooldowns) ? snapshot.world.generalCooldowns : []; + const matched = cooldowns.find( + (entry) => + typeof entry === 'object' && + entry !== null && + (entry as Record).generalId === generalId && + (entry as Record).actionName === actionName + ); + const value = + typeof matched === 'object' && matched !== null ? (matched as Record).nextAvailableTurn : null; + return typeof value === 'number' ? value : null; +}; + +integration('general command post-required cooldown boundary matrix', () => { + it('stores the same 60-turn cooldown after domestic trait reset completion', async () => { + const request = buildRequest('che_내정특기초기화', undefined, { + specialDomestic: 'che_인덕', + lastTurn: { command: '내정 특기 초기화', term: 1 }, + }); + request.observe!.generalCooldowns = [{ generalId: 1, actionName: '내정 특기 초기화' }]; + const reference = runReferenceTurnCommandTraceRequest( + workspaceRoot!, + request as unknown as Record + ); + const core = await runCoreTurnCommandTrace(request, reference.before); + const expectedNextAvailableTurn = 190 * 12 + 1 - 1 + 60 - 1; + + expect(reference.execution.outcome).toMatchObject({ completed: true }); + expect(core.execution.outcome).toMatchObject({ + requestedAction: 'che_내정특기초기화', + actionKey: 'che_내정특기초기화', + usedFallback: false, + }); + expect(readGeneralCooldown(reference.after, 1, '내정 특기 초기화')).toBe(expectedNextAvailableTurn); + expect(readGeneralCooldown(core.after, 1, '내정 특기 초기화')).toBe(expectedNextAvailableTurn); + expect(core.rng).toEqual(reference.rng); + expect( + compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, { + ignoredPathPatterns: ignoredLifecyclePaths, + }) + ).toEqual([]); + }, 120_000); + + it('blocks domestic trait reset one turn before the cooldown boundary', async () => { + const currentYearMonth = 190 * 12 + 1 - 1; + const request = buildRequest('che_내정특기초기화', undefined, { + specialDomestic: 'che_인덕', + lastTurn: { command: '내정 특기 초기화', term: 1 }, + }); + request.setup!.generalCooldowns = [ + { + generalId: 1, + actionName: '내정 특기 초기화', + nextAvailableTurn: currentYearMonth + 1, + }, + ]; + request.observe!.generalCooldowns = [{ generalId: 1, actionName: '내정 특기 초기화' }]; + const reference = runReferenceTurnCommandTraceRequest( + workspaceRoot!, + request as unknown as Record + ); + const core = await runCoreTurnCommandTrace(request, reference.before); + + expect(readGeneralCooldown(reference.before, 1, '내정 특기 초기화')).toBe(currentYearMonth + 1); + expect(readGeneralCooldown(core.before, 1, '내정 특기 초기화')).toBe(currentYearMonth + 1); + expect(reference.execution.outcome).toMatchObject({ completed: false }); + expect(core.execution.outcome).toMatchObject({ + requestedAction: 'che_내정특기초기화', + actionKey: '휴식', + usedFallback: true, + blockedReason: '1턴 더 기다려야 합니다', + }); + expect(reference.after.logs.some((entry) => String(entry.text).includes('1턴 더 기다려야 합니다'))).toBe(true); + expect(core.after.logs.some((entry) => String(entry.text).includes('1턴 더 기다려야 합니다'))).toBe(true); + expect(readGeneralCooldown(reference.after, 1, '내정 특기 초기화')).toBe(currentYearMonth + 1); + expect(readGeneralCooldown(core.after, 1, '내정 특기 초기화')).toBe(currentYearMonth + 1); + expect(core.rng).toEqual(reference.rng); + expect( + compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, { + ignoredPathPatterns: ignoredLifecyclePaths, + }) + ).toEqual([]); + }, 120_000); + + it('allows war trait reset exactly at the cooldown boundary', async () => { + const currentYearMonth = 190 * 12 + 1 - 1; + const request = buildRequest('che_전투특기초기화', undefined, { + specialWar: 'che_귀병', + lastTurn: { command: '전투 특기 초기화', term: 1 }, + }); + request.setup!.generalCooldowns = [ + { + generalId: 1, + actionName: '전투 특기 초기화', + nextAvailableTurn: currentYearMonth, + }, + ]; + request.observe!.generalCooldowns = [{ generalId: 1, actionName: '전투 특기 초기화' }]; + const reference = runReferenceTurnCommandTraceRequest( + workspaceRoot!, + request as unknown as Record + ); + const core = await runCoreTurnCommandTrace(request, reference.before); + const expectedNextAvailableTurn = currentYearMonth + 60 - 1; + + expect(reference.execution.outcome).toMatchObject({ completed: true }); + expect(core.execution.outcome).toMatchObject({ + requestedAction: 'che_전투특기초기화', + actionKey: 'che_전투특기초기화', + usedFallback: false, + }); + expect(readGeneralCooldown(reference.after, 1, '전투 특기 초기화')).toBe(expectedNextAvailableTurn); + expect(readGeneralCooldown(core.after, 1, '전투 특기 초기화')).toBe(expectedNextAvailableTurn); + expect(core.rng).toEqual(reference.rng); + expect( + compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, { + ignoredPathPatterns: ignoredLifecyclePaths, + }) + ).toEqual([]); + }, 120_000); +}); + type GeneralConstraintCase = { name: string; action: string; From 430193ef8865b7672f43580cd106f940dac05463 Mon Sep 17 00:00:00 2001 From: hided62 Date: Sun, 26 Jul 2026 05:04:46 +0000 Subject: [PATCH 2/4] feat: complete inheritance management flow --- app/game-api/src/router/inherit/index.ts | 111 +- app/game-frontend/src/views/InheritView.vue | 993 ++++++++++-------- packages/logic/src/inheritance/inheritBuff.ts | 21 +- 3 files changed, 642 insertions(+), 483 deletions(-) diff --git a/app/game-api/src/router/inherit/index.ts b/app/game-api/src/router/inherit/index.ts index 9397806..e072927 100644 --- a/app/game-api/src/router/inherit/index.ts +++ b/app/game-api/src/router/inherit/index.ts @@ -3,7 +3,14 @@ import { z } from 'zod'; import { authedProcedure, router } from '../../trpc.js'; import { asNumber, asRecord, parseJson, LiteHashDRBG } from '@sammo-ts/common'; -import { loadWarTraitModules, WarTraitLoader, WAR_TRAIT_KEYS, isWarTraitKey } from '@sammo-ts/logic'; +import { + ItemLoader, + isItemKey, + loadWarTraitModules, + WarTraitLoader, + WAR_TRAIT_KEYS, + isWarTraitKey, +} from '@sammo-ts/logic'; import type { InheritBuffType } from '@sammo-ts/logic'; import { appendInheritanceLog, @@ -23,8 +30,8 @@ const BUFF_KEYS: InheritBuffType[] = [ 'warAvoidRatio', 'warCriticalRatio', 'warMagicTrialProb', - 'success', - 'fail', + 'domesticSuccessProb', + 'domesticFailProb', 'warAvoidRatioOppose', 'warCriticalRatioOppose', 'warMagicTrialProbOppose', @@ -34,8 +41,8 @@ const BUFF_LABELS: Record = { warAvoidRatio: '회피 확률 증가', warCriticalRatio: '필살 확률 증가', warMagicTrialProb: '전투계략 시도 확률 증가', - success: '내정 성공률 증가', - fail: '내정 실패율 감소', + domesticSuccessProb: '내정 성공률 증가', + domesticFailProb: '내정 실패율 감소', warAvoidRatioOppose: '상대 회피 확률 감소', warCriticalRatioOppose: '상대 필살 확률 감소', warMagicTrialProbOppose: '상대 전투계략 시도 확률 감소', @@ -58,6 +65,37 @@ const parseBuffRecord = (raw: unknown): Record => { const serializeBuffRecord = (buff: Record): string => JSON.stringify(buff); +const readBuffLevel = (buff: Record, key: InheritBuffType): number => { + const compatibilityKey = key === 'domesticSuccessProb' ? 'success' : key === 'domesticFailProb' ? 'fail' : null; + return Math.max(0, Math.min(5, Math.floor(buff[key] ?? (compatibilityKey ? buff[compatibilityKey] : 0) ?? 0))); +}; + +const loadAvailableUniqueItems = async (worldState: WorldStateRow) => { + const configuredItems = asRecord(asRecord(worldState.config).const).allItems; + const enabledKeys: Array[0]> = []; + for (const entries of Object.values(asRecord(configuredItems))) { + for (const [key, amount] of Object.entries(asRecord(entries))) { + if (asNumber(amount, 0) !== 0 && isItemKey(key)) { + enabledKeys.push(key); + } + } + } + + const loader = new ItemLoader(); + const items = await Promise.all( + [...new Set(enabledKeys)].map(async (key) => { + const item = await loader.load(key); + return { + key, + name: item.name, + rawName: item.rawName, + info: item.info ?? '', + }; + }) + ); + return items.sort((left, right) => left.name.localeCompare(right.name, 'ko')); +}; + const resolveWorld = async (ctx: { db: { worldState: { findFirst: () => Promise } } }) => { const worldState = await ctx.db.worldState.findFirst(); if (!worldState || typeof worldState !== 'object') { @@ -199,6 +237,9 @@ export const inheritRouter = router({ special2Code: true, meta: true, turnTime: true, + leadership: true, + strength: true, + intel: true, }, }); @@ -219,7 +260,7 @@ export const inheritRouter = router({ const inheritConst = resolveInheritConstants(worldState); const buffState = parseBuffRecord(asRecord(general.meta).inheritBuff); const buffLevels = BUFF_KEYS.reduce>((acc, key) => { - acc[key] = Math.max(0, Math.min(5, Math.floor(buffState[key] ?? 0))); + acc[key] = readBuffLevel(buffState, key); return acc; }, {}); @@ -240,11 +281,14 @@ export const inheritRouter = router({ info: trait.info ?? '', })); - const others = await ctx.db.general.findMany({ - where: { id: { not: general.id }, userId: { not: null } }, - select: { id: true, name: true }, - orderBy: { id: 'asc' }, - }); + const [others, availableUnique] = await Promise.all([ + ctx.db.general.findMany({ + where: { id: { not: general.id }, npcState: { lt: 2 }, userId: { not: null } }, + select: { id: true, name: true }, + orderBy: { id: 'asc' }, + }), + loadAvailableUniqueItems(worldState), + ]); return { items, @@ -260,10 +304,16 @@ export const inheritRouter = router({ resetTurnTime: resetTurnLevel, }, availableSpecialWar: warSpecials, + availableUnique, availableTargetGenerals: others, turnTimeZones: buildTurnTimeZoneList(Math.max(1, Math.round(worldState.tickSeconds / 60))), isUnited, currentSpecialWar: general.special2Code ?? 'None', + currentStat: { + leadership: general.leadership, + strength: general.strength, + intel: general.intel, + }, }; }), getLogs: authedProcedure @@ -285,7 +335,7 @@ export const inheritRouter = router({ }, orderBy: { id: 'desc' }, take: 30, - select: { id: true, year: true, month: true, text: true }, + select: { id: true, year: true, month: true, text: true, createdAt: true }, }); return logs; }), @@ -318,7 +368,7 @@ export const inheritRouter = router({ } const buff = parseBuffRecord(asRecord(general.meta).inheritBuff); - const prevLevel = Math.max(0, Math.min(5, Math.floor(buff[input.type] ?? 0))); + const prevLevel = readBuffLevel(buff, input.type); if (input.level === prevLevel) { throw new TRPCError({ code: 'BAD_REQUEST', message: '이미 구입했습니다.' }); } @@ -417,7 +467,12 @@ export const inheritRouter = router({ }, }); - await setInheritancePoint(ctx.db, userId, 'previous', currentPoint - inheritConst.inheritSpecificSpecialPoint); + await setInheritancePoint( + ctx.db, + userId, + 'previous', + currentPoint - inheritConst.inheritSpecificSpecialPoint + ); await appendInheritanceLog( ctx.db, userId, @@ -460,7 +515,8 @@ export const inheritRouter = router({ } const meta = asRecord(general.meta); - const prevList = parseJson(typeof meta.prev_types_special2 === 'string' ? meta.prev_types_special2 : null) ?? []; + const prevList = + parseJson(typeof meta.prev_types_special2 === 'string' ? meta.prev_types_special2 : null) ?? []; prevList.push(general.special2Code); await patchGeneral(ctx, general.id, { @@ -473,7 +529,13 @@ export const inheritRouter = router({ }); await setInheritancePoint(ctx.db, userId, 'previous', currentPoint - cost); - await appendInheritanceLog(ctx.db, userId, worldState.currentYear, worldState.currentMonth, `${cost} 포인트로 전투 특기 초기화`); + await appendInheritanceLog( + ctx.db, + userId, + worldState.currentYear, + worldState.currentMonth, + `${cost} 포인트로 전투 특기 초기화` + ); return { ok: true }; }), resetTurnTime: authedProcedure.mutation(async ({ ctx }) => { @@ -624,9 +686,7 @@ export const inheritRouter = router({ const finalBonus = bonusSum === 0 ? buildRandomBonus( - new LiteHashDRBG( - `${asRecord(worldState.meta).hiddenSeed ?? 'inherit'}:ResetStat:${userId}` - ), + new LiteHashDRBG(`${asRecord(worldState.meta).hiddenSeed ?? 'inherit'}:ResetStat:${userId}`), [input.leadership, input.strength, input.intel] ) : (bonus as [number, number, number]); @@ -674,9 +734,7 @@ export const inheritRouter = router({ if (seasonValue !== null) { const userState = await readUserStateMeta(ctx.db, userId); const resetSeasons = readResetSeasons(userState); - const nextSeasons = resetSeasons.includes(seasonValue) - ? resetSeasons - : [...resetSeasons, seasonValue]; + const nextSeasons = resetSeasons.includes(seasonValue) ? resetSeasons : [...resetSeasons, seasonValue]; await writeUserStateMeta(ctx.db, userId, { ...userState, last_stat_reset: nextSeasons, @@ -709,7 +767,10 @@ export const inheritRouter = router({ } const meta = asRecord(general.meta); if (meta.inheritRandomUnique !== undefined && meta.inheritRandomUnique !== null) { - throw new TRPCError({ code: 'BAD_REQUEST', message: '이미 구입 명령을 내렸습니다. 다음 턴까지 기다려주세요.' }); + throw new TRPCError({ + code: 'BAD_REQUEST', + message: '이미 구입 명령을 내렸습니다. 다음 턴까지 기다려주세요.', + }); } await patchGeneral(ctx, general.id, { @@ -803,7 +864,9 @@ export const inheritRouter = router({ throw new TRPCError({ code: 'BAD_REQUEST', message: '자신의 정보는 확인할 수 없습니다.' }); } - const ownerName = typeof asRecord(target.meta).ownerName === 'string' ? (asRecord(target.meta).ownerName as string) : target.userId; + const rawOwnerName = asRecord(target.meta).ownerName; + const ownerName = + typeof rawOwnerName === 'string' && rawOwnerName.trim().length > 0 ? rawOwnerName : '알수없음'; await setInheritancePoint(ctx.db, userId, 'previous', currentPoint - inheritConst.inheritCheckOwnerPoint); await appendInheritanceLog( diff --git a/app/game-frontend/src/views/InheritView.vue b/app/game-frontend/src/views/InheritView.vue index 8e8261d..47d478e 100644 --- a/app/game-frontend/src/views/InheritView.vue +++ b/app/game-frontend/src/views/InheritView.vue @@ -1,7 +1,5 @@ diff --git a/packages/logic/src/inheritance/inheritBuff.ts b/packages/logic/src/inheritance/inheritBuff.ts index 27df95d..f80f3f2 100644 --- a/packages/logic/src/inheritance/inheritBuff.ts +++ b/packages/logic/src/inheritance/inheritBuff.ts @@ -7,8 +7,8 @@ export type InheritBuffType = | 'warAvoidRatio' | 'warCriticalRatio' | 'warMagicTrialProb' - | 'success' - | 'fail' + | 'domesticSuccessProb' + | 'domesticFailProb' | 'warAvoidRatioOppose' | 'warCriticalRatioOppose' | 'warMagicTrialProbOppose'; @@ -25,7 +25,8 @@ const DOMESTIC_TARGETS = new Set([ ]); const readBuffLevel = (buff: Record, key: InheritBuffType): number => { - const raw = buff[key]; + const compatibilityKey = key === 'domesticSuccessProb' ? 'success' : key === 'domesticFailProb' ? 'fail' : null; + const raw = buff[key] ?? (compatibilityKey ? buff[compatibilityKey] : undefined); if (typeof raw !== 'number' || !Number.isFinite(raw)) { return 0; } @@ -40,7 +41,9 @@ const parseInheritBuff = (value: unknown): Record => { return asRecord(value); }; -const resolveBuffRecord = (context: { general: { meta: Record; triggerState: { meta: Record } } }): Record => { +const resolveBuffRecord = (context: { + general: { meta: Record; triggerState: { meta: Record } }; +}): Record => { const fromTrigger = parseInheritBuff(context.general.triggerState.meta.inheritBuff); if (Object.keys(fromTrigger).length > 0) { return fromTrigger; @@ -58,11 +61,11 @@ const applyDomesticBuff = ( return value; } if (varType === 'success') { - const level = readBuffLevel(buff, 'success'); + const level = readBuffLevel(buff, 'domesticSuccessProb'); return value + level * 0.01; } if (varType === 'fail') { - const level = readBuffLevel(buff, 'fail'); + const level = readBuffLevel(buff, 'domesticFailProb'); return value - level * 0.01; } return value; @@ -84,11 +87,7 @@ const applyWarBuff = (buff: Record, statName: WarStatName, valu return value; }; -const applyOpposeWarBuff = ( - buff: Record, - statName: WarStatName, - value: number | [number, number] -) => { +const applyOpposeWarBuff = (buff: Record, statName: WarStatName, value: number | [number, number]) => { if (typeof value !== 'number') { return value; } From 7d41bfa95771180ff3113727f57052d90a9a34b4 Mon Sep 17 00:00:00 2001 From: hided62 Date: Sun, 26 Jul 2026 05:04:51 +0000 Subject: [PATCH 3/4] test: verify inheritance parity and permissions --- app/game-api/test/inheritRouter.test.ts | 266 ++++++++++++++++++ docs/frontend-legacy-parity.md | 33 +-- packages/logic/test/inheritBuff.test.ts | 67 +++++ .../inheritance-management.spec.ts | 237 ++++++++++++++++ .../playwright.config.mjs | 1 + 5 files changed, 588 insertions(+), 16 deletions(-) create mode 100644 app/game-api/test/inheritRouter.test.ts create mode 100644 packages/logic/test/inheritBuff.test.ts create mode 100644 tools/frontend-legacy-parity/inheritance-management.spec.ts diff --git a/app/game-api/test/inheritRouter.test.ts b/app/game-api/test/inheritRouter.test.ts new file mode 100644 index 0000000..da9f6e5 --- /dev/null +++ b/app/game-api/test/inheritRouter.test.ts @@ -0,0 +1,266 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken'; +import type { RedisConnector } from '@sammo-ts/infra'; + +import { RedisAccessTokenStore } from '../src/auth/accessTokenStore.js'; +import { InMemoryFlushStore } from '../src/auth/flushStore.js'; +import type { DatabaseClient, GameApiContext, GeneralRow } from '../src/context.js'; +import type { TurnDaemonTransport } from '../src/daemon/transport.js'; +import { appRouter } from '../src/router.js'; + +const buildGeneral = (overrides: Partial = {}): GeneralRow => ({ + id: 7, + userId: 'user-1', + name: '유비', + nationId: 1, + cityId: 1, + troopId: 0, + npcState: 0, + affinity: null, + bornYear: 180, + deadYear: 300, + picture: null, + imageServer: 0, + leadership: 70, + strength: 45, + intel: 85, + injury: 0, + experience: 0, + dedication: 0, + officerLevel: 1, + gold: 1000, + rice: 1000, + crew: 0, + crewTypeId: 0, + train: 0, + atmos: 0, + weaponCode: 'None', + bookCode: 'None', + horseCode: 'None', + itemCode: 'None', + turnTime: new Date('2026-07-26T00:00:00Z'), + recentWarTime: null, + age: 20, + startAge: 20, + personalCode: 'None', + specialCode: 'None', + special2Code: 'che_선봉', + lastTurn: {}, + meta: {}, + penalty: {}, + createdAt: new Date('2026-07-26T00:00:00Z'), + updatedAt: new Date('2026-07-26T00:00:00Z'), + ...overrides, +}); + +const buildAuth = (userId = 'user-1'): GameSessionTokenPayload => ({ + version: 1, + profile: 'che:default', + issuedAt: '2026-07-26T00:00:00.000Z', + expiresAt: '2026-07-27T00:00:00.000Z', + sessionId: `session-${userId}`, + user: { + id: userId, + username: userId, + displayName: userId, + roles: [], + }, + sanctions: {}, +}); + +const worldState = { + id: 1, + scenarioCode: 'default', + currentYear: 200, + currentMonth: 4, + tickSeconds: 3600, + config: { + const: { + availableSpecialWar: ['che_선봉'], + allItems: { + weapon: { + che_무기_12_칠성검: 1, + che_무기_01_단도: 0, + }, + }, + }, + }, + meta: { hiddenSeed: 'test-seed', isUnited: 0, season: 1 }, + updatedAt: new Date('2026-07-26T00:00:00Z'), +}; + +const buildContext = (options: { + auth?: GameSessionTokenPayload | null; + general?: GeneralRow | null; + target?: GeneralRow | null; + inheritancePoint?: number; +}) => { + const auth = options.auth === undefined ? buildAuth() : options.auth; + const general = options.general === undefined ? buildGeneral() : options.general; + const target = + options.target === undefined + ? buildGeneral({ id: 8, userId: 'user-2', name: '조조', meta: { ownerName: '위유저' } }) + : options.target; + const requestCommand = vi.fn(async (command: { type: string; generalId: number }) => ({ + type: command.type, + ok: true, + generalId: command.generalId, + })); + const pointUpsert = vi.fn(async () => ({})); + const logCreate = vi.fn(async () => ({})); + const findMany = vi.fn(async () => (target ? [{ id: target.id, name: target.name }] : [])); + const db = { + $queryRaw: vi.fn(async () => [{ value: options.inheritancePoint ?? 10_000 }]), + worldState: { + findFirst: vi.fn(async () => worldState), + }, + general: { + findFirst: vi.fn(async ({ where }: { where: { userId: string } }) => + general?.userId === where.userId ? general : null + ), + findMany, + findUnique: vi.fn(async ({ where }: { where: { id: number } }) => + target?.id === where.id ? target : null + ), + }, + inheritancePoint: { + upsert: pointUpsert, + }, + inheritanceLog: { + create: logCreate, + findMany: vi.fn(async () => []), + }, + inheritanceUserState: { + findUnique: vi.fn(async () => null), + upsert: vi.fn(async () => ({})), + }, + }; + const accessTokenStore = new RedisAccessTokenStore( + { + get: async () => null, + set: async () => null, + }, + 'che:default' + ); + const context: GameApiContext = { + db: db as unknown as DatabaseClient, + redis: {} as RedisConnector['client'], + turnDaemon: { requestCommand } as unknown as TurnDaemonTransport, + battleSim: {} as GameApiContext['battleSim'], + profile: { id: 'che', scenario: 'default', name: 'che:default' }, + auth, + uploadDir: 'uploads', + uploadPath: '/uploads', + uploadPublicUrl: null, + accessTokenStore, + flushStore: new InMemoryFlushStore(), + gameTokenSecret: 'test-secret', + }; + return { context, requestCommand, pointUpsert, logCreate, findMany }; +}; + +describe('inherit router actor and permission boundaries', () => { + it('rejects unauthenticated status and mutations', async () => { + const fixture = buildContext({ auth: null }); + const caller = appRouter.createCaller(fixture.context); + + await expect(caller.inherit.getStatus()).rejects.toMatchObject({ code: 'UNAUTHORIZED' }); + await expect(caller.inherit.buyHiddenBuff({ type: 'warAvoidRatio', level: 1 })).rejects.toMatchObject({ + code: 'UNAUTHORIZED', + }); + expect(fixture.requestCommand).not.toHaveBeenCalled(); + }); + + it('builds status only from the authenticated user general and filters target generals like ref', async () => { + const fixture = buildContext({}); + const status = await appRouter.createCaller(fixture.context).inherit.getStatus(); + + expect(status.currentStat).toEqual({ leadership: 70, strength: 45, intel: 85 }); + expect(status.availableTargetGenerals).toEqual([{ id: 8, name: '조조' }]); + expect(status.availableUnique).toEqual([ + expect.objectContaining({ key: 'che_무기_12_칠성검', rawName: '칠성검' }), + ]); + expect(status.buffLevels).toHaveProperty('domesticSuccessProb', 0); + expect(fixture.findMany).toHaveBeenCalledWith({ + where: { id: { not: 7 }, npcState: { lt: 2 }, userId: { not: null } }, + select: { id: true, name: true }, + orderBy: { id: 'asc' }, + }); + }); + + it('does not dispatch or charge when the authenticated user owns no general', async () => { + const fixture = buildContext({ + auth: buildAuth('user-2'), + general: buildGeneral({ userId: 'user-1' }), + }); + + await expect( + appRouter.createCaller(fixture.context).inherit.buyHiddenBuff({ + type: 'domesticSuccessProb', + level: 1, + }) + ).rejects.toMatchObject({ + code: 'PRECONDITION_FAILED', + message: '장수가 존재하지 않습니다.', + }); + expect(fixture.requestCommand).not.toHaveBeenCalled(); + expect(fixture.pointUpsert).not.toHaveBeenCalled(); + }); + + it('mutates only the authenticated user general and inheritance balance', async () => { + const fixture = buildContext({ inheritancePoint: 1000 }); + + await expect( + appRouter.createCaller(fixture.context).inherit.buyHiddenBuff({ + type: 'domesticSuccessProb', + level: 1, + }) + ).resolves.toEqual({ ok: true, remainPoint: 800 }); + + expect(fixture.requestCommand).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'patchGeneral', + generalId: 7, + patch: expect.objectContaining({ + meta: expect.objectContaining({ + inheritBuff: JSON.stringify({ domesticSuccessProb: 1 }), + }), + }), + }) + ); + expect(fixture.pointUpsert).toHaveBeenCalledWith( + expect.objectContaining({ + where: { userId_key: { userId: 'user-1', key: 'previous' } }, + update: { value: 800 }, + }) + ); + }); + + it('reveals a target owner to the caller without using the caller general id from input', async () => { + const fixture = buildContext({ inheritancePoint: 1500 }); + + await expect( + appRouter.createCaller(fixture.context).inherit.checkOwner({ targetGeneralId: 8 }) + ).resolves.toEqual({ + ok: true, + ownerName: '위유저', + targetName: '조조', + }); + expect(fixture.pointUpsert).toHaveBeenCalledWith( + expect.objectContaining({ + where: { userId_key: { userId: 'user-1', key: 'previous' } }, + update: { value: 500 }, + }) + ); + expect(fixture.logCreate).toHaveBeenCalledWith({ + data: { + userId: 'user-1', + year: 200, + month: 4, + text: '1000 포인트로 장수 소유자 확인', + }, + }); + expect(fixture.requestCommand).not.toHaveBeenCalled(); + }); +}); diff --git a/docs/frontend-legacy-parity.md b/docs/frontend-legacy-parity.md index 0162c50..42a4c80 100644 --- a/docs/frontend-legacy-parity.md +++ b/docs/frontend-legacy-parity.md @@ -43,22 +43,23 @@ storage, route guards, and image loading. ## Enforced contracts -| Screen | Ref entry point | Current automated contract | -| -------------------- | ---------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | -| gateway login/status | `index.php` | 450/700px desktop widths, mobile collapse, Pretendard title, real login mutation/session storage, actual seasonal map asset | -| gateway account | `i_entrance/user_info.php` | 550px × minimum 575px panel, 14px Pretendard, three legacy textures, success and API-error password flows | -| gateway OAuth join | `oauth_kakao/join.php` | 700px centered registration card, Kakao exchange/register success, retained-input API error, hover/focus | -| game login hand-off | unauthenticated `hwe/index.php` redirect | `/che/login` delegates to `/gateway/` | -| troop | `hwe/v_troop.php` | existing `app/game-frontend/e2e/troop.spec.ts` desktop/mobile geometry and interaction suite | -| hall of fame | `hwe/a_hallOfFame.php` | 500/1000px container, 100px ranking cells, 64px natural image, walnut/green textures, Pretendard, close-button focus | -| yearbook | `hwe/v_history.php` | 1000px 700+300 desktop grid, 500px stacked grid, month navigation, legacy textures, success and API-error flows | -| nation betting | `hwe/v_nationBetting.php` | 1000px/6-column desktop and 500px/3-column mobile grids, picked card style, payout table, success and retained-form error | -| public NPC list | `hwe/a_npcList.php` | 1000px 12-column table with Chromium-expanded legacy widths, NPC color, eight sorts, retained table/sort after API error | -| survey | `hwe/v_vote.php`, `hwe/ts/PageVote.vue` | 1000/500px fixed container, blue title/green table textures, list/detail/results/comments, selection/focus/hover, submit and retained-selection API error | -| nation personnel | `hwe/b_myBossInfo.php` | fixed 1000px document at both viewports, chief icon columns, officer/permission/city/kick controls, role redaction | -| nation finance | `hwe/v_nationStratFinan.php` | 1000/500px at the legacy 940px breakpoint, exact diplomacy grid, policy controls, role gating and failed-mutation rollback | -| tournament | `hwe/b_tournament.php` | fixed 2000px canvas, 16×125px bracket, eight 250px group tables, walnut texture, 1024px overflow, hover/focus | -| tournament betting | `hwe/b_betting.php` | fixed 1120px canvas, 16×70px candidates, four 280px rank tables, exact title/button geometry, retained selection on error | +| Screen | Ref entry point | Current automated contract | +| -------------------- | ---------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| gateway login/status | `index.php` | 450/700px desktop widths, mobile collapse, Pretendard title, real login mutation/session storage, actual seasonal map asset | +| gateway account | `i_entrance/user_info.php` | 550px × minimum 575px panel, 14px Pretendard, three legacy textures, success and API-error password flows | +| gateway OAuth join | `oauth_kakao/join.php` | 700px centered registration card, Kakao exchange/register success, retained-input API error, hover/focus | +| game login hand-off | unauthenticated `hwe/index.php` redirect | `/che/login` delegates to `/gateway/` | +| troop | `hwe/v_troop.php` | existing `app/game-frontend/e2e/troop.spec.ts` desktop/mobile geometry and interaction suite | +| hall of fame | `hwe/a_hallOfFame.php` | 500/1000px container, 100px ranking cells, 64px natural image, walnut/green textures, Pretendard, close-button focus | +| yearbook | `hwe/v_history.php` | 1000px 700+300 desktop grid, 500px stacked grid, month navigation, legacy textures, success and API-error flows | +| inheritance | `hwe/v_inheritPoint.php` | 1000px 3-column desktop and 500px stacked layout, walnut/green textures, Pretendard 14px, scenario unique selector, buff purchase success and retained-input API error | +| nation betting | `hwe/v_nationBetting.php` | 1000px/6-column desktop and 500px/3-column mobile grids, picked card style, payout table, success and retained-form error | +| public NPC list | `hwe/a_npcList.php` | 1000px 12-column table with Chromium-expanded legacy widths, NPC color, eight sorts, retained table/sort after API error | +| survey | `hwe/v_vote.php`, `hwe/ts/PageVote.vue` | 1000/500px fixed container, blue title/green table textures, list/detail/results/comments, selection/focus/hover, submit and retained-selection API error | +| nation personnel | `hwe/b_myBossInfo.php` | fixed 1000px document at both viewports, chief icon columns, officer/permission/city/kick controls, role redaction | +| nation finance | `hwe/v_nationStratFinan.php` | 1000/500px at the legacy 940px breakpoint, exact diplomacy grid, policy controls, role gating and failed-mutation rollback | +| tournament | `hwe/b_tournament.php` | fixed 2000px canvas, 16×125px bracket, eight 250px group tables, walnut texture, 1024px overflow, hover/focus | +| tournament betting | `hwe/b_betting.php` | fixed 1120px canvas, 16×70px candidates, four 280px rank tables, exact title/button geometry, retained selection on error | The global game baseline is black, white, Pretendard 14px. Legacy texture helpers intentionally follow `common.orig.css`: `bg0` is walnut, `bg1` is diff --git a/packages/logic/test/inheritBuff.test.ts b/packages/logic/test/inheritBuff.test.ts new file mode 100644 index 0000000..ce6fb69 --- /dev/null +++ b/packages/logic/test/inheritBuff.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from 'vitest'; + +import type { General } from '../src/domain/entities.js'; +import { createInheritBuffModules } from '../src/inheritance/inheritBuff.js'; +import { GeneralActionPipeline } from '../src/triggers/general-action.js'; + +const buildGeneral = (inheritBuff: Record): General => ({ + id: 1, + name: 'Tester', + nationId: 1, + cityId: 1, + troopId: 0, + stats: { leadership: 70, strength: 60, intelligence: 50 }, + experience: 0, + dedication: 0, + officerLevel: 0, + role: { + personality: null, + specialDomestic: null, + specialWar: null, + items: { horse: null, weapon: null, book: null, item: null }, + }, + injury: 0, + gold: 0, + rice: 0, + crew: 0, + crewTypeId: 0, + train: 0, + atmos: 0, + age: 20, + npcState: 0, + triggerState: { + flags: {}, + counters: {}, + modifiers: {}, + meta: {}, + }, + meta: { killturn: 24, inheritBuff: JSON.stringify(inheritBuff) }, +}); + +describe('inheritance buff legacy keys', () => { + it('applies the canonical legacy domestic buff names', () => { + const pipeline = new GeneralActionPipeline([createInheritBuffModules().general]); + const context = { + general: buildGeneral({ + domesticSuccessProb: 3, + domesticFailProb: 2, + }), + }; + + expect(pipeline.onCalcDomestic(context, '농업', 'success', 0.5)).toBeCloseTo(0.53); + expect(pipeline.onCalcDomestic(context, '상업', 'fail', 0.2)).toBeCloseTo(0.18); + }); + + it('continues to read the earlier core success and fail aliases', () => { + const pipeline = new GeneralActionPipeline([createInheritBuffModules().general]); + const context = { + general: buildGeneral({ + success: 2, + fail: 1, + }), + }; + + expect(pipeline.onCalcDomestic(context, '치안', 'success', 0.5)).toBeCloseTo(0.52); + expect(pipeline.onCalcDomestic(context, '성벽', 'fail', 0.2)).toBeCloseTo(0.19); + }); +}); diff --git a/tools/frontend-legacy-parity/inheritance-management.spec.ts b/tools/frontend-legacy-parity/inheritance-management.spec.ts new file mode 100644 index 0000000..6907362 --- /dev/null +++ b/tools/frontend-legacy-parity/inheritance-management.spec.ts @@ -0,0 +1,237 @@ +import { expect, test, type Page, type Route } from '@playwright/test'; +import { readFile } from 'node:fs/promises'; +import { dirname, extname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../..'); +const imageRoot = resolve(repositoryRoot, '../../image'); +const artifactRoot = process.env.FRONTEND_PARITY_ARTIFACT_DIR; + +const response = (data: unknown) => ({ result: { data } }); + +const operations = (route: Route): string[] => { + const pathname = new URL(route.request().url()).pathname; + return decodeURIComponent(pathname.slice(pathname.lastIndexOf('/trpc/') + 6)).split(','); +}; + +const installImages = async (page: Page): Promise => { + await page.route('**/image/**', async (route) => { + const relative = decodeURIComponent(new URL(route.request().url()).pathname).replace(/^\/image\//, ''); + for (const candidate of [ + resolve(imageRoot, relative), + resolve(imageRoot, 'game', relative), + resolve(imageRoot, 'icons', '22.jpg'), + ]) { + try { + const body = await readFile(candidate); + await route.fulfill({ + status: 200, + contentType: extname(candidate).toLowerCase() === '.png' ? 'image/png' : 'image/jpeg', + body, + }); + return; + } catch { + // 다음 공개 image root 후보를 확인한다. + } + } + await route.abort('failed'); + }); +}; + +const statusFixture = { + items: { + previous: 12_000, + lived_month: 240, + max_domestic_critical: 80, + active_action: 35, + combat: 150, + sabotage: 60, + dex: 42, + unifier: 0, + tournament: 30, + betting: 20, + max_belong: 8, + }, + totalPoint: 12_665, + inheritConst: { + minMonthToAllowInheritItem: 4, + inheritBornSpecialPoint: 6000, + inheritBornTurntimePoint: 2500, + inheritBornCityPoint: 1000, + inheritBornStatPoint: 1000, + inheritItemUniqueMinPoint: 5000, + inheritItemRandomPoint: 3000, + inheritBuffPoints: [0, 200, 600, 1200, 2000, 3000], + inheritSpecificSpecialPoint: 4000, + inheritResetAttrPointBase: [1000, 1000, 2000, 3000], + inheritCheckOwnerPoint: 1000, + }, + buffLevels: { + warAvoidRatio: 0, + warCriticalRatio: 1, + warMagicTrialProb: 0, + domesticSuccessProb: 0, + domesticFailProb: 0, + warAvoidRatioOppose: 0, + warCriticalRatioOppose: 0, + warMagicTrialProbOppose: 0, + }, + resetCosts: { resetSpecialWar: 1000, resetTurnTime: 1000 }, + resetLevels: { resetSpecialWar: 0, resetTurnTime: 0 }, + availableSpecialWar: [{ key: 'che_선봉', name: '선봉', info: '공격에 유리합니다.' }], + availableUnique: [ + { + key: 'che_무기_12_칠성검', + name: '칠성검(+12)', + rawName: '칠성검', + info: '무력을 올려주는 유니크 무기입니다.', + }, + ], + availableTargetGenerals: [{ id: 8, name: '조조' }], + turnTimeZones: ['00:00'], + isUnited: false, + currentSpecialWar: 'che_선봉', + currentStat: { leadership: 70, strength: 45, intel: 85 }, +}; + +const installFixture = async (page: Page, options: { failBuff?: boolean } = {}) => { + let buffMutationCount = 0; + await installImages(page); + await page.addInitScript(() => { + window.localStorage.setItem('sammo-game-token', 'ga_inherit-visual-token'); + window.localStorage.setItem('sammo-game-profile', 'che'); + }); + await page.route('**/che/api/trpc/**', async (route) => { + const names = operations(route); + if (options.failBuff && names.includes('inherit.buyHiddenBuff')) { + await route.fulfill({ + status: 500, + contentType: 'application/json', + body: JSON.stringify({ error: { message: '의도한 유산 구입 오류' } }), + }); + return; + } + const result = names.map((name) => { + if (name === 'inherit.getStatus') return response(statusFixture); + if (name === 'lobby.info') { + return response({ + profile: { id: 'che', scenario: 'default', name: '체섭' }, + world: { year: 200, month: 4 }, + myGeneral: { id: 7, name: '유비', nationId: 1 }, + }); + } + if (name === 'inherit.getLogs') { + return response([ + { + id: 2, + year: 200, + month: 4, + text: '1000 포인트로 장수 소유자 확인', + createdAt: '2026-07-26T00:00:00.000Z', + }, + ]); + } + if (name === 'join.getConfig') { + return response({ rules: { stat: { total: 200, min: 10, max: 100 } } }); + } + if (name === 'inherit.buyHiddenBuff') { + buffMutationCount += 1; + return response({ ok: true, remainPoint: 11_800 }); + } + throw new Error(`Unhandled inheritance fixture operation: ${name}`); + }); + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(result), + }); + }); + return { buffMutationCount: () => buffMutationCount }; +}; + +test.describe('inheritance management legacy parity', () => { + test('matches the ref 1000px grid and computed styles on desktop and mobile', async ({ page }) => { + await installFixture(page); + await page.setViewportSize({ width: 1280, height: 900 }); + await page.goto('http://127.0.0.1:15102/che/inherit'); + await expect(page.locator('#container')).toBeVisible(); + await expect(page.locator('#specific-unique')).toHaveValue('che_무기_12_칠성검'); + + const desktop = await page.evaluate(() => { + const rect = (selector: string) => { + const box = document.querySelector(selector)!.getBoundingClientRect(); + return { x: box.x, width: box.width }; + }; + const container = getComputedStyle(document.querySelector('#container')!); + const title = getComputedStyle(document.querySelector('.section-title')!); + const button = getComputedStyle(document.querySelector('.buy-button')!); + return { + container: rect('#container'), + firstPoint: rect('#inherit_sum'), + fontFamily: container.fontFamily, + fontSize: container.fontSize, + backgroundImage: container.backgroundImage, + titleBackgroundImage: title.backgroundImage, + buttonBackground: button.backgroundColor, + }; + }); + + expect(desktop.container.width).toBe(1000); + expect(desktop.container.x).toBe(140); + expect(desktop.firstPoint.width).toBeCloseTo(327.3, 0); + expect(desktop.fontFamily).toContain('Pretendard'); + expect(desktop.fontSize).toBe('14px'); + expect(desktop.backgroundImage).toContain('back_walnut.jpg'); + expect(desktop.titleBackgroundImage).toContain('back_green.jpg'); + + const buyButton = page.locator('.buy-button').first(); + const beforeHover = await buyButton.evaluate((element) => getComputedStyle(element).backgroundColor); + await buyButton.hover(); + const afterHover = await buyButton.evaluate((element) => getComputedStyle(element).backgroundColor); + expect(afterHover).not.toBe(beforeHover); + await buyButton.focus(); + await expect(buyButton).toBeFocused(); + + if (artifactRoot) { + await page.screenshot({ path: resolve(artifactRoot, 'inherit-core-desktop.png'), fullPage: true }); + } + + await page.setViewportSize({ width: 500, height: 900 }); + await page.reload(); + await expect(page.locator('#container')).toBeVisible(); + const mobile = await page.evaluate(() => { + const container = document.querySelector('#container')!.getBoundingClientRect(); + const first = document.querySelector('#inherit_sum')!.getBoundingClientRect(); + const second = document.querySelector('#inherit_previous')!.getBoundingClientRect(); + return { + containerWidth: container.width, + firstWidth: first.width, + stacked: second.y > first.y, + }; + }); + expect(mobile.containerWidth).toBe(500); + expect(mobile.firstWidth).toBeCloseTo(482, 0); + expect(mobile.stacked).toBe(true); + }); + + test('submits a legacy buff purchase and refreshes status and logs', async ({ page }) => { + const fixture = await installFixture(page); + page.on('dialog', (dialog) => dialog.accept()); + await page.goto('http://127.0.0.1:15102/che/inherit'); + await page.locator('#buff-warAvoidRatio').fill('1'); + await page.locator('#buff-warAvoidRatio').locator('xpath=../..').getByRole('button', { name: '구입' }).click(); + await expect.poll(fixture.buffMutationCount).toBe(1); + await expect(page.locator('#inherit_previous_value')).toHaveValue('12,000'); + }); + + test('keeps controls usable and renders an API mutation error', async ({ page }) => { + await installFixture(page, { failBuff: true }); + page.on('dialog', (dialog) => dialog.accept()); + await page.goto('http://127.0.0.1:15102/che/inherit'); + await page.locator('#buff-warAvoidRatio').fill('1'); + await page.locator('#buff-warAvoidRatio').locator('xpath=../..').getByRole('button', { name: '구입' }).click(); + await expect(page.locator('[role="alert"]')).toBeVisible(); + await expect(page.locator('#buff-warAvoidRatio')).toHaveValue('1'); + await expect(page.locator('#buff-warAvoidRatio')).toBeEnabled(); + }); +}); diff --git a/tools/frontend-legacy-parity/playwright.config.mjs b/tools/frontend-legacy-parity/playwright.config.mjs index eece2a1..fc57f96 100644 --- a/tools/frontend-legacy-parity/playwright.config.mjs +++ b/tools/frontend-legacy-parity/playwright.config.mjs @@ -13,6 +13,7 @@ export default defineConfig({ 'public-gaps.spec.ts', 'instant-diplomacy-message.spec.ts', 'tournament-betting.spec.ts', + 'inheritance-management.spec.ts', ], fullyParallel: false, workers: 1, From 75b40f30bf0e72cf7674e507548849b29e20ccc1 Mon Sep 17 00:00:00 2001 From: hided62 Date: Sun, 26 Jul 2026 05:06:27 +0000 Subject: [PATCH 4/4] test: honor inheritance parity ports --- .../frontend-legacy-parity/inheritance-management.spec.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tools/frontend-legacy-parity/inheritance-management.spec.ts b/tools/frontend-legacy-parity/inheritance-management.spec.ts index 6907362..a68beb8 100644 --- a/tools/frontend-legacy-parity/inheritance-management.spec.ts +++ b/tools/frontend-legacy-parity/inheritance-management.spec.ts @@ -6,6 +6,7 @@ import { fileURLToPath } from 'node:url'; const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../..'); const imageRoot = resolve(repositoryRoot, '../../image'); const artifactRoot = process.env.FRONTEND_PARITY_ARTIFACT_DIR; +const gameUrl = `http://127.0.0.1:${process.env.FRONTEND_PARITY_GAME_PORT ?? '15102'}/che/inherit`; const response = (data: unknown) => ({ result: { data } }); @@ -153,7 +154,7 @@ test.describe('inheritance management legacy parity', () => { test('matches the ref 1000px grid and computed styles on desktop and mobile', async ({ page }) => { await installFixture(page); await page.setViewportSize({ width: 1280, height: 900 }); - await page.goto('http://127.0.0.1:15102/che/inherit'); + await page.goto(gameUrl); await expect(page.locator('#container')).toBeVisible(); await expect(page.locator('#specific-unique')).toHaveValue('che_무기_12_칠성검'); @@ -217,7 +218,7 @@ test.describe('inheritance management legacy parity', () => { test('submits a legacy buff purchase and refreshes status and logs', async ({ page }) => { const fixture = await installFixture(page); page.on('dialog', (dialog) => dialog.accept()); - await page.goto('http://127.0.0.1:15102/che/inherit'); + await page.goto(gameUrl); await page.locator('#buff-warAvoidRatio').fill('1'); await page.locator('#buff-warAvoidRatio').locator('xpath=../..').getByRole('button', { name: '구입' }).click(); await expect.poll(fixture.buffMutationCount).toBe(1); @@ -227,7 +228,7 @@ test.describe('inheritance management legacy parity', () => { test('keeps controls usable and renders an API mutation error', async ({ page }) => { await installFixture(page, { failBuff: true }); page.on('dialog', (dialog) => dialog.accept()); - await page.goto('http://127.0.0.1:15102/che/inherit'); + await page.goto(gameUrl); await page.locator('#buff-warAvoidRatio').fill('1'); await page.locator('#buff-warAvoidRatio').locator('xpath=../..').getByRole('button', { name: '구입' }).click(); await expect(page.locator('[role="alert"]')).toBeVisible();