From 430193ef8865b7672f43580cd106f940dac05463 Mon Sep 17 00:00:00 2001 From: hided62 Date: Sun, 26 Jul 2026 05:04:46 +0000 Subject: [PATCH 01/19] 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 02/19] 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 03/19] 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(); From b92774bc45ca94c07401b3888da6f7e0c00fd716 Mon Sep 17 00:00:00 2001 From: hided62 Date: Sun, 26 Jul 2026 05:09:31 +0000 Subject: [PATCH 04/19] fix tournament lifecycle settlement --- app/game-api/src/index.ts | 3 + app/game-api/src/tournament/worker.ts | 154 +++--- app/game-api/src/tournament/workerHelpers.ts | 5 +- app/game-api/test/tournamentWorker.test.ts | 114 ++++- .../test/tournamentLifecycle.test.ts | 468 ++++++++++++++++++ 5 files changed, 683 insertions(+), 61 deletions(-) create mode 100644 tools/integration-tests/test/tournamentLifecycle.test.ts diff --git a/app/game-api/src/index.ts b/app/game-api/src/index.ts index c69c4e6..4de2b2d 100644 --- a/app/game-api/src/index.ts +++ b/app/game-api/src/index.ts @@ -30,6 +30,9 @@ export * from './auction/types.js'; export * from './auction/keys.js'; export * from './auction/scheduler.js'; export * from './auction/worker.js'; +export * from './tournament/keys.js'; +export * from './tournament/store.js'; +export * from './tournament/types.js'; export * from './tournament/worker.js'; // Types for TRPC consumer diff --git a/app/game-api/src/tournament/worker.ts b/app/game-api/src/tournament/worker.ts index af79bfb..9d65372 100644 --- a/app/game-api/src/tournament/worker.ts +++ b/app/game-api/src/tournament/worker.ts @@ -1,15 +1,15 @@ -import { createTournamentRng } from '@sammo-ts/common'; +import { createTournamentRng, type TurnDaemonCommandResult } from '@sammo-ts/common'; import { resolveTournamentBattle } from '@sammo-ts/logic'; import { createGamePostgresConnector, createRedisConnector, resolvePostgresConfigFromEnv, resolveRedisConfigFromEnv, + type GamePrismaClient, } from '@sammo-ts/infra'; import { resolveGameApiConfigFromEnv } from '../config.js'; -import { RedisTurnDaemonTransport } from '../daemon/redisTransport.js'; -import { buildTurnDaemonStreamKeys } from '../daemon/streamKeys.js'; +import { DatabaseTurnDaemonTransport } from '../daemon/databaseTransport.js'; import type { TurnDaemonTransport } from '../daemon/transport.js'; import { buildTournamentKeys } from './keys.js'; import { TournamentStore } from './store.js'; @@ -462,13 +462,30 @@ export const settleTournamentOutcome = async (options: { return null; } - let settledState: TournamentState | null = null; + let settledState = state; + let changed = false; - if (!state.rewardSettled) { + const requireSuccessfulResult = ( + result: TurnDaemonCommandResult | null, + expectedType: TurnDaemonCommandResult['type'] + ): void => { + if (!result) { + throw new Error(`${expectedType} 명령 응답 시간이 초과되었습니다.`); + } + if (result.type !== expectedType) { + throw new Error(`${expectedType} 명령에 잘못된 응답(${result.type})을 받았습니다.`); + } + if (!result.ok) { + throw new Error(`${expectedType} 명령이 실패했습니다: ${result.reason}`); + } + }; + + if (!settledState.rewardSettled) { const matches = await store.getMatches(); const rewardPayload = buildTournamentRewardPayload(matches); - await daemonTransport.sendCommand({ + const result = await daemonTransport.requestCommand({ type: 'tournamentReward', + requestId: `tournament:${state.bettingId ?? `${state.openYear}:${state.openMonth}:${state.type}`}:reward`, tournamentType: state.type, winnerId: rewardPayload.winnerId, runnerUpId: rewardPayload.runnerUpId, @@ -476,46 +493,100 @@ export const settleTournamentOutcome = async (options: { top8: rewardPayload.top8, top4: rewardPayload.top4, }); + requireSuccessfulResult(result, 'tournamentReward'); settledState = { - ...(settledState ?? state), + ...settledState, rewardSettled: true, }; + changed = true; + await store.setState(settledState); } - if (state.bettingId && !state.bettingSettled) { + if (settledState.bettingId && !settledState.bettingSettled) { const bettingEntries = await store.getBettingEntries(); if (bettingEntries.length > 0) { - const payoutInfo = buildBettingPayouts(state.winnerId, bettingEntries); + const payoutInfo = buildBettingPayouts(settledState.winnerId!, bettingEntries); if (payoutInfo.payouts.length > 0) { if (payoutInfo.refundAll) { - await daemonTransport.sendCommand({ + const result = await daemonTransport.requestCommand({ type: 'tournamentRefund', - bettingId: state.bettingId, + requestId: `tournament:${settledState.bettingId}:betting-refund`, + bettingId: settledState.bettingId, refunds: payoutInfo.payouts, reason: 'no_winner', }); + requireSuccessfulResult(result, 'tournamentRefund'); } else { - await daemonTransport.sendCommand({ + const result = await daemonTransport.requestCommand({ type: 'tournamentBettingPayout', - bettingId: state.bettingId, + requestId: `tournament:${settledState.bettingId}:betting-payout`, + bettingId: settledState.bettingId, payouts: payoutInfo.payouts, reason: 'winner_payout', }); + requireSuccessfulResult(result, 'tournamentBettingPayout'); } } } settledState = { - ...(settledState ?? state), + ...settledState, bettingSettled: true, }; - } - - if (settledState) { + changed = true; await store.setState(settledState); } - return settledState; + return changed ? settledState : null; +}; + +const needsSettlement = (state: TournamentState): boolean => + state.stage === 0 && + Boolean(state.winnerId) && + (!state.rewardSettled || (Boolean(state.bettingId) && !state.bettingSettled)); + +export const processTournamentTick = async (options: { + store: TournamentStore; + prisma: GamePrismaClient; + daemonTransport: TurnDaemonTransport; + now?: () => number; +}): Promise => { + const { store, prisma, daemonTransport } = options; + const now = options.now ?? Date.now; + let processedState: TournamentState | null = null; + + await store.withMutationLock(async () => { + const state = await store.getState(); + if (!state || (!state.auto && !needsSettlement(state))) { + return; + } + const nextAt = new Date(state.nextAt).getTime(); + if (state.auto && Number.isFinite(nextAt) && nextAt > now()) { + return; + } + + if (needsSettlement(state)) { + processedState = (await settleTournamentOutcome({ store, daemonTransport, state })) ?? state; + return; + } + + const worldState = await prisma.worldState.findFirst(); + const baseSeed = (worldState?.meta as Record | null)?.hiddenSeed ?? 'tournament'; + let nextState = state; + if (isBattleStage(state.stage)) { + nextState = await applyBattle(store, state, String(baseSeed), daemonTransport); + } else if (isPreBattleStage(state.stage)) { + nextState = await applyPreBattleStage(store, prisma, state, String(baseSeed), daemonTransport); + } + processedState = + (await settleTournamentOutcome({ + store, + daemonTransport, + state: nextState, + })) ?? nextState; + }); + + return processedState; }; export const runTournamentWorker = async (): Promise => { @@ -527,10 +598,7 @@ export const runTournamentWorker = async (): Promise => { await redis.connect(); const store = new TournamentStore(redis.client, buildTournamentKeys(config.profileName)); - const daemonTransport = new RedisTurnDaemonTransport(redis.client, { - keys: buildTurnDaemonStreamKeys(config.profileName), - requestTimeoutMs: config.daemonRequestTimeoutMs, - }); + const daemonTransport = new DatabaseTurnDaemonTransport(postgres.prisma, config.daemonRequestTimeoutMs); const handleExit = async () => { await redis.disconnect(); @@ -541,49 +609,23 @@ export const runTournamentWorker = async (): Promise => { while (true) { const state = await store.getState(); - if (!state || !state.auto) { + if (!state || (!state.auto && !needsSettlement(state))) { await sleepMs(config.tournamentPollMs); continue; } const nextAt = new Date(state.nextAt).getTime(); const now = Date.now(); - if (Number.isFinite(nextAt) && nextAt > now) { + if (state.auto && Number.isFinite(nextAt) && nextAt > now) { await sleepMs(Math.min(config.tournamentPollMs, nextAt - now)); continue; } try { - await store.withMutationLock(async () => { - const lockedState = await store.getState(); - if (!lockedState || !lockedState.auto) { - return; - } - const lockedNextAt = new Date(lockedState.nextAt).getTime(); - if (Number.isFinite(lockedNextAt) && lockedNextAt > Date.now()) { - return; - } - - const worldState = await postgres.prisma.worldState.findFirst(); - const baseSeed = (worldState?.meta as Record | null)?.hiddenSeed ?? 'tournament'; - let nextState = lockedState; - if (isBattleStage(lockedState.stage)) { - nextState = await applyBattle(store, lockedState, String(baseSeed), daemonTransport); - } else if (isPreBattleStage(lockedState.stage)) { - nextState = await applyPreBattleStage( - store, - postgres.prisma, - lockedState, - String(baseSeed), - daemonTransport - ); - } - - await settleTournamentOutcome({ - store, - daemonTransport, - state: nextState, - }); + await processTournamentTick({ + store, + prisma: postgres.prisma, + daemonTransport, }); } catch (error) { const message = error instanceof Error ? error.message : 'Unknown error'; @@ -603,9 +645,9 @@ export const runTournamentWorker = async (): Promise => { }, }, }); + const currentState = (await store.getState()) ?? state; const nextState: TournamentState = { - ...state, - auto: false, + ...currentState, lastError: message, lastErrorAt: now, }; diff --git a/app/game-api/src/tournament/workerHelpers.ts b/app/game-api/src/tournament/workerHelpers.ts index 2519d24..eda261b 100644 --- a/app/game-api/src/tournament/workerHelpers.ts +++ b/app/game-api/src/tournament/workerHelpers.ts @@ -520,8 +520,9 @@ export const buildBettingPayouts = ( const winners = entries.filter((entry) => entry.targetId === winnerId); const winnersTotal = winners.reduce((sum, entry) => sum + entry.amount, 0); if (winnersTotal <= 0) { - const refunds = entries.map((entry) => ({ generalId: entry.generalId, amount: entry.amount })); - return { payouts: refunds, total, refundAll: true }; + // Legacy Betting::_calcRewardExclusive() builds a refund candidate list + // but returns no rewards when nobody selected the winner. + return { payouts: [], total, refundAll: false }; } const ratio = total / winnersTotal; const payouts = winners.map((entry) => ({ diff --git a/app/game-api/test/tournamentWorker.test.ts b/app/game-api/test/tournamentWorker.test.ts index 4a0ca2a..4c89803 100644 --- a/app/game-api/test/tournamentWorker.test.ts +++ b/app/game-api/test/tournamentWorker.test.ts @@ -12,6 +12,7 @@ import type { TournamentState, } from '../src/tournament/types.js'; import { applyBattle, applyPreBattleStage, settleTournamentOutcome } from '../src/tournament/worker.js'; +import { buildBettingPayouts } from '../src/tournament/workerHelpers.js'; import type { TurnDaemonTransport } from '../src/daemon/transport.js'; class MemoryRedis { @@ -209,6 +210,15 @@ const runTournamentToCompletion = async (options: { const delayTick = async (): Promise => new Promise((resolve) => setTimeout(resolve, 0)); describe('tournament worker (in-memory)', () => { + it('당첨자가 없으면 레거시와 같이 베팅금을 지급하거나 환불하지 않는다', () => { + expect( + buildBettingPayouts(10, [ + { generalId: 1, targetId: 11, amount: 100 }, + { generalId: 2, targetId: 12, amount: 200 }, + ]) + ).toEqual({ payouts: [], total: 300, refundAll: false }); + }); + it('locks 64 applicants into eight groups of eight', async () => { const redis = new MemoryRedis(); const store = new TournamentStore(redis, buildTournamentKeys('test-groups')); @@ -284,11 +294,33 @@ describe('tournament worker (in-memory)', () => { const sent: TurnDaemonCommand[] = []; const transport: TurnDaemonTransport = { - sendCommand: async (command) => { + sendCommand: async () => 'unused', + requestCommand: async (command) => { sent.push(command); - return 'ok'; + if (command.type === 'tournamentReward') { + return { + type: 'tournamentReward', + ok: true, + winnerId: command.winnerId, + runnerUpId: command.runnerUpId, + rewarded: 2, + missing: 0, + totalGold: 100, + totalExp: 10, + }; + } + if (command.type === 'tournamentBettingPayout') { + return { + type: 'tournamentBettingPayout', + ok: true, + bettingId: command.bettingId, + processed: command.payouts.length, + missing: 0, + totalPayout: command.payouts.reduce((sum, payout) => sum + payout.amount, 0), + }; + } + return null; }, - requestCommand: async () => null, requestStatus: async () => null, }; @@ -302,6 +334,82 @@ describe('tournament worker (in-memory)', () => { if (bettingCommand && bettingCommand.type === 'tournamentBettingPayout') { expect(bettingCommand.payouts).toEqual([{ generalId: 1, amount: 300 }]); } + expect(await store.getState()).toMatchObject({ + rewardSettled: true, + bettingSettled: true, + }); + }); + + it('정산 응답 실패 시 완료 표시를 남기지 않고 성공한 보상만 재시도에서 제외한다', async () => { + const redis = new MemoryRedis(); + const store = new TournamentStore(redis, buildTournamentKeys('test-bet-retry')); + const state = createTournamentState({ + stage: 0, + auto: false, + winnerId: 10, + bettingId: 124, + rewardSettled: false, + bettingSettled: false, + }); + await store.setMatches([{ id: 1, stage: 10, roundIndex: 0, attackerId: 10, defenderId: 11, winnerId: 10 }]); + await store.setBettingEntries([{ generalId: 1, targetId: 10, amount: 100 }]); + await store.setState(state); + + let payoutAttempts = 0; + const commands: TurnDaemonCommand[] = []; + const transport: TurnDaemonTransport = { + sendCommand: async () => 'unused', + requestCommand: async (command) => { + commands.push(command); + if (command.type === 'tournamentReward') { + return { + type: 'tournamentReward', + ok: true, + winnerId: command.winnerId, + runnerUpId: command.runnerUpId, + rewarded: 2, + missing: 0, + totalGold: 100, + totalExp: 10, + }; + } + if (command.type === 'tournamentBettingPayout') { + payoutAttempts += 1; + if (payoutAttempts === 1) { + return { + type: 'tournamentBettingPayout', + ok: false, + bettingId: command.bettingId, + reason: '일시적 실패', + }; + } + return { + type: 'tournamentBettingPayout', + ok: true, + bettingId: command.bettingId, + processed: 1, + missing: 0, + totalPayout: 100, + }; + } + return null; + }, + requestStatus: async () => null, + }; + + await expect(settleTournamentOutcome({ store, daemonTransport: transport, state })).rejects.toThrow( + '일시적 실패' + ); + const afterFailure = await store.getState(); + expect(afterFailure).toMatchObject({ rewardSettled: true, bettingSettled: false }); + + await settleTournamentOutcome({ store, daemonTransport: transport, state: afterFailure! }); + expect(await store.getState()).toMatchObject({ rewardSettled: true, bettingSettled: true }); + expect(commands.filter((command) => command.type === 'tournamentReward')).toHaveLength(1); + expect(commands.filter((command) => command.type === 'tournamentBettingPayout')).toHaveLength(2); + expect( + commands.filter((command) => command.type === 'tournamentBettingPayout').map((command) => command.requestId) + ).toEqual(['tournament:124:betting-payout', 'tournament:124:betting-payout']); }); it('자동 오픈 후 참가자 보충(NPC/더미 포함)하고 결승까지 진행된다', async () => { diff --git a/tools/integration-tests/test/tournamentLifecycle.test.ts b/tools/integration-tests/test/tournamentLifecycle.test.ts new file mode 100644 index 0000000..6e4aa22 --- /dev/null +++ b/tools/integration-tests/test/tournamentLifecycle.test.ts @@ -0,0 +1,468 @@ +import path from 'node:path'; +import fs from 'node:fs/promises'; +import { execFile } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { createTRPCProxyClient, httpBatchLink } from '@trpc/client'; + +import type { AppRouter as GatewayAppRouter } from '@sammo-ts/gateway-api'; +import { createGatewayApiServer } from '@sammo-ts/gateway-api'; +import type { AppRouter as GameAppRouter } from '@sammo-ts/game-api'; +import { + buildTournamentKeys, + createGameApiServer, + DatabaseTurnDaemonTransport, + processTournamentTick, + TournamentStore, +} from '@sammo-ts/game-api'; +import { createTurnDaemonRuntime } from '@sammo-ts/game-engine'; +import { + createGamePostgresConnector, + createGatewayPostgresConnector, + createRedisConnector, + resolvePostgresConfigFromEnv, + resolveRedisConfigFromEnv, + type GamePrisma, +} from '@sammo-ts/infra'; + +const workspaceRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..'); +const sleep = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)); + +const parseEnvFile = (rawText: string): Record => { + const env: Record = {}; + for (const line of rawText.split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) { + continue; + } + const separator = trimmed.indexOf('='); + if (separator < 0) { + continue; + } + const key = trimmed.slice(0, separator).trim(); + let value = trimmed.slice(separator + 1).trim(); + if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) { + value = value.slice(1, -1); + } + env[key] = value; + } + return env; +}; + +const loadEnv = async (): Promise => { + const values = parseEnvFile(await fs.readFile(path.join(workspaceRoot, '.env.ci'), 'utf8')); + for (const [key, value] of Object.entries(values)) { + if (process.env[key] === undefined) { + process.env[key] = value; + } + } + process.env.INTEGRATION_JOIN_ALLOW_CITY ??= 'true'; + process.env.INTEGRATION_WORLD_SEED ??= 'tournament-lifecycle-seed'; +}; + +const execCommand = (command: string, args: string[], env?: NodeJS.ProcessEnv): Promise => + new Promise((resolve, reject) => { + execFile(command, args, { env, cwd: workspaceRoot }, (error, stdout, stderr) => { + if (error) { + reject(new Error(`${command} ${args.join(' ')} failed:\n${stdout}\n${stderr}`)); + return; + } + resolve(); + }); + }); + +const ensureSchema = async (schema: string): Promise => { + const connector = createGatewayPostgresConnector({ + url: resolvePostgresConfigFromEnv({ schema: 'public' }).url, + }); + await connector.connect(); + try { + await connector.prisma.$executeRawUnsafe(`CREATE SCHEMA IF NOT EXISTS "${schema}"`); + } finally { + await connector.disconnect(); + } +}; + +const truncateSchema = async (schema: string): Promise => { + const connector = createGatewayPostgresConnector({ + url: resolvePostgresConfigFromEnv({ schema: 'public' }).url, + }); + await connector.connect(); + try { + const rows = (await connector.prisma.$queryRawUnsafe( + `SELECT tablename FROM pg_tables WHERE schemaname = '${schema}'` + )) as Array<{ tablename: string }>; + if (rows.length === 0) { + return; + } + const tableList = rows.map((row) => `"${schema}"."${row.tablename}"`).join(', '); + await connector.prisma.$executeRawUnsafe(`TRUNCATE TABLE ${tableList} RESTART IDENTITY CASCADE`); + } finally { + await connector.disconnect(); + } +}; + +const resetServices = async (): Promise => { + await ensureSchema('public'); + await ensureSchema('che'); + await execCommand('pnpm', ['--filter', '@sammo-ts/infra', 'prisma:db:push:gateway', '--accept-data-loss'], { + ...process.env, + POSTGRES_SCHEMA: 'public', + }); + await execCommand('pnpm', ['--filter', '@sammo-ts/infra', 'prisma:db:push:game', '--accept-data-loss'], { + ...process.env, + POSTGRES_SCHEMA: 'che', + }); + await truncateSchema('public'); + await truncateSchema('che'); + + const redis = createRedisConnector(resolveRedisConfigFromEnv()); + await redis.connect(); + try { + await redis.client.flushDb(); + } finally { + await redis.disconnect(); + } +}; + +const createGatewayClient = (baseUrl: string, pathName: string, token: { value?: string }) => + createTRPCProxyClient({ + links: [ + httpBatchLink({ + url: `${baseUrl}${pathName}`, + headers: () => (token.value ? { 'x-session-token': token.value } : {}), + }), + ], + }); + +const createGameClient = (baseUrl: string, pathName: string, token: { value?: string }) => + createTRPCProxyClient({ + links: [ + httpBatchLink({ + url: `${baseUrl}${pathName}`, + headers: () => (token.value ? { authorization: `Bearer ${token.value}` } : {}), + }), + ], + }); + +describe('actual tournament lifecycle', () => { + let gatewayServer: Awaited> | null = null; + let gameServer: Awaited> | null = null; + let turnDaemon: Awaited> | null = null; + let turnDaemonLoop: Promise | null = null; + let gameConnector: Awaited> | null = null; + let redisConnector: Awaited> | null = null; + let store: TournamentStore | null = null; + let transport: DatabaseTurnDaemonTransport | null = null; + + const clients = new Map>(); + const generalIds = new Map(); + + beforeAll(async () => { + await loadEnv(); + process.env.SCENARIO = '908'; + process.chdir(workspaceRoot); + await resetServices(); + + gatewayServer = await createGatewayApiServer(); + await gatewayServer.app.listen({ host: gatewayServer.config.host, port: gatewayServer.config.port }); + gameServer = await createGameApiServer(); + await gameServer.app.listen({ host: gameServer.config.host, port: gameServer.config.port }); + + const gatewayUrl = `http://localhost:${gatewayServer.config.port}`; + const gameUrl = `http://localhost:${gameServer.config.port}`; + const adminSession = { value: undefined as string | undefined }; + const gatewayClient = createGatewayClient(gatewayUrl, gatewayServer.config.trpcPath, adminSession); + + const bootstrap = await gatewayClient.auth.bootstrapLocal.mutate({ + token: process.env.GATEWAY_BOOTSTRAP_TOKEN ?? '', + username: 'admin', + password: 'admin-pass-123', + displayName: '관리자', + }); + adminSession.value = bootstrap.sessionToken; + + const users = [ + ['participant', '대회참가자'], + ['bettor-a', '베팅유저A'], + ['bettor-b', '베팅유저B'], + ['no-general', '무장수유저'], + ] as const; + for (const [username, displayName] of users) { + await gatewayClient.admin.users.createLocal.mutate({ + username, + password: `${username}-pass`, + displayName, + }); + } + + await gatewayClient.admin.profiles.upsert.mutate({ + profile: 'che', + scenario: '908', + apiPort: Number(process.env.GAME_API_PORT ?? 14000), + status: 'RUNNING', + }); + await gatewayClient.admin.profiles.installNow.mutate({ + profileName: 'che:908', + install: { + scenarioId: 908, + turnTermMinutes: 1, + sync: false, + fiction: 0, + extend: true, + blockGeneralCreate: 0, + npcMode: 0, + showImgLevel: 0, + tournamentTrig: true, + joinMode: 'full', + autorunUser: null, + }, + }); + + for (const [username, displayName] of users) { + const login = await gatewayClient.auth.login.mutate({ + username, + password: `${username}-pass`, + }); + const gatewayToken = await gatewayClient.auth.issueGameSession.mutate({ + sessionToken: login.sessionToken, + profile: 'che:908', + }); + const accessRef = { value: undefined as string | undefined }; + const client = createGameClient(gameUrl, gameServer.config.trpcPath, accessRef); + const access = await client.auth.exchangeGatewayToken.mutate({ gatewayToken: gatewayToken.gameToken }); + accessRef.value = access.accessToken; + clients.set(username, client); + + if (username !== 'no-general') { + const created = await client.join.createGeneral.mutate({ + name: displayName, + leadership: 55, + strength: 55, + intel: 55, + character: 'Random', + }); + generalIds.set(username, created.generalId); + } + } + + const gameDatabaseUrl = resolvePostgresConfigFromEnv({ schema: 'che' }).url; + const gatewayDatabaseUrl = resolvePostgresConfigFromEnv({ schema: 'public' }).url; + gameConnector = createGamePostgresConnector({ url: gameDatabaseUrl }); + await gameConnector.connect(); + await gameConnector.prisma.general.updateMany({ + where: { id: { in: [...generalIds.values()] } }, + data: { gold: 10_000 }, + }); + const maxGeneralId = (await gameConnector.prisma.general.aggregate({ _max: { id: true } }))._max.id ?? 0; + const npcTemplate = await gameConnector.prisma.general.findFirstOrThrow({ + where: { id: { in: [...generalIds.values()] } }, + }); + const { + id: _templateId, + userId: _templateUserId, + name: _templateName, + createdAt: _templateCreatedAt, + updatedAt: _templateUpdatedAt, + ...npcTemplateData + } = npcTemplate; + const templateMeta = + typeof npcTemplate.meta === 'object' && npcTemplate.meta !== null && !Array.isArray(npcTemplate.meta) + ? npcTemplate.meta + : {}; + await gameConnector.prisma.general.createMany({ + data: Array.from({ length: 48 }, (_, index): GamePrisma.GeneralCreateManyInput => ({ + ...npcTemplateData, + id: maxGeneralId + index + 1, + userId: null, + name: `대회NPC${index + 1}`, + npcState: 2, + leadership: 40 + (index % 31), + strength: 40 + ((index * 3) % 31), + intel: 40 + ((index * 7) % 31), + gold: 10_000, + lastTurn: npcTemplate.lastTurn as GamePrisma.InputJsonValue, + meta: { ...templateMeta, explevel: 20 }, + penalty: npcTemplate.penalty as GamePrisma.InputJsonValue, + })), + }); + + redisConnector = createRedisConnector(resolveRedisConfigFromEnv()); + await redisConnector.connect(); + store = new TournamentStore(redisConnector.client, buildTournamentKeys('che:908')); + transport = new DatabaseTurnDaemonTransport(gameConnector.prisma, 30_000); + + turnDaemon = await createTurnDaemonRuntime({ + profile: 'che', + profileName: 'che:908', + databaseUrl: gameDatabaseUrl, + gatewayDatabaseUrl, + redisUrl: resolveRedisConfigFromEnv().url, + }); + + for (let attempt = 0; attempt < 36; attempt += 1) { + const current = turnDaemon.world.getState().lastTurnTime; + const next = new Date(current.getTime()); + next.setUTCMonth(next.getUTCMonth() + 1); + await turnDaemon.world.advanceMonth(next); + if ((await store.getState())?.stage === 1) { + break; + } + } + expect(await store.getState()).toMatchObject({ stage: 1, auto: true }); + + turnDaemonLoop = turnDaemon.lifecycle.start(); + const status = await transport.requestStatus(10_000); + expect(status).not.toBeNull(); + }, 120_000); + + afterAll(async () => { + if (turnDaemon) { + await turnDaemon.lifecycle.stop('tournament-lifecycle-test'); + await turnDaemon.close(); + await turnDaemonLoop; + } + await redisConnector?.disconnect(); + await gameConnector?.disconnect(); + await gameServer?.app.close(); + await gatewayServer?.app.close(); + }, 30_000); + + it('runs auto-open, enrollment, betting, finals, rewards, and payout through the real daemon', async () => { + if (!store || !transport || !gameConnector) { + throw new Error('integration runtime is not ready'); + } + const participant = clients.get('participant')!; + const bettorA = clients.get('bettor-a')!; + const bettorB = clients.get('bettor-b')!; + const noGeneral = clients.get('no-general')!; + + await expect(noGeneral.tournament.getState.query()).rejects.toThrow(); + await expect(participant.tournament.join.mutate()).resolves.toMatchObject({ ok: true }); + await expect(participant.tournament.join.mutate()).resolves.toMatchObject({ ok: true }); + + for (let steps = 0; steps < 2000; steps += 1) { + const state = await store.getState(); + if (!state) { + throw new Error('tournament state disappeared'); + } + if (state.stage === 6) { + break; + } + await store.setState({ + ...state, + nextAt: new Date(Date.now() - 1_000).toISOString(), + }); + await processTournamentTick({ + store, + prisma: gameConnector.prisma, + daemonTransport: transport, + }); + } + + const bettingState = await store.getState(); + expect(bettingState).toMatchObject({ stage: 6, auto: true }); + const matches = await store.getMatches(); + const candidates = Array.from( + new Set( + matches.filter((match) => match.stage === 7).flatMap((match) => [match.attackerId, match.defenderId]) + ) + ); + expect(candidates).toHaveLength(16); + + const idleDeadline = Date.now() + 60_000; + while (Date.now() < idleDeadline) { + const pending = await gameConnector.prisma.inputEvent.count({ + where: { status: { in: ['PENDING', 'PROCESSING'] } }, + }); + if (pending === 0) { + break; + } + await sleep(100); + } + expect( + await gameConnector.prisma.inputEvent.count({ + where: { status: { in: ['PENDING', 'PROCESSING'] } }, + }) + ).toBe(0); + + for (const targetId of candidates) { + await bettorA.tournament.placeBet.mutate({ targetId, amount: 10 }); + } + await bettorB.tournament.placeBet.mutate({ targetId: candidates[0]!, amount: 100 }); + + const bets = await store.getBettingEntries(); + const bettorAId = generalIds.get('bettor-a')!; + const bettorBId = generalIds.get('bettor-b')!; + expect(bets.filter((entry) => entry.generalId === bettorAId)).toHaveLength(16); + expect(bets.some((entry) => entry.generalId === bettorBId && entry.targetId === candidates[0])).toBe(true); + expect(bets.every((entry) => entry.generalId !== generalIds.get('participant'))).toBe(true); + + const bettorBeforeSettlement = await gameConnector.prisma.general.findUniqueOrThrow({ + where: { id: bettorAId }, + select: { gold: true, meta: true }, + }); + + await store.setState({ + ...bettingState!, + bettingCloseAt: new Date(Date.now() - 1_000).toISOString(), + nextAt: new Date(Date.now() - 1_000).toISOString(), + }); + + for (let steps = 0; steps < 100; steps += 1) { + const state = await store.getState(); + if (!state) { + throw new Error('tournament state disappeared'); + } + if (state.stage === 0 && state.rewardSettled && state.bettingSettled) { + break; + } + if (state.stage > 0) { + await store.setState({ + ...state, + bettingCloseAt: + state.stage === 6 ? new Date(Date.now() - 1_000).toISOString() : state.bettingCloseAt, + nextAt: new Date(Date.now() - 1_000).toISOString(), + }); + } + await processTournamentTick({ + store, + prisma: gameConnector.prisma, + daemonTransport: transport, + }); + } + + const finalState = await store.getState(); + expect(finalState).toMatchObject({ + stage: 0, + auto: false, + rewardSettled: true, + bettingSettled: true, + }); + expect(finalState?.winnerId).toBeTypeOf('number'); + + const bettorAfterSettlement = await gameConnector.prisma.general.findUniqueOrThrow({ + where: { id: bettorAId }, + select: { gold: true, meta: true }, + }); + expect(bettorAfterSettlement.gold).toBeGreaterThan(bettorBeforeSettlement.gold); + expect(bettorAfterSettlement.meta).toMatchObject({ + betgold: 160, + betwin: 1, + }); + expect(Number((bettorAfterSettlement.meta as Record).betwingold)).toBeGreaterThan(0); + + const settlementEvents = await gameConnector.prisma.inputEvent.findMany({ + where: { eventType: { in: ['tournamentReward', 'tournamentBettingPayout'] } }, + select: { eventType: true, status: true, result: true }, + }); + expect(settlementEvents).toEqual( + expect.arrayContaining([ + expect.objectContaining({ eventType: 'tournamentReward', status: 'SUCCEEDED' }), + expect.objectContaining({ eventType: 'tournamentBettingPayout', status: 'SUCCEEDED' }), + ]) + ); + expect(settlementEvents.every((event) => (event.result as { ok?: boolean } | null)?.ok === true)).toBe(true); + }, 120_000); +}); From 37048c9be78e18e8bfd4f6295f385c1bbafa143d Mon Sep 17 00:00:00 2001 From: hided62 Date: Sun, 26 Jul 2026 05:11:49 +0000 Subject: [PATCH 05/19] feat: complete map-to-city page parity --- app/game-api/src/router/world/index.ts | 79 +++- app/game-api/test/inGameInfoRouter.test.ts | 68 ++- app/game-frontend/e2e/inGameInfo.spec.ts | 203 ++++++++- .../src/components/main/MapCityBasic.vue | 19 +- .../src/components/main/MapCityDetail.vue | 7 +- .../src/views/CurrentCityView.vue | 394 ++++++++++++++---- docs/frontend-legacy-parity.md | 41 +- .../reference-current-city.mjs | 128 ++++++ 8 files changed, 804 insertions(+), 135 deletions(-) create mode 100644 tools/frontend-legacy-parity/reference-current-city.mjs diff --git a/app/game-api/src/router/world/index.ts b/app/game-api/src/router/world/index.ts index 5719247..80d1ca7 100644 --- a/app/game-api/src/router/world/index.ts +++ b/app/game-api/src/router/world/index.ts @@ -7,6 +7,7 @@ import { authedProcedure } from '../../trpc.js'; import { asRecord, isRecord } from '@sammo-ts/common'; import { loadWorldMap } from '../../maps/worldMap.js'; import { loadMapLayout } from '../../maps/mapLayout.js'; +import { loadUnitSetDefinitionByName } from '../../battleSim/unitSetLoader.js'; import { getMyGeneral, getOwnedGeneral } from '../shared/general.js'; const isWorldAdmin = (roles: readonly string[]): boolean => @@ -33,6 +34,29 @@ const defenceTrain = (meta: unknown): number => { return typeof raw === 'number' && Number.isFinite(raw) ? raw : 0; }; +const unitSetName = (world: WorldStateRow | null, fallback: string): string => { + const config = asRecord(world?.config); + const environment = asRecord(config.environment ?? config.map); + return typeof environment.unitSet === 'string' && environment.unitSet.trim() ? environment.unitSet : fallback; +}; + +const crewTypeNameCache = new Map>>(); +const loadCrewTypeNames = (name: string): Promise> => { + const cached = crewTypeNameCache.get(name); + if (cached) return cached; + const pending = loadUnitSetDefinitionByName(name) + .then((definition) => new Map((definition.crewTypes ?? []).map((crewType) => [crewType.id, crewType.name]))) + .catch(() => new Map()); + crewTypeNameCache.set(name, pending); + return pending; +}; + +const leadershipBonus = (officerLevel: number, nationLevel: number): number => { + if (officerLevel === 12) return nationLevel * 2; + if (officerLevel >= 5) return nationLevel; + return 0; +}; + const toWorldStateSnapshot = (row: WorldStateRow) => ({ scenarioCode: row.scenarioCode, currentYear: row.currentYear, @@ -122,6 +146,8 @@ export const worldRouter = router({ if (me.officerLevel > 0 && me.nationId > 0) { cities.filter((city) => city.nationId === me.nationId).forEach((city) => selectable.add(city.id)); nationGenerals.forEach((general) => selectable.add(general.cityId)); + } + if ((nation?.level ?? 0) > 0) { Object.keys(spy).forEach((id) => selectable.add(Number(id))); } if (admin) cities.forEach((city) => selectable.add(city.id)); @@ -150,6 +176,8 @@ export const worldRouter = router({ turnMap.set(turn.generalId, list); } const nationMap = new Map(nations.map((item) => [item.id, item])); + const selectedNation = nationMap.get(selected.nationId); + const crewTypeNames = await loadCrewTypeNames(unitSetName(world, ctx.profile.id)); const officers = await ctx.db.general.findMany({ where: { officerLevel: { in: [2, 3, 4] } }, select: { name: true, officerLevel: true, meta: true }, @@ -175,14 +203,59 @@ export const worldRouter = router({ intelligence: general.intel, injury: general.injury, officerLevel: general.officerLevel, + leadershipBonus: leadershipBonus(general.officerLevel, nationMap.get(general.nationId)?.level ?? 0), defenceTrain: ours ? defenceTrain(general.meta) : null, crewTypeId: ours ? general.crewTypeId : null, + crewTypeName: ours ? (crewTypeNames.get(general.crewTypeId) ?? null) : null, crew: ours || full ? general.crew : null, train: ours ? general.train : null, atmos: ours ? general.atmos : null, turns: ours && general.npcState <= 1 ? (turnMap.get(general.id) ?? []) : [], }; }); + const forceSummary = mappedGenerals.reduce( + (summary, general) => { + if (general.nationId > 0 && me.nationId > 0 && general.nationId !== me.nationId) { + summary.enemyGenerals += 1; + if (general.crew !== null && general.crew >= 0) summary.enemyCrew += general.crew; + if (general.crew !== null && general.crew > 0) summary.enemyArmedGenerals += 1; + return summary; + } + if (me.nationId <= 0 || general.nationId !== me.nationId) return summary; + summary.ownGenerals += 1; + summary.ownCrew += general.crew ?? 0; + if ((general.crew ?? 0) <= 0) return summary; + summary.ownArmedGenerals += 1; + const readiness = Math.min(general.train ?? -1, general.atmos ?? -1); + if (readiness >= 90) { + summary.ready90Crew += general.crew ?? 0; + summary.ready90Generals += 1; + } + if (readiness >= 60) { + summary.ready60Crew += general.crew ?? 0; + summary.ready60Generals += 1; + } + if (general.defenceTrain !== null && readiness >= general.defenceTrain) { + summary.defenceReadyCrew += general.crew ?? 0; + summary.defenceReadyGenerals += 1; + } + return summary; + }, + { + enemyCrew: 0, + enemyArmedGenerals: 0, + enemyGenerals: 0, + ownCrew: 0, + ownArmedGenerals: 0, + ownGenerals: 0, + ready90Crew: 0, + ready90Generals: 0, + ready60Crew: 0, + ready60Generals: 0, + defenceReadyCrew: 0, + defenceReadyGenerals: 0, + } + ); return { me: { id: me.id, nationId: me.nationId, officerLevel: me.officerLevel, admin }, options: [...selectable] @@ -194,6 +267,7 @@ export const worldRouter = router({ id: selected.id, name: selected.name, nationId: selected.nationId, + nationColor: selectedNation?.color ?? '#000000', level: selected.level, region: selected.region, population: redact(selected.population), @@ -217,8 +291,11 @@ export const worldRouter = router({ }, }, generals: mappedGenerals, + forceSummary, lastExecute: - typeof asRecord(world?.meta).turntime === 'string' ? String(asRecord(world?.meta).turntime) : '', + typeof asRecord(world?.meta).turntime === 'string' + ? String(asRecord(world?.meta).turntime).slice(5, 19) + : '', }; }), getState: procedure.query(async ({ ctx }) => { diff --git a/app/game-api/test/inGameInfoRouter.test.ts b/app/game-api/test/inGameInfoRouter.test.ts index 8a788cd..9cb7c5d 100644 --- a/app/game-api/test/inGameInfoRouter.test.ts +++ b/app/game-api/test/inGameInfoRouter.test.ts @@ -53,13 +53,13 @@ const general = (overrides: Partial = {}): GeneralRow => ({ updatedAt: now, ...overrides, }); -const auth = (roles: string[] = []): GameSessionTokenPayload => ({ +const auth = (roles: string[] = [], userId = 'user-1'): GameSessionTokenPayload => ({ version: 1, profile: 'che:default', issuedAt: now.toISOString(), expiresAt: new Date(now.getTime() + 86400000).toISOString(), sessionId: 'session', - user: { id: 'user-1', username: 'tester', displayName: 'Tester', roles }, + user: { id: userId, username: 'tester', displayName: 'Tester', roles }, sanctions: {}, }); const city = (id: number, nationId: number) => ({ @@ -88,15 +88,30 @@ const city = (id: number, nationId: number) => ({ meta: {}, }); -const context = (options: { me?: GeneralRow; roles?: string[]; nationMeta?: Record } = {}) => { +const context = ( + options: { + me?: GeneralRow; + roles?: string[]; + userId?: string; + nationMeta?: Record; + nationLevel?: number; + stationCityId?: number; + } = {} +) => { const me = options.me ?? general(); const cities = [city(1, 1), city(2, 2), city(3, 2), city(80, 1)]; const foreign = general({ id: 2, userId: 'user-2', name: '적군', nationId: 2, cityId: 2, crew: 777 }); const db = { general: { - findFirst: vi.fn(async () => me), + findFirst: vi.fn(async ({ where }: { where: { userId: string } }) => + where.userId === me.userId ? me : null + ), findMany: vi.fn(async (args: { where?: Record; select?: Record }) => { - if (args.where?.nationId === 1 && args.select?.cityId) return [{ cityId: me.cityId }]; + if (args.where?.nationId === 1 && args.select?.cityId) + return [ + { cityId: me.cityId }, + ...(options.stationCityId ? [{ cityId: options.stationCityId }] : []), + ]; if (args.where?.cityId === 2) return [foreign]; if (args.where?.cityId === 3) return [foreign]; if (args.where?.officerLevel) return []; @@ -108,7 +123,7 @@ const context = (options: { me?: GeneralRow; roles?: string[]; nationMeta?: Reco id: 1, name: '아국', color: '#008000', - level: 1, + level: options.nationLevel ?? 1, capitalCityId: 1, meta: options.nationMeta ?? {}, })), @@ -130,7 +145,7 @@ const context = (options: { me?: GeneralRow; roles?: string[]; nationMeta?: Reco turnDaemon: {} as GameApiContext['turnDaemon'], battleSim: {} as GameApiContext['battleSim'], profile: { id: 'che', scenario: 'default', name: 'che:default' }, - auth: auth(options.roles), + auth: auth(options.roles, options.userId), uploadDir: 'uploads', uploadPath: '/uploads', uploadPublicUrl: null, @@ -157,6 +172,25 @@ describe('in-game information permissions', () => { expect(result.generals).toEqual([]); }); + it('derives the actor from the session user instead of accepting another user general', async () => { + const caller = appRouter.createCaller(context({ userId: 'user-2' })); + await expect(caller.world.getCurrentCity({ cityId: 1 })).rejects.toMatchObject({ code: 'NOT_FOUND' }); + }); + + it('allows a nation member to select a city occupied by another general of the same nation', async () => { + const result = await appRouter.createCaller(context({ stationCityId: 2 })).world.getCurrentCity({ cityId: 2 }); + expect(result.options.map((entry) => entry.id)).toContain(2); + expect(result.visibility.full).toBe(true); + }); + + it('does not grant a spy city while the nation has no active level', async () => { + const result = await appRouter + .createCaller(context({ nationMeta: { spy: { 2: 2 } }, nationLevel: 0 })) + .world.getCurrentCity({ cityId: 2 }); + expect(result.options.map((entry) => entry.id)).not.toContain(2); + expect(result.visibility.full).toBe(false); + }); + it('keeps adjacent foreign detail redacted and never reveals military fields', async () => { const result = await appRouter .createCaller(context({ me: general({ cityId: 80 }) })) @@ -174,12 +208,20 @@ describe('in-game information permissions', () => { expect(result.visibility.full).toBe(true); expect(result.city.population).toBe(1000); expect(result.generals[0]).toMatchObject({ crew: 777, train: null, atmos: null, crewTypeId: null }); + expect(result.forceSummary).toMatchObject({ + enemyCrew: 777, + enemyArmedGenerals: 1, + enemyGenerals: 1, + }); }); - it('allows administrative roles to inspect all city and general fields', async () => { - const result = await appRouter.createCaller(context({ roles: ['admin'] })).world.getCurrentCity({ cityId: 3 }); - expect(result.options).toHaveLength(4); - expect(result.visibility.full).toBe(true); - expect(result.generals[0]).toMatchObject({ crew: 777, train: 90, atmos: 90, crewTypeId: 1 }); - }); + it.each(['admin', 'superuser', 'admin.superuser'])( + 'allows the %s role to inspect all city and general fields', + async (role) => { + const result = await appRouter.createCaller(context({ roles: [role] })).world.getCurrentCity({ cityId: 3 }); + expect(result.options).toHaveLength(4); + expect(result.visibility.full).toBe(true); + expect(result.generals[0]).toMatchObject({ crew: 777, train: 90, atmos: 90, crewTypeId: 1 }); + } + ); }); diff --git a/app/game-frontend/e2e/inGameInfo.spec.ts b/app/game-frontend/e2e/inGameInfo.spec.ts index 98e9421..b7da581 100644 --- a/app/game-frontend/e2e/inGameInfo.spec.ts +++ b/app/game-frontend/e2e/inGameInfo.spec.ts @@ -1,6 +1,28 @@ import { expect, test, type Page, type Route } from '@playwright/test'; +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; const response = (data: unknown) => ({ result: { data } }); +const artifactRoot = process.env.CITY_PARITY_ARTIFACT_DIR; +const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../..'); +const imageRoots = [resolve(repositoryRoot, '../image'), resolve(repositoryRoot, '../../image')]; +const readImage = async (relativePath: string): Promise => { + if (relativePath.includes('..')) throw new Error(`Unsafe fixture image path: ${relativePath}`); + for (const root of imageRoots) { + try { + return await readFile(resolve(root, relativePath)); + } catch { + // Product checkout and feature worktrees have different image-root parents. + } + } + throw new Error(`Fixture image not found: ${relativePath}`); +}; +const imageContentType = (relativePath: string) => { + if (relativePath.endsWith('.png')) return 'image/png'; + if (relativePath.endsWith('.gif')) return 'image/gif'; + return 'image/jpeg'; +}; const operationNames = (route: Route) => decodeURIComponent(new URL(route.request().url()).pathname.split('/trpc/')[1] ?? '').split(','); const city = { @@ -46,19 +68,68 @@ const layout = { regionMap: { 1: '하북' }, levelMap: { 8: '특' }, }; +const generalContext = { + general: { + id: 1, + name: '장수', + nationId: 1, + cityId: 1, + officerLevel: 1, + npcState: 0, + troopId: 0, + picture: null, + imageServer: 0, + stats: { leadership: 70, strength: 60, intelligence: 50 }, + gold: 1000, + rice: 1000, + crew: 500, + train: 90, + atmos: 90, + injury: 0, + experience: 0, + dedication: 0, + items: { horse: 'None', weapon: 'None', book: 'None', item: 'None' }, + }, + city, + nation: { id: 1, name: '아국', color: '#008000', level: 1 }, + settings: {}, + penalties: {}, +}; +const emptyMessages = { + private: [], + national: [], + public: [], + diplomacy: [], + sequence: -1, + hasMore: { private: false, national: false, public: false, diplomacy: false }, + latestRead: { private: 0, national: 0, public: 0, diplomacy: 0 }, + canRespondDiplomacy: false, +}; const install = async (page: Page, mode: 'member' | 'wanderer' | 'admin' = 'member') => { await page.addInitScript(() => { localStorage.setItem('sammo-game-token', 'ga_info'); localStorage.setItem('sammo-game-profile', 'che:default'); }); - await page.route('**/image/game/**', (route) => - route.fulfill({ status: 200, contentType: 'image/jpeg', body: Buffer.from('') }) - ); + await page.route('**/image/**', async (route) => { + const relativePath = decodeURIComponent(new URL(route.request().url()).pathname.split('/image/')[1] ?? ''); + await route.fulfill({ + status: 200, + contentType: imageContentType(relativePath), + body: await readImage(relativePath), + }); + }); await page.route('**/che/api/trpc/**', async (route) => { const results = operationNames(route).map((operation) => { if (operation === 'lobby.info') return response({ myGeneral: { id: 1, name: '장수' } }); if (operation === 'join.getConfig') return response({}); + if (operation === 'general.me') return response(generalContext); + if (operation === 'world.getMap') return response(map); + if (operation === 'turns.getCommandTable') return response({ general: [], nation: [] }); + if (operation === 'turns.reserved.getGeneral') return response([]); + if (operation === 'messages.getRecent') return response(emptyMessages); + if (operation === 'board.getAccess') return response({ canMeeting: false, canSecret: false }); + if (operation === 'tournament.getState') return response({ stage: 0 }); if (operation === 'nation.getNationInfo') return response({ nation: { @@ -158,6 +229,7 @@ const install = async (page: Page, mode: 'member' | 'wanderer' | 'admin' = 'memb id: 1, name: '업', nationId: 1, + nationColor: '#008000', level: 8, region: 1, population: mode === 'wanderer' ? null : 150000, @@ -193,14 +265,30 @@ const install = async (page: Page, mode: 'member' | 'wanderer' | 'admin' = 'memb intelligence: 50, injury: 0, officerLevel: 1, + leadershipBonus: 0, defenceTrain: 80, crewTypeId: 1, + crewTypeName: '보병', crew: 500, train: 90, atmos: 90, turns: ['징병'], }, ], + forceSummary: { + enemyCrew: 0, + enemyArmedGenerals: 0, + enemyGenerals: 0, + ownCrew: mode === 'wanderer' ? 0 : 500, + ownArmedGenerals: mode === 'wanderer' ? 0 : 1, + ownGenerals: mode === 'wanderer' ? 0 : 1, + ready90Crew: mode === 'wanderer' ? 0 : 500, + ready90Generals: mode === 'wanderer' ? 0 : 1, + ready60Crew: mode === 'wanderer' ? 0 : 500, + ready60Generals: mode === 'wanderer' ? 0 : 1, + defenceReadyCrew: mode === 'wanderer' ? 0 : 500, + defenceReadyGenerals: mode === 'wanderer' ? 0 : 1, + }, lastExecute: '2026-07-26', }); return { error: { message: `unhandled ${operation}`, data: { code: 'BAD_REQUEST' } } }; @@ -215,11 +303,11 @@ const go = async (page: Page, path: string) => { test('four legacy menu pages keep the 1000px desktop table contract', async ({ page }) => { await install(page); await page.setViewportSize({ width: 1200, height: 900 }); - for (const [path, selector] of [ - ['nation/info', '.legacy-info-page'], - ['nation/cities', '.nation-cities-page'], - ['global-info', '.global-page'], - ['current-city', '.city-page'], + for (const [path, selector, fontSize, fontFamily, borderCollapse] of [ + ['nation/info', '.legacy-info-page', '14px', 'Pretendard', 'collapse'], + ['nation/cities', '.nation-cities-page', '14px', 'Pretendard', 'collapse'], + ['global-info', '.global-page', '14px', 'Pretendard', 'collapse'], + ['current-city', '.city-page', '16px', 'Times New Roman', 'separate'], ] as const) { await go(page, path); await expect(page.locator(selector)).toBeVisible(); @@ -230,14 +318,14 @@ test('four legacy menu pages keep the 1000px desktop table contract', async ({ p }); expect(box.width).toBe(1000); expect(box.x).toBe(100); - expect(box.fontSize).toBe('14px'); - expect(box.fontFamily).toContain('Pretendard'); + expect(box.fontSize).toBe(fontSize); + expect(box.fontFamily).toContain(fontFamily); expect( await page .locator('table') .first() .evaluate((el) => getComputedStyle(el).borderCollapse) - ).toBe('collapse'); + ).toBe(borderCollapse); } }); @@ -250,7 +338,100 @@ test('current-city hides values and general rows for a wandering user', async ({ test('current-city exposes own general details to a member and admin fixture', async ({ page }) => { await install(page, 'admin'); + await page.setViewportSize({ width: 1200, height: 900 }); await go(page, 'current-city'); await expect(page.locator('.generals')).toContainText('장수'); await expect(page.locator('.generals')).toContainText('90'); + const legacyGeometry = await page.evaluate(() => { + const rect = (selector: string) => { + const box = document.querySelector(selector)?.getBoundingClientRect(); + return box ? { x: box.x, y: box.y, width: box.width, height: box.height } : null; + }; + const icon = document.querySelector('.general-icon'); + return { + selector: rect('#citySelector'), + stats: rect('.stats'), + generals: rect('.generals'), + titleAlign: getComputedStyle(document.querySelector('.city-page > table:first-child td')!).textAlign, + icon: icon + ? { + ...rect('.general-icon'), + naturalWidth: icon.naturalWidth, + naturalHeight: icon.naturalHeight, + } + : null, + }; + }); + expect(legacyGeometry.selector).toMatchObject({ width: 400, height: 19 }); + expect(legacyGeometry.stats).toEqual({ x: 100, y: 178, width: 1000, height: 136 }); + expect(legacyGeometry.generals).toMatchObject({ x: 88, y: 332, width: 1024 }); + expect(legacyGeometry.titleAlign).toBe('start'); + expect(legacyGeometry.icon).toMatchObject({ width: 64, height: 64, naturalWidth: 64, naturalHeight: 64 }); + if (artifactRoot) { + await mkdir(artifactRoot, { recursive: true }); + const computedDom = await page.evaluate(() => { + const measure = (selector: string) => { + const element = document.querySelector(selector); + if (!element) return null; + const box = element.getBoundingClientRect(); + const style = getComputedStyle(element); + return { + rect: { x: box.x, y: box.y, width: box.width, height: box.height }, + style: { + fontFamily: style.fontFamily, + fontSize: style.fontSize, + lineHeight: style.lineHeight, + color: style.color, + backgroundColor: style.backgroundColor, + backgroundImage: style.backgroundImage, + borderCollapse: style.borderCollapse, + padding: style.padding, + textAlign: style.textAlign, + }, + }; + }; + const icon = document.querySelector('.general-icon'); + return { + body: measure('body'), + page: measure('.city-page'), + selector: measure('#citySelector'), + stats: measure('.stats'), + generals: measure('.generals'), + title: measure('.city-title'), + firstIcon: icon + ? { + ...measure('.general-icon'), + naturalWidth: icon.naturalWidth, + naturalHeight: icon.naturalHeight, + } + : null, + document: { + width: document.documentElement.scrollWidth, + height: document.documentElement.scrollHeight, + }, + }; + }); + await writeFile( + resolve(artifactRoot, 'core-current-city-computed-dom.json'), + `${JSON.stringify(computedDom, null, 2)}\n` + ); + await page.screenshot({ + path: resolve(artifactRoot, 'core-current-city-desktop.png'), + fullPage: true, + animations: 'disabled', + }); + } +}); + +test('a Chromium map click opens the clicked city route and keeps the legacy pointer interaction', async ({ page }) => { + await install(page); + await page.setViewportSize({ width: 1200, height: 900 }); + await go(page, ''); + const cityLink = page.locator('.map-city').first(); + await expect(cityLink).toBeVisible(); + await cityLink.hover(); + await expect(cityLink).toHaveCSS('cursor', 'pointer'); + await cityLink.click(); + await expect(page).toHaveURL(/\/che\/current-city\?cityId=1$/); + await expect(page.locator('.stats')).toContainText('업'); }); diff --git a/app/game-frontend/src/components/main/MapCityBasic.vue b/app/game-frontend/src/components/main/MapCityBasic.vue index ff072f4..d678446 100644 --- a/app/game-frontend/src/components/main/MapCityBasic.vue +++ b/app/game-frontend/src/components/main/MapCityBasic.vue @@ -34,8 +34,9 @@ const stateOffset = computed(() => -6 * props.mapScale); diff --git a/docs/frontend-legacy-parity.md b/docs/frontend-legacy-parity.md index 0162c50..0084262 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 | +| current city | `hwe/b_currentCity.php` | ref-specific 16px Times New Roman, 1000px summary/1024px general tables, 400px selector, 64px icon, nation title color, force summary, actor/spy/admin redaction, and map-click query navigation | +| 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 | The global game baseline is black, white, Pretendard 14px. Legacy texture helpers intentionally follow `common.orig.css`: `bg0` is walnut, `bg1` is @@ -96,6 +97,14 @@ reference collection, `tools/frontend-legacy-parity/reference-nation-offices.mjs records desktop/500px computed DOM and screenshots from the PHP service without changing its product code. +The current-city reference can be collected independently with +`tools/frontend-legacy-parity/reference-current-city.mjs`. It requires +`REF_PARITY_PASSWORD_FILE` and accepts `REF_PARITY_URL`, `REF_PARITY_USER`, and +`REF_PARITY_ARTIFACT_DIR`; the password is read from the ignored secret file +and is never written to the artifact. The matching core fixture is +`app/game-frontend/e2e/inGameInfo.spec.ts`, which writes its computed DOM and +screenshot only when `CITY_PARITY_ARTIFACT_DIR` is set. + For a review run that also writes full-page screenshots, create an ignored artifact directory and set `FRONTEND_PARITY_ARTIFACT_DIR` before invoking the suite. The ordinary CI run does not write screenshots after successful tests. diff --git a/tools/frontend-legacy-parity/reference-current-city.mjs b/tools/frontend-legacy-parity/reference-current-city.mjs new file mode 100644 index 0000000..0ef2309 --- /dev/null +++ b/tools/frontend-legacy-parity/reference-current-city.mjs @@ -0,0 +1,128 @@ +import { chromium } from '@playwright/test'; +import { createHash } from 'node:crypto'; +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; + +const baseUrl = process.env.REF_PARITY_URL ?? 'http://127.0.0.1:3400/sam/'; +const username = process.env.REF_PARITY_USER ?? 'refadmin'; +const passwordFile = process.env.REF_PARITY_PASSWORD_FILE; +const artifactRoot = resolve(process.env.REF_PARITY_ARTIFACT_DIR ?? 'test-results/reference-current-city'); + +if (!passwordFile) { + throw new Error('REF_PARITY_PASSWORD_FILE is required.'); +} + +const password = (await readFile(passwordFile, 'utf8')).trim(); +await mkdir(artifactRoot, { recursive: true }); + +const browser = await chromium.launch({ headless: true }); +try { + const context = await browser.newContext({ + viewport: { width: 1200, height: 900 }, + deviceScaleFactor: 1, + locale: 'ko-KR', + timezoneId: 'Asia/Seoul', + colorScheme: 'dark', + }); + const page = await context.newPage(); + await page.goto(baseUrl, { waitUntil: 'networkidle' }); + const globalSalt = await page.locator('#global_salt').inputValue(); + const passwordHash = createHash('sha512') + .update(globalSalt + password + globalSalt) + .digest('hex'); + const loginResponse = await context.request.post(new URL('api.php?path=Login/LoginByID', baseUrl).toString(), { + data: { username, password: passwordHash }, + }); + const loginResult = await loginResponse.json(); + if (!loginResponse.ok() || loginResult.result !== true) { + throw new Error('Reference login failed.'); + } + + await page.goto(new URL('hwe/', baseUrl).toString(), { waitUntil: 'networkidle' }); + const mapCity = page.locator('a[href*="b_currentCity.php?citylist="]').first(); + const hasMapCity = await mapCity.isVisible({ timeout: 8_000 }).catch(() => false); + let mapInteraction; + if (hasMapCity) { + mapInteraction = await mapCity.evaluate((element) => ({ + available: true, + href: element.getAttribute('href'), + cursor: getComputedStyle(element).cursor, + rect: (() => { + const box = element.getBoundingClientRect(); + return { x: box.x, y: box.y, width: box.width, height: box.height }; + })(), + })); + await mapCity.click(); + await page.waitForLoadState('networkidle'); + if (!page.url().includes('b_currentCity.php?citylist=')) { + throw new Error(`Reference map click did not open current city: ${page.url()}`); + } + } else { + mapInteraction = { + available: false, + pageUrl: page.url(), + pageTitle: await page.title(), + }; + await page.goto(new URL('hwe/b_currentCity.php', baseUrl).toString(), { waitUntil: 'networkidle' }); + } + + const measurements = await page.evaluate(() => { + const measure = (element) => { + const box = element.getBoundingClientRect(); + const style = getComputedStyle(element); + return { + rect: { x: box.x, y: box.y, width: box.width, height: box.height }, + style: { + fontFamily: style.fontFamily, + fontSize: style.fontSize, + lineHeight: style.lineHeight, + color: style.color, + backgroundColor: style.backgroundColor, + backgroundImage: style.backgroundImage, + borderCollapse: style.borderCollapse, + padding: style.padding, + textAlign: style.textAlign, + }, + }; + }; + const tables = [...document.querySelectorAll('table')]; + const selector = document.querySelector('#citySelector'); + const stats = tables.find((table) => table.textContent?.includes('90병장')); + const generals = document.querySelector('#general_list')?.closest('table'); + const firstIcon = document.querySelector('.generalIcon'); + const title = stats?.querySelector('tr:first-child td'); + return { + body: measure(document.body), + tables: tables.map(measure), + selector: selector ? measure(selector) : null, + stats: stats ? measure(stats) : null, + generals: generals ? measure(generals) : null, + firstIcon: firstIcon + ? { + ...measure(firstIcon), + naturalWidth: firstIcon.naturalWidth, + naturalHeight: firstIcon.naturalHeight, + } + : null, + title: title ? measure(title) : null, + document: { + width: document.documentElement.scrollWidth, + height: document.documentElement.scrollHeight, + }, + }; + }); + + await page.screenshot({ + path: resolve(artifactRoot, 'reference-current-city-desktop.png'), + fullPage: true, + animations: 'disabled', + }); + await writeFile( + resolve(artifactRoot, 'reference-current-city-computed-dom.json'), + `${JSON.stringify({ mapInteraction, currentCity: measurements }, null, 2)}\n` + ); + process.stdout.write(`${JSON.stringify({ ok: true, artifactRoot })}\n`); + await context.close(); +} finally { + await browser.close(); +} From 267d21f1a95311a284f9aebcd194044aeeb9fd0c Mon Sep 17 00:00:00 2001 From: hided62 Date: Sun, 26 Jul 2026 05:12:20 +0000 Subject: [PATCH 06/19] test(turn): cover missing target boundaries --- .../general-command-differential-testing.md | 5 ++ .../turn-state-differential-testing.md | 6 +++ ...rnCommandGeneralMatrix.integration.test.ts | 51 +++++++++++++++++++ 3 files changed, 62 insertions(+) diff --git a/docs/architecture/general-command-differential-testing.md b/docs/architecture/general-command-differential-testing.md index f33f40e..ac900c8 100644 --- a/docs/architecture/general-command-differential-testing.md +++ b/docs/architecture/general-command-differential-testing.md @@ -30,6 +30,11 @@ turn 6개는 전투태세 1/2/3턴, 내정·전투 특기 초기화 1턴, 은퇴 `last_turn`과 진행 로그, RNG 무소비를 비교한다. cooldown 3개는 특기 초기화 완료 직후 `current + 60 - preReq`, 1턴 전 차단, 경계 월 허용을 ref `next_execute` KV와 core general meta의 공통 projection으로 비교한다. +존재하지 않는 대상 경계 4개는 증여·등용의 장수 ID와 첩보·이동의 도시 ID를 +고정해 원 명령 미완료, 휴식 fallback, RNG 무소비와 semantic delta를 +비교한다. +필수 인자 객체 자체를 생략한 요청은 ref runner가 제한 시간 안에 종료되지 +않아 동적 호환 판정에서 제외한다. 나머지 명령별 제약 실패·값 경계와 전체 core PostgreSQL 재조회가 완료 기준을 통과하기 전까지 55개 명령 전체의 동적 호환 상태를 `확인`으로 올리지 않는다. diff --git a/docs/architecture/turn-state-differential-testing.md b/docs/architecture/turn-state-differential-testing.md index f29f430..52d3154 100644 --- a/docs/architecture/turn-state-differential-testing.md +++ b/docs/architecture/turn-state-differential-testing.md @@ -210,6 +210,12 @@ 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. +Four missing-target cases cover nonexistent general IDs for gift and +employment plus nonexistent city IDs for spying and movement. Both engines +reject the requested command, execute rest without command RNG, and produce +the same semantic state delta. +Requests that omit the required argument object remain unverified because the +reference runner did not terminate within the bounded comparison run. 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/test/turnCommandGeneralMatrix.integration.test.ts b/tools/integration-tests/test/turnCommandGeneralMatrix.integration.test.ts index f6a139e..9e507f5 100644 --- a/tools/integration-tests/test/turnCommandGeneralMatrix.integration.test.ts +++ b/tools/integration-tests/test/turnCommandGeneralMatrix.integration.test.ts @@ -1058,6 +1058,57 @@ integration('general command post-required cooldown boundary matrix', () => { }, 120_000); }); +const missingTargetCases: Array<{ name: string; action: string; args: Record }> = [ + { + name: 'gift to a missing general', + action: 'che_증여', + args: { isGold: true, amount: 100, destGeneralID: 999 }, + }, + { + name: 'spy on a missing city', + action: 'che_첩보', + args: { destCityID: 999 }, + }, + { + name: 'move to a missing city', + action: 'che_이동', + args: { destCityID: 999 }, + }, + { + name: 'employ a missing general', + action: 'che_등용', + args: { destGeneralID: 999 }, + }, +]; + +integration('general command missing-target fallback matrix', () => { + it.each(missingTargetCases)( + '$name rejects the missing target and falls back without command RNG', + async ({ action, args }) => { + const request = buildRequest(action, args); + const reference = runReferenceTurnCommandTraceRequest( + workspaceRoot!, + request as unknown as Record + ); + const core = await runCoreTurnCommandTrace(request, reference.before); + + expect(reference.execution.outcome).toMatchObject({ completed: false }); + expect(core.execution.outcome).toMatchObject({ + requestedAction: action, + actionKey: '휴식', + usedFallback: true, + }); + 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 8771840c1c50ff7384d56b148d1ad676f604b010 Mon Sep 17 00:00:00 2001 From: hided62 Date: Sun, 26 Jul 2026 05:16:07 +0000 Subject: [PATCH 07/19] Add nation general and secret office parity --- .../router/nation/endpoints/getGeneralList.ts | 50 +- .../nation/endpoints/getSecretGeneralList.ts | 141 +++++ app/game-api/src/router/nation/index.ts | 2 + .../test/nationGeneralSecretRouter.test.ts | 142 +++++ .../e2e/nationGeneralSecret.spec.ts | 150 +++++ app/game-frontend/e2e/playwright.config.mjs | 8 +- app/game-frontend/src/router/index.ts | 7 + app/game-frontend/src/views/MainView.vue | 4 + .../src/views/NationGeneralsView.vue | 521 +++++++----------- .../src/views/NationSecretView.vue | 210 +++++++ .../reference-general-lists.mjs | 91 +++ 11 files changed, 1001 insertions(+), 325 deletions(-) create mode 100644 app/game-api/src/router/nation/endpoints/getSecretGeneralList.ts create mode 100644 app/game-api/test/nationGeneralSecretRouter.test.ts create mode 100644 app/game-frontend/e2e/nationGeneralSecret.spec.ts create mode 100644 app/game-frontend/src/views/NationSecretView.vue create mode 100644 tools/frontend-legacy-parity/reference-general-lists.mjs diff --git a/app/game-api/src/router/nation/endpoints/getGeneralList.ts b/app/game-api/src/router/nation/endpoints/getGeneralList.ts index 8223e9b..21ed1f6 100644 --- a/app/game-api/src/router/nation/endpoints/getGeneralList.ts +++ b/app/game-api/src/router/nation/endpoints/getGeneralList.ts @@ -2,7 +2,21 @@ import { TRPCError } from '@trpc/server'; import { authedProcedure } from '../../../trpc.js'; import { getMyGeneral } from '../../shared/general.js'; -import { assertNationAccess, loadTraitNames, mapGeneralList, resolveChiefStatMin } from '../shared.js'; +import { + assertNationAccess, + loadTraitNames, + mapGeneralList, + resolveChiefStatMin, + resolveNationPermission, +} from '../shared.js'; + +const experienceLevel = (experience: number): number => + Math.max( + 0, + Math.min(100, experience < 1000 ? Math.floor(experience / 100) : Math.floor(Math.sqrt(experience / 10))) + ); +const dedicationLevel = (dedication: number): number => + Math.max(0, Math.min(10, Math.ceil(Math.sqrt(dedication) / 10))); export const getGeneralList = authedProcedure.query(async ({ ctx }) => { const general = await getMyGeneral(ctx); @@ -62,7 +76,38 @@ export const getGeneralList = authedProcedure.query(async ({ ctx }) => { const cityNameMap = new Map(cityRows.map((city) => [city.id, city.name])); const troopNameMap = new Map(troopRows.map((troop) => [troop.troopLeaderId, troop.name])); const list = await mapGeneralList(generalRows, cityNameMap, troopNameMap); + const accessRows = generalRows.length + ? await ctx.db.generalAccessLog.findMany({ + where: { generalId: { in: generalRows.map((entry) => entry.id) } }, + select: { generalId: true, refreshScoreTotal: true }, + }) + : []; + const accessByGeneral = new Map(accessRows.map((entry) => [entry.generalId, entry.refreshScoreTotal])); const nationTrait = (await loadTraitNames([nation.typeCode], 'nation')).get(nation.typeCode); + const permission = resolveNationPermission(general, nation.meta, true); + const visibleList = list.map((entry) => { + const { permission: _targetPermission, ...safeEntry } = entry; + if (permission >= 1) { + return { + ...safeEntry, + refreshScoreTotal: accessByGeneral.get(entry.id) ?? 0, + experienceLevel: experienceLevel(entry.experience), + dedicationLevel: dedicationLevel(entry.dedication), + }; + } + const { crew: _crew, experience: _experience, dedication: _dedication, ...visible } = safeEntry; + return { + ...visible, + refreshScoreTotal: accessByGeneral.get(entry.id) ?? 0, + officerLevel: entry.officerLevel >= 5 ? entry.officerLevel : Math.min(1, entry.officerLevel), + cityName: null, + troopName: null, + officerCity: 0, + officerCityName: null, + experienceLevel: experienceLevel(entry.experience), + dedicationLevel: dedicationLevel(entry.dedication), + }; + }); return { nation: { @@ -79,6 +124,7 @@ export const getGeneralList = authedProcedure.query(async ({ ctx }) => { capitalCityId: nation.capitalCityId ?? 0, }, chiefStatMin: resolveChiefStatMin(worldState), - generals: list, + viewer: { generalId: general.id, permission }, + generals: visibleList, }; }); diff --git a/app/game-api/src/router/nation/endpoints/getSecretGeneralList.ts b/app/game-api/src/router/nation/endpoints/getSecretGeneralList.ts new file mode 100644 index 0000000..b72977f --- /dev/null +++ b/app/game-api/src/router/nation/endpoints/getSecretGeneralList.ts @@ -0,0 +1,141 @@ +import { TRPCError } from '@trpc/server'; + +import { asRecord } from '@sammo-ts/common'; + +import { authedProcedure } from '../../../trpc.js'; +import { getMyGeneral } from '../../shared/general.js'; +import { assertNationAccess, resolveNationPermission } from '../shared.js'; + +const readNumber = (record: Record, keys: string[], fallback = 0): number => { + for (const key of keys) { + const value = record[key]; + if (typeof value === 'number' && Number.isFinite(value)) return value; + } + return fallback; +}; +const woundedStat = (value: number, injury: number): number => + injury > 0 ? Math.floor((value * (100 - injury)) / 100) : value; +const experienceLevel = (experience: number): number => + Math.max( + 0, + Math.min(100, experience < 1000 ? Math.floor(experience / 100) : Math.floor(Math.sqrt(experience / 10))) + ); +const leadershipBonus = (officerLevel: number, nationLevel: number): number => + officerLevel === 12 ? nationLevel * 2 : officerLevel >= 5 ? nationLevel : 0; +const defenceTrainText = (value: number): string => + value === 999 ? '×' : value >= 90 ? '☆' : value >= 80 ? '◎' : value >= 60 ? '○' : '△'; + +export const getSecretGeneralList = authedProcedure.query(async ({ ctx }) => { + const me = await getMyGeneral(ctx); + assertNationAccess(me); + const nation = await ctx.db.nation.findUnique({ + where: { id: me.nationId }, + select: { id: true, name: true, color: true, level: true, meta: true }, + }); + if (!nation) throw new TRPCError({ code: 'NOT_FOUND', message: 'Nation not found' }); + const permission = resolveNationPermission(me, nation.meta, true); + if (permission < 1) { + throw new TRPCError({ + code: 'FORBIDDEN', + message: '권한이 부족합니다. 수뇌부가 아니거나 사관년도가 부족합니다.', + }); + } + + const [cities, troops, generalRows] = await Promise.all([ + ctx.db.city.findMany({ select: { id: true, name: true } }), + ctx.db.troop.findMany({ + where: { nationId: me.nationId }, + select: { troopLeaderId: true, name: true }, + }), + ctx.db.general.findMany({ + where: { nationId: me.nationId }, + orderBy: [{ turnTime: 'asc' }, { id: 'asc' }], + }), + ]); + const generalIds = generalRows.map((general) => general.id); + const turns = generalIds.length + ? await ctx.db.generalTurn.findMany({ + where: { generalId: { in: generalIds }, turnIdx: { lt: 5 } }, + select: { generalId: true, turnIdx: true, actionCode: true }, + orderBy: [{ generalId: 'asc' }, { turnIdx: 'asc' }], + }) + : []; + const cityNames = new Map(cities.map((city) => [city.id, city.name])); + const troopNames = new Map(troops.map((troop) => [troop.troopLeaderId, troop.name])); + const turnMap = new Map(); + for (const turn of turns) { + const list = turnMap.get(turn.generalId) ?? []; + list[turn.turnIdx] = turn.actionCode; + turnMap.set(turn.generalId, list); + } + const generals = generalRows.map((general) => { + const meta = asRecord(general.meta); + const defenceTrain = readNumber(meta, ['defenceTrain', 'defence_train'], 80); + return { + id: general.id, + name: general.name, + npcState: general.npcState, + injury: general.injury, + stats: { + leadership: woundedStat(general.leadership, general.injury), + strength: woundedStat(general.strength, general.injury), + intelligence: woundedStat(general.intel, general.injury), + }, + leadershipBonus: leadershipBonus(general.officerLevel, nation.level), + experienceLevel: experienceLevel(general.experience), + troopId: general.troopId, + troopName: troopNames.get(general.troopId) ?? null, + gold: general.gold, + rice: general.rice, + cityId: general.cityId, + cityName: cityNames.get(general.cityId) ?? null, + defenceTrain, + defenceTrainText: defenceTrainText(defenceTrain), + crewTypeId: general.crewTypeId, + crew: general.crew, + train: general.train, + atmos: general.atmos, + killTurn: readNumber(meta, ['killturn', 'killTurn']), + turnTime: general.turnTime.toISOString(), + reservedCommands: general.npcState < 2 ? (turnMap.get(general.id) ?? []) : [], + }; + }); + const counted = generals.filter((general) => general.npcState !== 5); + const summary = counted.reduce( + (result, general) => { + result.gold += general.gold; + result.rice += general.rice; + result.crew += general.crew; + if (general.crew > 0) { + for (const threshold of [90, 80, 60] as const) { + if (general.train >= threshold && general.atmos >= threshold) { + result.readiness[threshold].crew += general.crew; + result.readiness[threshold].generals += 1; + } + } + } + return result; + }, + { + gold: 0, + rice: 0, + crew: 0, + readiness: { + 90: { crew: 0, generals: 0 }, + 80: { crew: 0, generals: 0 }, + 60: { crew: 0, generals: 0 }, + }, + } + ); + return { + nation: { id: nation.id, name: nation.name, color: nation.color, level: nation.level }, + viewer: { generalId: me.id, permission }, + summary: { + ...summary, + generalCount: counted.length, + averageGold: counted.length ? summary.gold / counted.length : 0, + averageRice: counted.length ? summary.rice / counted.length : 0, + }, + generals, + }; +}); diff --git a/app/game-api/src/router/nation/index.ts b/app/game-api/src/router/nation/index.ts index e810b96..04ff481 100644 --- a/app/game-api/src/router/nation/index.ts +++ b/app/game-api/src/router/nation/index.ts @@ -5,6 +5,7 @@ import { getBattleCenter } from './endpoints/getBattleCenter.js'; import { getChiefCenter } from './endpoints/getChiefCenter.js'; import { getCityOverview } from './endpoints/getCityOverview.js'; import { getGeneralList } from './endpoints/getGeneralList.js'; +import { getSecretGeneralList } from './endpoints/getSecretGeneralList.js'; import { getGeneralLog } from './endpoints/getGeneralLog.js'; import { getNationInfo } from './endpoints/getNationInfo.js'; import { getPersonnelInfo } from './endpoints/getPersonnelInfo.js'; @@ -21,6 +22,7 @@ import { setSecretLimit } from './endpoints/setSecretLimit.js'; export const nationRouter = router({ getNationInfo, getGeneralList, + getSecretGeneralList, getCityOverview, getPersonnelInfo, getStratFinan, diff --git a/app/game-api/test/nationGeneralSecretRouter.test.ts b/app/game-api/test/nationGeneralSecretRouter.test.ts new file mode 100644 index 0000000..c1fce96 --- /dev/null +++ b/app/game-api/test/nationGeneralSecretRouter.test.ts @@ -0,0 +1,142 @@ +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 { appRouter } from '../src/router.js'; + +const now = new Date('2026-01-01T01:02:00Z'); +const general = (overrides: Partial = {}): GeneralRow => ({ + id: 1, + userId: 'u1', + name: '장수', + nationId: 1, + cityId: 1, + troopId: 0, + npcState: 0, + affinity: null, + bornYear: 180, + deadYear: 300, + picture: null, + imageServer: 0, + leadership: 70, + strength: 60, + intel: 50, + injury: 0, + experience: 900, + dedication: 100, + officerLevel: 1, + gold: 1000, + rice: 2000, + crew: 300, + crewTypeId: 1, + train: 90, + atmos: 90, + weaponCode: 'None', + bookCode: 'None', + horseCode: 'None', + itemCode: 'None', + turnTime: now, + recentWarTime: null, + age: 20, + startAge: 20, + personalCode: 'None', + specialCode: 'None', + special2Code: 'None', + lastTurn: {}, + meta: { belong: 1, defence_train: 80, killturn: 7 }, + penalty: {}, + createdAt: now, + updatedAt: now, + ...overrides, +}); +const token = (userId: string): GameSessionTokenPayload => ({ + version: 1, + profile: 'che:default', + issuedAt: now.toISOString(), + expiresAt: new Date(now.getTime() + 86400000).toISOString(), + sessionId: userId, + user: { id: userId, username: userId, displayName: userId, roles: [] }, + sanctions: {}, +}); +const fixture = (generals: GeneralRow[], userId = 'u1') => { + const db = { + general: { + findFirst: vi.fn(async ({ where }: { where: { userId: string } }) => + generals.find((g) => g.userId === where.userId) + ), + findMany: vi.fn(async ({ where }: { where: { nationId: number } }) => + generals.filter((g) => g.nationId === where.nationId) + ), + }, + nation: { + findUnique: vi.fn(async () => ({ + id: 1, + name: '위', + color: '#080', + level: 3, + typeCode: 'che_중립', + capitalCityId: 1, + meta: { secretlimit: 3 }, + })), + }, + city: { findMany: vi.fn(async () => [{ id: 1, name: '업' }]) }, + troop: { findMany: vi.fn(async () => [{ troopLeaderId: 2, name: '선봉대' }]) }, + worldState: { findFirst: vi.fn(async () => null) }, + generalTurn: { findMany: vi.fn(async () => [{ generalId: 1, turnIdx: 0, actionCode: '징병' }]) }, + generalAccessLog: { + findMany: vi.fn(async () => generals.map((g) => ({ generalId: g.id, refreshScoreTotal: g.id * 10 }))), + }, + }; + const redis = { get: vi.fn(async () => null), set: vi.fn(async () => null) } as unknown as RedisConnector['client']; + const context: GameApiContext = { + db: db as unknown as DatabaseClient, + redis, + turnDaemon: {} as GameApiContext['turnDaemon'], + battleSim: {} as GameApiContext['battleSim'], + profile: { id: 'che', scenario: 'default', name: 'che:default' }, + auth: token(userId), + uploadDir: 'uploads', + uploadPath: '/uploads', + uploadPublicUrl: null, + accessTokenStore: new RedisAccessTokenStore(redis, 'che:default'), + flushStore: new InMemoryFlushStore(), + gameTokenSecret: 'secret', + }; + return { caller: appRouter.createCaller(context), db }; +}; + +describe('nation general and secret office permissions', () => { + it('redacts ordinary-member details and denies the secret office', async () => { + const { caller } = fixture([general()]); + const result = await caller.nation.getGeneralList(); + expect(result.viewer).toEqual({ generalId: 1, permission: 0 }); + expect(result.generals[0]).toMatchObject({ + officerLevel: 1, + cityName: null, + troopName: null, + refreshScoreTotal: 10, + }); + expect(result.generals[0]).not.toHaveProperty('crew'); + await expect(caller.nation.getSecretGeneralList()).rejects.toMatchObject({ code: 'FORBIDDEN' }); + }); + it('uses the session-owned general and scopes secret rows to that nation', async () => { + const first = general(); + const actor = general({ id: 2, userId: 'u2', officerLevel: 5, meta: { belong: 1 } }); + const ally = general({ id: 3, userId: 'u3', gold: 3000, crew: 200, train: 80, atmos: 80 }); + const foreign = general({ id: 4, userId: 'u4', nationId: 2, gold: 99999 }); + const { caller, db } = fixture([first, actor, ally, foreign], 'u2'); + const result = await caller.nation.getSecretGeneralList(); + expect(result.viewer).toEqual({ generalId: 2, permission: 2 }); + expect(result.generals.map((g) => g.id)).toEqual([1, 2, 3]); + expect(result.summary).toMatchObject({ gold: 5000, crew: 800, generalCount: 3 }); + expect(db.general.findFirst).toHaveBeenCalledWith({ where: { userId: 'u2' } }); + expect(db.general.findMany).toHaveBeenCalledWith(expect.objectContaining({ where: { nationId: 1 } })); + }); + it('honors general penalties after a user switch', async () => { + const penalized = general({ officerLevel: 5, penalty: { noChief: true } }); + const { caller } = fixture([penalized]); + await expect(caller.nation.getSecretGeneralList()).rejects.toMatchObject({ code: 'FORBIDDEN' }); + }); +}); diff --git a/app/game-frontend/e2e/nationGeneralSecret.spec.ts b/app/game-frontend/e2e/nationGeneralSecret.spec.ts new file mode 100644 index 0000000..39a8f5a --- /dev/null +++ b/app/game-frontend/e2e/nationGeneralSecret.spec.ts @@ -0,0 +1,150 @@ +import { expect, test, type Page, type Route } from '@playwright/test'; + +const response = (data: unknown) => ({ result: { data } }); +const operations = (route: Route) => + decodeURIComponent(new URL(route.request().url()).pathname.split('/trpc/')[1] ?? '').split(','); +const general = { + id: 1, + name: '테스트장수', + npcState: 0, + officerLevel: 1, + cityId: 1, + cityName: null, + troopId: 0, + troopName: null, + officerCity: 0, + officerCityName: null, + stats: { leadership: 70, strength: 60, intelligence: 50 }, + experienceLevel: 9, + dedicationLevel: 1, + injury: 0, + gold: 1000, + rice: 2000, + personality: null, + specialDomestic: null, + specialWar: null, + belong: 1, + refreshScoreTotal: 10, + permission: 'normal', +}; +const install = async (page: Page, secretAllowed = true) => { + await page.addInitScript(() => { + localStorage.setItem('sammo-game-token', 'ga_general'); + localStorage.setItem('sammo-game-profile', 'che:default'); + }); + await page.route('**/che/api/trpc/**', async (route) => { + const results = operations(route).map((operation) => { + if (operation === 'lobby.info') return response({ myGeneral: { id: 1, name: '테스트장수' } }); + if (operation === 'join.getConfig') return response({}); + if (operation === 'nation.getGeneralList') + return response({ + nation: { id: 1, name: '위', color: '#008000', level: 3 }, + viewer: { generalId: 1, permission: 0 }, + generals: [general], + }); + if (operation === 'nation.getSecretGeneralList') { + if (!secretAllowed) + return { + error: { + message: '권한이 부족합니다.', + code: -32000, + data: { code: 'FORBIDDEN', httpStatus: 403, path: operation }, + }, + }; + return response({ + nation: { id: 1, name: '위', color: '#008000', level: 3 }, + viewer: { generalId: 1, permission: 1 }, + summary: { + gold: 1000, + rice: 2000, + crew: 300, + generalCount: 1, + averageGold: 1000, + averageRice: 2000, + readiness: { + 90: { crew: 300, generals: 1 }, + 80: { crew: 300, generals: 1 }, + 60: { crew: 300, generals: 1 }, + }, + }, + generals: [ + { + id: 1, + name: '테스트장수', + npcState: 0, + injury: 0, + stats: { leadership: 70, strength: 60, intelligence: 50 }, + leadershipBonus: 0, + experienceLevel: 9, + troopId: 0, + troopName: null, + gold: 1000, + rice: 2000, + cityId: 1, + cityName: '업', + defenceTrain: 90, + defenceTrainText: '☆', + crewTypeId: 1, + crew: 300, + train: 90, + atmos: 90, + killTurn: 7, + turnTime: '2026-01-01T01:02:00.000Z', + reservedCommands: ['징병', '훈련'], + }, + ], + }); + } + return { error: { message: `unhandled ${operation}`, data: { code: 'BAD_REQUEST' } } }; + }); + await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(results) }); + }); +}; + +test('nation generals keeps the 1000px legacy grid and redacted member columns', async ({ page }) => { + await install(page); + await page.setViewportSize({ width: 1200, height: 900 }); + await page.goto('nation/generals'); + await expect(page.locator('#nation-general-list')).toContainText('테스트장수'); + await expect(page.locator('#nation-general-list')).toContainText('?'); + const computed = await page.locator('.general-page').evaluate((element) => { + const rect = element.getBoundingClientRect(); + const style = getComputedStyle(element); + return { x: rect.x, width: rect.width, fontSize: style.fontSize, fontFamily: style.fontFamily }; + }); + expect(computed).toMatchObject({ x: 100, width: 1000, fontSize: '16px' }); + expect(computed.fontFamily).toContain('Times New Roman'); + expect(await page.locator('#nation-general-list').evaluate((el) => getComputedStyle(el).borderCollapse)).toBe( + 'separate' + ); + expect((await page.locator('#nation-general-list').boundingBox())?.width).toBe(1030); + expect((await page.locator('#nation-general-list tbody tr').boundingBox())?.height).toBe(66); +}); + +test('both pages preserve the legacy 1000px overflow contract at 500px', async ({ page }) => { + await install(page); + await page.setViewportSize({ width: 500, height: 900 }); + for (const path of ['nation/generals', 'nation/secret']) { + await page.goto(path); + await expect( + page.locator(path.endsWith('secret') ? '#secret-general-list' : '#nation-general-list') + ).toBeVisible(); + expect(await page.locator('main').evaluate((el) => el.getBoundingClientRect().width)).toBe(1000); + expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeGreaterThanOrEqual(1000); + } +}); + +test('secret office renders summary, turns, and the forbidden error flow', async ({ page }) => { + await install(page); + await page.setViewportSize({ width: 1200, height: 900 }); + await page.goto('nation/secret'); + await expect(page.locator('.summary')).toContainText('전체 금'); + await expect(page.locator('#secret-general-list')).toContainText('1 : 징병'); + expect(await page.locator('.secret-page').evaluate((el) => el.getBoundingClientRect().width)).toBe(1000); + + await page.unroute('**/che/api/trpc/**'); + await install(page, false); + await page.goto('nation/secret'); + await expect(page.getByRole('alert')).toContainText('권한이 부족합니다.'); + await expect(page.locator('#secret-general-list')).toHaveCount(0); +}); diff --git a/app/game-frontend/e2e/playwright.config.mjs b/app/game-frontend/e2e/playwright.config.mjs index ddee57a..273abd6 100644 --- a/app/game-frontend/e2e/playwright.config.mjs +++ b/app/game-frontend/e2e/playwright.config.mjs @@ -8,7 +8,13 @@ const baseURL = `http://127.0.0.1:${port}/che/`; export default defineConfig({ testDir: '.', - testMatch: ['troop.spec.ts', 'board.spec.ts', 'inGameInfo.spec.ts', 'nationOffices.spec.ts'], + testMatch: [ + 'troop.spec.ts', + 'board.spec.ts', + 'inGameInfo.spec.ts', + 'nationOffices.spec.ts', + 'nationGeneralSecret.spec.ts', + ], fullyParallel: false, workers: 1, timeout: 30_000, diff --git a/app/game-frontend/src/router/index.ts b/app/game-frontend/src/router/index.ts index a412e4c..651dd13 100644 --- a/app/game-frontend/src/router/index.ts +++ b/app/game-frontend/src/router/index.ts @@ -10,6 +10,7 @@ import NationInfoView from '../views/NationInfoView.vue'; import GlobalInfoView from '../views/GlobalInfoView.vue'; import CurrentCityView from '../views/CurrentCityView.vue'; import NationGeneralsView from '../views/NationGeneralsView.vue'; +import NationSecretView from '../views/NationSecretView.vue'; import NationPersonnelView from '../views/NationPersonnelView.vue'; import NationStratFinanView from '../views/NationStratFinanView.vue'; import ChiefCenterView from '../views/ChiefCenterView.vue'; @@ -148,6 +149,12 @@ const routes = [ requiresGeneral: true, }, }, + { + path: '/nation/secret', + name: 'nation-secret', + component: NationSecretView, + meta: { requiresAuth: true, requiresGeneral: true }, + }, { path: '/nation/personnel', name: 'nation-personnel', diff --git a/app/game-frontend/src/views/MainView.vue b/app/game-frontend/src/views/MainView.vue index c6e139c..258d795 100644 --- a/app/game-frontend/src/views/MainView.vue +++ b/app/game-frontend/src/views/MainView.vue @@ -104,6 +104,10 @@ watch( 중원 정보 현재 도시 세력 장수 + 암행부 + 암행부 인사부 부대 편성 내무부 diff --git a/app/game-frontend/src/views/NationGeneralsView.vue b/app/game-frontend/src/views/NationGeneralsView.vue index 763b854..347d0b7 100644 --- a/app/game-frontend/src/views/NationGeneralsView.vue +++ b/app/game-frontend/src/views/NationGeneralsView.vue @@ -1,349 +1,226 @@ diff --git a/app/game-frontend/src/views/NationSecretView.vue b/app/game-frontend/src/views/NationSecretView.vue new file mode 100644 index 0000000..aeb8c03 --- /dev/null +++ b/app/game-frontend/src/views/NationSecretView.vue @@ -0,0 +1,210 @@ + + + + + diff --git a/tools/frontend-legacy-parity/reference-general-lists.mjs b/tools/frontend-legacy-parity/reference-general-lists.mjs new file mode 100644 index 0000000..d0fcc2c --- /dev/null +++ b/tools/frontend-legacy-parity/reference-general-lists.mjs @@ -0,0 +1,91 @@ +import { chromium } from '@playwright/test'; +import { createHash } from 'node:crypto'; +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; + +const baseUrl = process.env.REF_GENERAL_URL ?? 'http://127.0.0.1:3400/sam/'; +const username = process.env.REF_GENERAL_USER ?? 'refuser1'; +const passwordFile = process.env.REF_GENERAL_PASSWORD_FILE; +const artifactRoot = resolve(process.env.REF_GENERAL_ARTIFACT_DIR ?? 'test-results/reference-general-lists'); +if (!passwordFile) throw new Error('REF_GENERAL_PASSWORD_FILE is required.'); +const password = (await readFile(passwordFile, 'utf8')).trim(); +await mkdir(artifactRoot, { recursive: true }); + +const measure = async (page, selectors) => + page.evaluate((items) => { + const result = {}; + for (const [name, selector] of Object.entries(items)) { + const element = document.querySelector(selector); + if (!element) { + result[name] = null; + continue; + } + const rect = element.getBoundingClientRect(); + const style = getComputedStyle(element); + result[name] = { + rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height }, + style: { + fontFamily: style.fontFamily, + fontSize: style.fontSize, + borderCollapse: style.borderCollapse, + backgroundImage: style.backgroundImage, + color: style.color, + }, + }; + } + return { elements: result, documentWidth: document.documentElement.scrollWidth }; + }, selectors); + +const browser = await chromium.launch({ headless: true }); +try { + const context = await browser.newContext({ + viewport: { width: 1200, height: 900 }, + deviceScaleFactor: 1, + locale: 'ko-KR', + colorScheme: 'dark', + }); + const page = await context.newPage(); + await page.goto(baseUrl, { waitUntil: 'networkidle' }); + const salt = await page.locator('#global_salt').inputValue(); + const passwordHash = createHash('sha512') + .update(salt + password + salt) + .digest('hex'); + const login = await context.request.post(new URL('api.php?path=Login/LoginByID', baseUrl).toString(), { + data: { username, password: passwordHash }, + }); + const loginResult = await login.json(); + if (!login.ok() || loginResult.result !== true) throw new Error('Reference login failed.'); + + const output = {}; + for (const [name, path, selectors] of [ + [ + 'generals', + 'hwe/b_myGenInfo.php', + { + body: 'body', + title: 'body > table:first-of-type', + list: 'body > table:nth-of-type(2)', + firstRow: 'body > table:nth-of-type(2) tr:nth-child(2)', + }, + ], + [ + 'secret', + 'hwe/b_genList.php', + { + body: 'body', + title: 'body > table:first-of-type', + summary: 'body > table:nth-of-type(2)', + list: '#general_list', + firstRow: '#general_list tbody tr:first-child', + }, + ], + ]) { + await page.goto(new URL(path, baseUrl).toString(), { waitUntil: 'networkidle' }); + output[name] = await measure(page, selectors); + await page.screenshot({ path: resolve(artifactRoot, `ref-${name}.png`), fullPage: true }); + } + await writeFile(resolve(artifactRoot, 'computed-dom.json'), `${JSON.stringify(output, null, 2)}\n`); + process.stdout.write(`${JSON.stringify({ ok: true, artifactRoot, output })}\n`); +} finally { + await browser.close(); +} From 1cba5bdfbf3b68ae1334518396681a196a561225 Mon Sep 17 00:00:00 2001 From: hided62 Date: Sun, 26 Jul 2026 05:17:59 +0000 Subject: [PATCH 08/19] Complete NPC policy API lifecycle parity --- app/game-api/src/router/npc/index.ts | 520 +++++++++--------- app/game-api/test/npcPolicyRouter.test.ts | 275 +++++++++ .../test/npcPolicyLifecycle.test.ts | 230 ++++++++ 3 files changed, 760 insertions(+), 265 deletions(-) create mode 100644 app/game-api/test/npcPolicyRouter.test.ts create mode 100644 app/game-engine/test/npcPolicyLifecycle.test.ts diff --git a/app/game-api/src/router/npc/index.ts b/app/game-api/src/router/npc/index.ts index e4d7781..87cfe3b 100644 --- a/app/game-api/src/router/npc/index.ts +++ b/app/game-api/src/router/npc/index.ts @@ -1,5 +1,3 @@ -import path from 'node:path'; - import { TRPCError } from '@trpc/server'; import { z } from 'zod'; @@ -165,8 +163,6 @@ const FLOAT_POLICY_KEYS = ['safeRecruitCityPopulationRatio'] as const; type NumericPolicyKey = (typeof INTEGER_POLICY_KEYS)[number]; type FloatPolicyKey = (typeof FLOAT_POLICY_KEYS)[number]; -const UNIT_SET_ROOT = path.resolve(process.cwd(), 'resources', 'unitset'); - const readNumber = (value: unknown, fallback = 0): number => { if (typeof value === 'number' && Number.isFinite(value)) { return value; @@ -353,8 +349,8 @@ const buildZeroPolicy = async ( } ): Promise => { const { statMax, statNpcMax, nationTech, develCost, defaultCrewTypeId, unitSetName } = options; - const unitSet = await loadUnitSetDefinitionByName(unitSetName, { unitSetRoot: UNIT_SET_ROOT }); - const crewType = findCrewTypeById(unitSet, defaultCrewTypeId); + const unitSet = await loadUnitSetDefinitionByName(unitSetName); + const crewType = findCrewTypeById(unitSet, defaultCrewTypeId || unitSet.defaultCrewTypeId || 0); const techCost = getTechCost(nationTech); const next = clonePolicy(policy); @@ -364,7 +360,7 @@ const buildZeroPolicy = async ( if (next.reqNPCWarGold === 0 || next.reqNPCWarRice === 0) { const baseGold = crewType ? crewType.cost * techCost * statNpcMax : 0; - const baseRice = statNpcMax; + const baseRice = crewType ? crewType.rice * techCost * statNpcMax : 0; if (next.reqNPCWarGold === 0) { next.reqNPCWarGold = roundTo(baseGold * 4, -2); } @@ -375,7 +371,7 @@ const buildZeroPolicy = async ( if (next.reqHumanWarUrgentGold === 0 || next.reqHumanWarUrgentRice === 0) { const baseGold = crewType ? crewType.cost * techCost * statMax : 0; - const baseRice = statMax; + const baseRice = crewType ? crewType.rice * techCost * statMax : 0; if (next.reqHumanWarUrgentGold === 0) { next.reqHumanWarUrgentGold = roundTo(baseGold * 6, -2); } @@ -415,8 +411,6 @@ const resolveSetterInfo = (policy: Record, kind: 'value' | 'pri }; }; -const ensureUniquePriority = (priority: string[]): string[] => Array.from(new Set(priority)); - const validateGeneralPriority = (priority: string[]): string | null => { const orderRequired: Array<[string, string]> = [['출병', '일반내정']]; const mustHave = new Set(['출병', '일반내정']); @@ -461,6 +455,7 @@ export const npcRouter = router({ id: true, name: true, level: true, + tech: true, meta: true, }, }), @@ -508,9 +503,9 @@ export const npcRouter = router({ const stat = resolveScenarioStat(config); const env = resolveCommandEnv(config); const unitSetName = resolveUnitSetName(config, 'che'); - const nationTech = readNumber(asRecord(nationMeta).tech, 0); + const nationTech = readNumber(nation.tech, 0); - const zeroPolicy = await buildZeroPolicy(defaultNationPolicy, { + const zeroPolicy = await buildZeroPolicy(DEFAULT_NATION_POLICY, { statMax: stat.max, statNpcMax: stat.npcMax, nationTech, @@ -542,277 +537,272 @@ export const npcRouter = router({ permissionLevel, }; }), - setNationPolicy: authedProcedure - .input(z.record(z.string(), z.unknown())) - .mutation(async ({ ctx, input }) => { - const general = await getMyGeneral(ctx); - if (general.nationId <= 0) { - throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'Nation membership required.' }); - } + setNationPolicy: authedProcedure.input(z.record(z.string(), z.unknown())).mutation(async ({ ctx, input }) => { + const general = await getMyGeneral(ctx); + if (general.nationId <= 0) { + throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'Nation membership required.' }); + } - const nation = await ctx.db.nation.findUnique({ - where: { id: general.nationId }, - select: { id: true, meta: true }, - }); - if (!nation) { - throw new TRPCError({ code: 'NOT_FOUND', message: 'Nation not found' }); - } + const nation = await ctx.db.nation.findUnique({ + where: { id: general.nationId }, + select: { id: true, meta: true }, + }); + if (!nation) { + throw new TRPCError({ code: 'NOT_FOUND', message: 'Nation not found' }); + } - const permissionLevel = resolveSecretPermission( - { - nationId: general.nationId, - officerLevel: general.officerLevel, - meta: general.meta, - penalty: general.penalty, - }, - nation.meta - ); - if (permissionLevel < 3) { - throw new TRPCError({ code: 'FORBIDDEN', message: '권한이 부족합니다.' }); - } + const permissionLevel = resolveSecretPermission( + { + nationId: general.nationId, + officerLevel: general.officerLevel, + meta: general.meta, + penalty: general.penalty, + }, + nation.meta + ); + if (permissionLevel < 3) { + throw new TRPCError({ code: 'FORBIDDEN', message: '권한이 부족합니다.' }); + } - const keys = Object.keys(input); - for (const key of keys) { - if (!NATION_POLICY_KEYS.has(key as keyof NationPolicy)) { + const keys = Object.keys(input); + for (const key of keys) { + if (!NATION_POLICY_KEYS.has(key as keyof NationPolicy)) { + throw new TRPCError({ code: 'BAD_REQUEST', message: `${key}는 올바른 정책값이 아닙니다.` }); + } + } + + const troopRows = await ctx.db.troop.findMany({ + where: { nationId: general.nationId }, + select: { troopLeaderId: true }, + }); + const cityRows = await ctx.db.city.findMany({ select: { id: true } }); + + const troopSet = new Set(troopRows.map((row) => row.troopLeaderId)); + const citySet = new Set(cityRows.map((row) => row.id)); + const assigned = new Set(); + + const nationMeta = asRecord(nation.meta); + const policyRoot = asRecord(nationMeta.npc_nation_policy); + const nextValues = applyPolicyValues(DEFAULT_NATION_POLICY, asRecord(policyRoot.values)); + + for (const key of INTEGER_POLICY_KEYS) { + if (!(key in input)) { + continue; + } + const value = input[key]; + if (typeof value !== 'number' || !Number.isFinite(value) || !Number.isInteger(value)) { + throw new TRPCError({ code: 'BAD_REQUEST', message: `${key}는 올바른 값이 아닙니다.` }); + } + nextValues[key] = Math.max(0, value); + } + + for (const key of FLOAT_POLICY_KEYS) { + if (!(key in input)) { + continue; + } + const value = input[key]; + if (typeof value !== 'number' || !Number.isFinite(value)) { + throw new TRPCError({ code: 'BAD_REQUEST', message: `${key}는 올바른 값이 아닙니다.` }); + } + nextValues[key] = value; + } + + if ('CombatForce' in input) { + const rawCombat = input.CombatForce; + if (!isRecord(rawCombat)) { + throw new TRPCError({ code: 'BAD_REQUEST', message: 'CombatForce는 올바른 정책값이 아닙니다.' }); + } + const combatForce: Record = {}; + for (const [rawKey, rawValue] of Object.entries(rawCombat)) { + const leaderId = Number(rawKey); + if (!Number.isFinite(leaderId)) { + throw new TRPCError({ code: 'BAD_REQUEST', message: `${rawKey}는 올바른 부대가 아닙니다.` }); + } + if (!troopSet.has(leaderId)) { + throw new TRPCError({ code: 'BAD_REQUEST', message: `${leaderId}는 국가의 부대가 아닙니다.` }); + } + if (assigned.has(leaderId)) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: `부대(${leaderId})는 하나의 역할만 지정할 수 있습니다.`, + }); + } + if (!Array.isArray(rawValue) || rawValue.length < 2) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: `${leaderId}의 입력양식이 올바르지 않습니다.`, + }); + } + const fromCity = Number(rawValue[0]); + const toCity = Number(rawValue[1]); + if (!citySet.has(fromCity) || !citySet.has(toCity)) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: `${leaderId}의 도시 ${fromCity}, ${toCity}가 올바른 도시 번호가 아닙니다.`, + }); + } + combatForce[leaderId] = [fromCity, toCity]; + assigned.add(leaderId); + } + nextValues.CombatForce = combatForce; + } + + for (const key of ['SupportForce', 'DevelopForce'] as const) { + if (!(key in input)) { + continue; + } + const rawList = input[key]; + if (!Array.isArray(rawList)) { + throw new TRPCError({ code: 'BAD_REQUEST', message: `${key}는 올바른 정책값이 아닙니다.` }); + } + const list: number[] = []; + for (const rawValue of rawList) { + if (typeof rawValue !== 'number' || !Number.isFinite(rawValue)) { throw new TRPCError({ code: 'BAD_REQUEST', message: `${key}는 올바른 정책값이 아닙니다.` }); } - } - - const troopRows = await ctx.db.troop.findMany({ - where: { nationId: general.nationId }, - select: { troopLeaderId: true }, - }); - const cityRows = await ctx.db.city.findMany({ select: { id: true } }); - - const troopSet = new Set(troopRows.map((row) => row.troopLeaderId)); - const citySet = new Set(cityRows.map((row) => row.id)); - const assigned = new Set(); - - const nationMeta = asRecord(nation.meta); - const policyRoot = asRecord(nationMeta.npc_nation_policy); - const nextValues = applyPolicyValues(DEFAULT_NATION_POLICY, asRecord(policyRoot.values)); - - for (const key of INTEGER_POLICY_KEYS) { - if (!(key in input)) { - continue; + if (!troopSet.has(rawValue)) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: `${rawValue}는 국가의 부대가 아닙니다.`, + }); } - const value = input[key]; - if (typeof value !== 'number' || !Number.isFinite(value) || !Number.isInteger(value)) { - throw new TRPCError({ code: 'BAD_REQUEST', message: `${key}는 올바른 값이 아닙니다.` }); + if (assigned.has(rawValue)) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: `부대(${rawValue})는 하나의 역할만 지정할 수 있습니다.`, + }); } - nextValues[key] = Math.max(0, value); + assigned.add(rawValue); + list.push(rawValue); } - - for (const key of FLOAT_POLICY_KEYS) { - if (!(key in input)) { - continue; - } - const value = input[key]; - if (typeof value !== 'number' || !Number.isFinite(value)) { - throw new TRPCError({ code: 'BAD_REQUEST', message: `${key}는 올바른 값이 아닙니다.` }); - } - nextValues[key] = Math.max(0, value); + if (key === 'SupportForce') { + nextValues.SupportForce = list; + } else { + nextValues.DevelopForce = list; } + } - if ('CombatForce' in input) { - const rawCombat = input.CombatForce; - if (!isRecord(rawCombat)) { - throw new TRPCError({ code: 'BAD_REQUEST', message: 'CombatForce는 올바른 정책값이 아닙니다.' }); - } - const combatForce: Record = {}; - for (const [rawKey, rawValue] of Object.entries(rawCombat)) { - const leaderId = Number(rawKey); - if (!Number.isFinite(leaderId)) { - throw new TRPCError({ code: 'BAD_REQUEST', message: `${rawKey}는 올바른 부대가 아닙니다.` }); - } - if (!troopSet.has(leaderId)) { - throw new TRPCError({ code: 'BAD_REQUEST', message: `${leaderId}는 국가의 부대가 아닙니다.` }); - } - if (assigned.has(leaderId)) { - throw new TRPCError({ - code: 'BAD_REQUEST', - message: `부대(${leaderId})는 하나의 역할만 지정할 수 있습니다.`, - }); - } - if (!Array.isArray(rawValue) || rawValue.length < 2) { - throw new TRPCError({ code: 'BAD_REQUEST', message: `${leaderId}의 입력양식이 올바르지 않습니다.` }); - } - const fromCity = Number(rawValue[0]); - const toCity = Number(rawValue[1]); - if (!citySet.has(fromCity) || !citySet.has(toCity)) { - throw new TRPCError({ - code: 'BAD_REQUEST', - message: `${leaderId}의 도시 ${fromCity}, ${toCity}가 올바른 도시 번호가 아닙니다.`, - }); - } - combatForce[leaderId] = [fromCity, toCity]; - assigned.add(leaderId); - } - nextValues.CombatForce = combatForce; + const nextPolicyRoot = { + ...policyRoot, + values: nextValues, + valueSetter: general.name, + valueSetTime: new Date().toISOString(), + }; + + await updateNationMeta( + ctx, + nation.id, + { + npc_nation_policy: nextPolicyRoot, + }, + nationMeta + ); + + return { ok: true }; + }), + setNationPriority: authedProcedure.input(z.array(z.string())).mutation(async ({ ctx, input }) => { + const general = await getMyGeneral(ctx); + if (general.nationId <= 0) { + throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'Nation membership required.' }); + } + + const nation = await ctx.db.nation.findUnique({ + where: { id: general.nationId }, + select: { id: true, meta: true }, + }); + if (!nation) { + throw new TRPCError({ code: 'NOT_FOUND', message: 'Nation not found' }); + } + + const permissionLevel = resolveSecretPermission( + { + nationId: general.nationId, + officerLevel: general.officerLevel, + meta: general.meta, + penalty: general.penalty, + }, + nation.meta + ); + if (permissionLevel < 3) { + throw new TRPCError({ code: 'FORBIDDEN', message: '권한이 부족합니다.' }); + } + + for (const item of input) { + if (!DEFAULT_NATION_PRIORITY.includes(item as (typeof DEFAULT_NATION_PRIORITY)[number])) { + throw new TRPCError({ code: 'BAD_REQUEST', message: `${item}은 올바른 명령이 아닙니다.` }); } + } - for (const key of ['SupportForce', 'DevelopForce'] as const) { - if (!(key in input)) { - continue; - } - const rawList = input[key]; - if (!Array.isArray(rawList)) { - throw new TRPCError({ code: 'BAD_REQUEST', message: `${key}는 올바른 정책값이 아닙니다.` }); - } - const list: number[] = []; - for (const rawValue of rawList) { - if (typeof rawValue !== 'number' || !Number.isFinite(rawValue)) { - throw new TRPCError({ code: 'BAD_REQUEST', message: `${key}는 올바른 정책값이 아닙니다.` }); - } - if (!troopSet.has(rawValue)) { - throw new TRPCError({ - code: 'BAD_REQUEST', - message: `${rawValue}는 국가의 부대가 아닙니다.`, - }); - } - if (assigned.has(rawValue)) { - throw new TRPCError({ - code: 'BAD_REQUEST', - message: `부대(${rawValue})는 하나의 역할만 지정할 수 있습니다.`, - }); - } - assigned.add(rawValue); - list.push(rawValue); - } - if (key === 'SupportForce') { - nextValues.SupportForce = list; - } else { - nextValues.DevelopForce = list; - } - } + const nationMeta = asRecord(nation.meta); + const policyRoot = asRecord(nationMeta.npc_nation_policy); + const nextPolicyRoot = { + ...policyRoot, + priority: input, + prioritySetter: general.name, + prioritySetTime: new Date().toISOString(), + }; - const nextPolicyRoot = { - ...policyRoot, - values: nextValues, - valueSetter: general.name, - valueSetTime: new Date().toISOString(), - }; + await updateNationMeta( + ctx, + nation.id, + { + npc_nation_policy: nextPolicyRoot, + }, + nationMeta + ); - await updateNationMeta( - ctx, - nation.id, - { - npc_nation_policy: nextPolicyRoot, - }, - nationMeta - ); + return { ok: true }; + }), + setGeneralPriority: authedProcedure.input(z.array(z.string())).mutation(async ({ ctx, input }) => { + const general = await getMyGeneral(ctx); + if (general.nationId <= 0) { + throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'Nation membership required.' }); + } - return { ok: true }; - }), - setNationPriority: authedProcedure - .input(z.array(z.string())) - .mutation(async ({ ctx, input }) => { - const general = await getMyGeneral(ctx); - if (general.nationId <= 0) { - throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'Nation membership required.' }); - } + const nation = await ctx.db.nation.findUnique({ + where: { id: general.nationId }, + select: { id: true, meta: true }, + }); + if (!nation) { + throw new TRPCError({ code: 'NOT_FOUND', message: 'Nation not found' }); + } - const nation = await ctx.db.nation.findUnique({ - where: { id: general.nationId }, - select: { id: true, meta: true }, - }); - if (!nation) { - throw new TRPCError({ code: 'NOT_FOUND', message: 'Nation not found' }); - } + const permissionLevel = resolveSecretPermission( + { + nationId: general.nationId, + officerLevel: general.officerLevel, + meta: general.meta, + penalty: general.penalty, + }, + nation.meta + ); + if (permissionLevel < 3) { + throw new TRPCError({ code: 'FORBIDDEN', message: '권한이 부족합니다.' }); + } - const permissionLevel = resolveSecretPermission( - { - nationId: general.nationId, - officerLevel: general.officerLevel, - meta: general.meta, - penalty: general.penalty, - }, - nation.meta - ); - if (permissionLevel < 3) { - throw new TRPCError({ code: 'FORBIDDEN', message: '권한이 부족합니다.' }); - } + const validationError = validateGeneralPriority(input); + if (validationError) { + throw new TRPCError({ code: 'BAD_REQUEST', message: validationError }); + } - const unique = ensureUniquePriority(input); - for (const item of unique) { - if (!DEFAULT_NATION_PRIORITY.includes(item as (typeof DEFAULT_NATION_PRIORITY)[number])) { - throw new TRPCError({ code: 'BAD_REQUEST', message: `${item}은 올바른 명령이 아닙니다.` }); - } - } + const nationMeta = asRecord(nation.meta); + const policyRoot = asRecord(nationMeta.npc_general_policy); + const nextPolicyRoot = { + ...policyRoot, + priority: input, + prioritySetter: general.name, + prioritySetTime: new Date().toISOString(), + }; - const nationMeta = asRecord(nation.meta); - const policyRoot = asRecord(nationMeta.npc_nation_policy); - const nextPolicyRoot = { - ...policyRoot, - priority: unique, - prioritySetter: general.name, - prioritySetTime: new Date().toISOString(), - }; + await updateNationMeta( + ctx, + nation.id, + { + npc_general_policy: nextPolicyRoot, + }, + nationMeta + ); - await updateNationMeta( - ctx, - nation.id, - { - npc_nation_policy: nextPolicyRoot, - }, - nationMeta - ); - - return { ok: true }; - }), - setGeneralPriority: authedProcedure - .input(z.array(z.string())) - .mutation(async ({ ctx, input }) => { - const general = await getMyGeneral(ctx); - if (general.nationId <= 0) { - throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'Nation membership required.' }); - } - - const nation = await ctx.db.nation.findUnique({ - where: { id: general.nationId }, - select: { id: true, meta: true }, - }); - if (!nation) { - throw new TRPCError({ code: 'NOT_FOUND', message: 'Nation not found' }); - } - - const permissionLevel = resolveSecretPermission( - { - nationId: general.nationId, - officerLevel: general.officerLevel, - meta: general.meta, - penalty: general.penalty, - }, - nation.meta - ); - if (permissionLevel < 3) { - throw new TRPCError({ code: 'FORBIDDEN', message: '권한이 부족합니다.' }); - } - - const unique = ensureUniquePriority(input); - const validationError = validateGeneralPriority(unique); - if (validationError) { - throw new TRPCError({ code: 'BAD_REQUEST', message: validationError }); - } - - const nationMeta = asRecord(nation.meta); - const policyRoot = asRecord(nationMeta.npc_general_policy); - const nextPolicyRoot = { - ...policyRoot, - priority: unique, - prioritySetter: general.name, - prioritySetTime: new Date().toISOString(), - }; - - await updateNationMeta( - ctx, - nation.id, - { - npc_general_policy: nextPolicyRoot, - }, - nationMeta - ); - - return { ok: true }; - }), + return { ok: true }; + }), }); diff --git a/app/game-api/test/npcPolicyRouter.test.ts b/app/game-api/test/npcPolicyRouter.test.ts new file mode 100644 index 0000000..052f35d --- /dev/null +++ b/app/game-api/test/npcPolicyRouter.test.ts @@ -0,0 +1,275 @@ +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 baseGeneral: GeneralRow = { + id: 22, + userId: 'user-22', + name: '정책담당', + nationId: 1, + cityId: 1, + troopId: 0, + npcState: 0, + affinity: null, + bornYear: 180, + deadYear: 300, + picture: 'default.jpg', + imageServer: 0, + leadership: 70, + strength: 70, + intel: 70, + injury: 0, + experience: 0, + dedication: 0, + officerLevel: 12, + gold: 1_000, + rice: 1_000, + crew: 0, + crewTypeId: 0, + train: 0, + atmos: 0, + weaponCode: 'None', + bookCode: 'None', + horseCode: 'None', + itemCode: 'None', + turnTime: new Date('2026-01-01T00:00:00.000Z'), + recentWarTime: null, + age: 20, + startAge: 20, + personalCode: 'None', + specialCode: 'None', + special2Code: 'None', + lastTurn: {}, + meta: { belong: 5, permission: 'normal' }, + penalty: {}, + createdAt: new Date('2026-01-01T00:00:00.000Z'), + updatedAt: new Date('2026-01-01T00:00:00.000Z'), +}; + +const auth: GameSessionTokenPayload = { + version: 1, + profile: 'che:default', + issuedAt: '2026-01-01T00:00:00.000Z', + expiresAt: '2026-01-02T00:00:00.000Z', + sessionId: 'session-22', + user: { id: 'user-22', username: 'tester', displayName: 'Tester', roles: [] }, + sanctions: {}, +}; + +const baseNation = { + id: 1, + name: '위', + level: 3, + tech: 3_000, + meta: { + _updatedAt: '2026-01-01T00:00:00.000Z', + npc_nation_policy: { + values: { reqNationRice: 456 }, + priority: ['천도', '천도'], + }, + npc_general_policy: { + priority: ['출병', '일반내정', '출병'], + }, + }, +}; + +const baseWorld = { + config: { + stat: { max: 80, npcMax: 75 }, + environment: { unitSet: 'basic' }, + const: { develCost: 100 }, + }, + meta: { + npc_nation_policy: { values: { reqNationGold: 123 } }, + npc_general_policy: {}, + }, +}; + +const createContext = ( + options: { + me?: GeneralRow; + nation?: typeof baseNation; + world?: typeof baseWorld; + requestCommand?: ReturnType; + troopRows?: Array<{ troopLeaderId: number }>; + cityRows?: Array<{ id: number }>; + } = {} +): { context: GameApiContext; findFirst: ReturnType; requestCommand: ReturnType } => { + const requestCommand = + options.requestCommand ?? + vi.fn(async () => ({ + type: 'setNationMeta', + ok: true, + nationId: 1, + updatedAt: '2026-01-01T00:01:00.000Z', + })); + const findFirst = vi.fn(async () => options.me ?? baseGeneral); + const db = { + general: { findFirst }, + nation: { findUnique: vi.fn(async () => options.nation ?? baseNation) }, + worldState: { findFirst: vi.fn(async () => options.world ?? baseWorld) }, + troop: { findMany: vi.fn(async () => options.troopRows ?? [{ troopLeaderId: 101 }]) }, + city: { findMany: vi.fn(async () => options.cityRows ?? [{ id: 1 }, { id: 2 }]) }, + }; + const redisClient = { get: async () => null, set: async () => null }; + return { + context: { + 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: new RedisAccessTokenStore(redisClient, 'che:default'), + flushStore: new InMemoryFlushStore(), + gameTokenSecret: 'test-secret', + }, + findFirst, + requestCommand, + }; +}; + +describe('NPC policy router', () => { + it('loads server and nation overrides while calculating legacy zero-value hints from nation tech', async () => { + const fixture = createContext(); + const result = await appRouter.createCaller(fixture.context).npc.getPolicy(); + + expect(fixture.findFirst).toHaveBeenCalledWith({ where: { userId: 'user-22' } }); + expect(result.currentNationPolicy).toMatchObject({ reqNationGold: 123, reqNationRice: 456 }); + expect(result.currentNationPriority).toEqual(['천도', '천도']); + expect(result.currentGeneralActionPriority).toEqual(['출병', '일반내정', '출병']); + expect(result.zeroPolicy).toMatchObject({ + reqNationGold: 10_000, + reqNationRice: 12_000, + reqNPCDevelGold: 3_000, + reqNPCWarGold: 3_900, + reqNPCWarRice: 3_900, + reqHumanWarUrgentGold: 6_300, + reqHumanWarUrgentRice: 6_300, + reqHumanWarRecommandGold: 12_600, + reqHumanWarRecommandRice: 12_600, + }); + }); + + it('lets a secret-level reader load the page but rejects every mutation before daemon dispatch', async () => { + const reader = { ...baseGeneral, officerLevel: 2 }; + const fixture = createContext({ me: reader }); + const caller = appRouter.createCaller(fixture.context); + + await expect(caller.npc.getPolicy()).resolves.toMatchObject({ permissionLevel: 1 }); + await expect(caller.npc.setNationPriority(['천도'])).rejects.toMatchObject({ code: 'FORBIDDEN' }); + await expect(caller.npc.setGeneralPriority(['출병', '일반내정'])).rejects.toMatchObject({ + code: 'FORBIDDEN', + }); + await expect(caller.npc.setNationPolicy({ reqNationGold: 100 })).rejects.toMatchObject({ + code: 'FORBIDDEN', + }); + expect(fixture.requestCommand).not.toHaveBeenCalled(); + }); + + it.each([ + ['군주', { ...baseGeneral, officerLevel: 12 }], + ['감찰권자', { ...baseGeneral, officerLevel: 1, meta: { belong: 0, permission: 'auditor' } }], + ['외교권자', { ...baseGeneral, officerLevel: 1, meta: { belong: 0, permission: 'ambassador' } }], + ])('%s can persist policy through the daemon-owned metadata command', async (_label, me) => { + const fixture = createContext({ me }); + await expect(appRouter.createCaller(fixture.context).npc.setNationPriority(['천도', '천도'])).resolves.toEqual({ + ok: true, + }); + expect(fixture.requestCommand).toHaveBeenCalledWith({ + type: 'setNationMeta', + nationId: 1, + updates: { + npc_nation_policy: expect.objectContaining({ + priority: ['천도', '천도'], + prioritySetter: '정책담당', + prioritySetTime: expect.any(String), + }), + }, + expectedUpdatedAt: '2026-01-01T00:00:00.000Z', + }); + }); + + it('clamps legacy integer values, preserves float values, and validates troop ownership before dispatch', async () => { + const fixture = createContext(); + const caller = appRouter.createCaller(fixture.context); + + await caller.npc.setNationPolicy({ + reqNationGold: -100, + safeRecruitCityPopulationRatio: -0.5, + CombatForce: { 101: [1, 2] }, + }); + expect(fixture.requestCommand).toHaveBeenCalledWith( + expect.objectContaining({ + updates: { + npc_nation_policy: expect.objectContaining({ + values: expect.objectContaining({ + reqNationGold: 0, + safeRecruitCityPopulationRatio: -0.5, + CombatForce: { 101: [1, 2] }, + }), + }), + }, + }) + ); + + fixture.requestCommand.mockClear(); + await expect(caller.npc.setNationPolicy({ SupportForce: [999] })).rejects.toMatchObject({ + code: 'BAD_REQUEST', + }); + expect(fixture.requestCommand).not.toHaveBeenCalled(); + }); + + it('preserves duplicate legacy priority entries and enforces required general actions and ordering', async () => { + const fixture = createContext(); + const caller = appRouter.createCaller(fixture.context); + + await caller.npc.setGeneralPriority(['출병', '출병', '일반내정']); + expect(fixture.requestCommand).toHaveBeenCalledWith( + expect.objectContaining({ + updates: { + npc_general_policy: expect.objectContaining({ + priority: ['출병', '출병', '일반내정'], + }), + }, + }) + ); + await expect(caller.npc.setGeneralPriority(['일반내정', '출병'])).rejects.toMatchObject({ + code: 'BAD_REQUEST', + }); + await expect(caller.npc.setGeneralPriority(['출병'])).rejects.toMatchObject({ code: 'BAD_REQUEST' }); + }); + + it('blocks nationless, penalized, and stale writers without changing lifecycle state directly', async () => { + const nationless = createContext({ me: { ...baseGeneral, nationId: 0, officerLevel: 0 } }); + await expect(appRouter.createCaller(nationless.context).npc.getPolicy()).rejects.toMatchObject({ + code: 'PRECONDITION_FAILED', + }); + + const penalized = createContext({ me: { ...baseGeneral, penalty: { noChief: true } } }); + await expect(appRouter.createCaller(penalized.context).npc.getPolicy()).rejects.toMatchObject({ + code: 'FORBIDDEN', + }); + + const staleCommand = vi.fn(async () => ({ + type: 'setNationMeta', + ok: false, + nationId: 1, + reason: 'CONFLICT', + })); + const stale = createContext({ requestCommand: staleCommand }); + await expect(appRouter.createCaller(stale.context).npc.setNationPriority(['천도'])).rejects.toMatchObject({ + code: 'CONFLICT', + }); + }); +}); diff --git a/app/game-engine/test/npcPolicyLifecycle.test.ts b/app/game-engine/test/npcPolicyLifecycle.test.ts new file mode 100644 index 0000000..18ca5af --- /dev/null +++ b/app/game-engine/test/npcPolicyLifecycle.test.ts @@ -0,0 +1,230 @@ +import { describe, expect, it } from 'vitest'; + +import type { TurnCommandEnv, TurnSchedule, UnitSetDefinition } from '@sammo-ts/logic'; +import { asRecord } from '@sammo-ts/common'; + +import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js'; +import { AutorunNationPolicy } from '../src/turn/ai/policies.js'; +import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js'; +import { createTurnDaemonCommandHandler } from '../src/turn/worldCommandHandler.js'; + +const schedule: TurnSchedule = { entries: [{ startMinute: 0, tickMinutes: 10 }] }; +const general: TurnGeneral = { + id: 1, + userId: 'owner-1', + name: 'NPC군주', + nationId: 1, + cityId: 1, + troopId: 0, + stats: { leadership: 75, strength: 40, intelligence: 70 }, + turnTime: new Date('0185-01-01T00:00:00Z'), + recentWarTime: null, + role: { + items: { horse: null, weapon: null, book: null, item: null }, + personality: null, + specialDomestic: null, + specialWar: null, + }, + triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} }, + meta: { killturn: 24 }, + penalty: {}, + officerLevel: 12, + experience: 0, + dedication: 0, + injury: 0, + gold: 1_000, + rice: 1_000, + crew: 0, + crewTypeId: 1100, + train: 0, + atmos: 0, + age: 30, + npcState: 2, +}; + +const snapshot: TurnWorldSnapshot = { + generals: [general], + cities: [ + { + id: 1, + name: '허창', + nationId: 1, + level: 7, + state: 0, + population: 100_000, + populationMax: 200_000, + agriculture: 1_000, + agricultureMax: 2_000, + commerce: 1_000, + commerceMax: 2_000, + security: 1_000, + securityMax: 2_000, + supplyState: 1, + frontState: 0, + defence: 1_000, + defenceMax: 2_000, + wall: 1_000, + wallMax: 2_000, + meta: {}, + }, + ], + nations: [ + { + id: 1, + name: '위', + color: '#777777', + capitalCityId: 1, + chiefGeneralId: 1, + gold: 10_000, + rice: 20_000, + power: 0, + level: 3, + typeCode: 'che_법가', + meta: { tech: 3_000, preserved: 'yes', _updatedAt: '2026-01-01T00:00:00.000Z' }, + }, + ], + troops: [], + diplomacy: [], + events: [], + initialEvents: [], + scenarioConfig: { + stat: { total: 300, min: 10, max: 80, npcTotal: 150, npcMax: 75, npcMin: 10, chiefMin: 65 }, + iconPath: '', + map: {}, + const: {}, + environment: { mapName: 'test', unitSet: 'basic' }, + }, + scenarioMeta: { + title: 'test', + startYear: 180, + life: null, + fiction: null, + history: [], + ignoreDefaultEvents: false, + }, + map: { + id: 'test', + name: 'test', + cities: [], + defaults: { trust: 50, trade: 100, supplyState: 1, frontState: 0 }, + }, +}; + +const state: TurnWorldState = { + id: 1, + currentYear: 185, + currentMonth: 1, + tickSeconds: 600, + lastTurnTime: new Date('0185-01-01T00:00:00Z'), + meta: { killturn: 24 }, +}; + +const commandEnv: TurnCommandEnv = { + baseGold: 1_000, + baseRice: 1_000, + develCost: 18, + maxResourceActionAmount: 10_000, + minAvailableRecruitPop: 30_000, + trainDelta: 5, + atmosDelta: 5, + maxTrainByCommand: 100, + maxAtmosByCommand: 100, + sabotageDefaultProb: 0.5, + sabotageProbCoefByStat: 0.01, + sabotageDefenceCoefByGeneralCount: 0.01, + sabotageDamageMin: 1, + sabotageDamageMax: 10, + defaultCrewTypeId: 1100, + maxGeneral: 100, + defaultNpcGold: 1_000, + defaultNpcRice: 1_000, + defaultSpecialDomestic: null, + defaultSpecialWar: null, + openingPartYear: 3, + initialNationGenLimit: 10, + maxTechLevel: 10, + techLevelIncYear: 5, + initialAllowedTechLevel: 1, +}; + +const unitSet: UnitSetDefinition = { + id: 'basic', + name: 'basic', + defaultCrewTypeId: 1100, + armTypes: { 1: '보병' }, + crewTypes: [ + { + id: 1100, + armType: 1, + name: '보병', + attack: 100, + defence: 150, + speed: 7, + avoid: 10, + magicCoef: 0, + cost: 9, + rice: 9, + requirements: [], + attackCoef: {}, + defenceCoef: {}, + info: [], + initSkillTrigger: null, + phaseSkillTrigger: null, + iActionList: null, + }, + ], +}; + +describe('NPC policy lifecycle', () => { + it('applies one CAS-protected metadata command and the next AI instance consumes it without scheduler changes', async () => { + const world = new InMemoryTurnWorld(state, snapshot, { schedule }); + const handler = createTurnDaemonCommandHandler({ world }); + const updates = { + npc_nation_policy: { + values: { reqNationGold: 4_321 }, + priority: ['천도'], + valueSetter: '정책담당', + }, + }; + + await expect( + handler.handle({ + type: 'setNationMeta', + nationId: 1, + updates, + expectedUpdatedAt: '2026-01-01T00:00:00.000Z', + }) + ).resolves.toMatchObject({ type: 'setNationMeta', ok: true, nationId: 1 }); + + const nation = world.getNationById(1)!; + expect(nation.meta).toMatchObject({ preserved: 'yes', npc_nation_policy: updates.npc_nation_policy }); + const policy = new AutorunNationPolicy({ + general: world.getGeneralById(1)!, + aiOptions: null, + nationPolicy: asRecord(nation.meta).npc_nation_policy as Record, + serverPolicy: null, + nation, + env: commandEnv, + scenarioConfig: snapshot.scenarioConfig, + unitSet, + }); + expect(policy.reqNationGold).toBe(4_321); + expect(policy.priority).toEqual(['천도']); + expect(policy.reqNpcDevelGold).toBe(540); + expect(policy.reqNpcWarGold).toBe(3_900); + expect(policy.reqNpcWarRice).toBe(3_900); + + await expect( + handler.handle({ + type: 'setNationMeta', + nationId: 1, + updates: { npc_nation_policy: { values: { reqNationGold: 9_999 } } }, + expectedUpdatedAt: '2026-01-01T00:00:00.000Z', + }) + ).resolves.toMatchObject({ type: 'setNationMeta', ok: false, reason: 'CONFLICT' }); + expect(asRecord(asRecord(world.getNationById(1)?.meta).npc_nation_policy).values).toEqual({ + reqNationGold: 4_321, + }); + expect(world.getState()).toMatchObject({ currentYear: 185, currentMonth: 1, tickSeconds: 600 }); + }); +}); From b82924b55f4a2235ffb0e3bc2a544331c4735b4f Mon Sep 17 00:00:00 2001 From: hided62 Date: Sun, 26 Jul 2026 05:18:04 +0000 Subject: [PATCH 09/19] Restore legacy NPC policy frontend --- app/game-frontend/e2e/npcPolicy.spec.ts | 338 ++++ app/game-frontend/e2e/playwright.config.mjs | 2 +- app/game-frontend/package.json | 1 + .../src/views/NpcControlView.vue | 1384 +++++++++-------- docs/frontend-legacy-parity.md | 19 +- .../reference-npc-policy.mjs | 117 ++ 6 files changed, 1168 insertions(+), 693 deletions(-) create mode 100644 app/game-frontend/e2e/npcPolicy.spec.ts create mode 100644 tools/frontend-legacy-parity/reference-npc-policy.mjs diff --git a/app/game-frontend/e2e/npcPolicy.spec.ts b/app/game-frontend/e2e/npcPolicy.spec.ts new file mode 100644 index 0000000..e627d63 --- /dev/null +++ b/app/game-frontend/e2e/npcPolicy.spec.ts @@ -0,0 +1,338 @@ +import { expect, test, type Page, type Route } from '@playwright/test'; +import { mkdir, readFile } from 'node:fs/promises'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +type FixtureState = { + permissionLevel: number; + failNextMutation?: boolean; + failLoad?: boolean; + mutations: string[]; +}; + +const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../..'); +const artifactRoot = process.env.NPC_POLICY_PARITY_ARTIFACT_DIR + ? resolve(process.env.NPC_POLICY_PARITY_ARTIFACT_DIR) + : null; +const imageRoots = [resolve(repositoryRoot, '../image'), resolve(repositoryRoot, '../../image')]; +const referenceAsset = async (relativePath: string): Promise => { + for (const root of imageRoots) { + try { + return await readFile(resolve(root, relativePath)); + } catch { + // Nested worktrees and the primary checkout have different image parents. + } + } + throw new Error(`Reference image not found: ${relativePath}`); +}; + +const response = (data: unknown) => ({ result: { data } }); +const errorResponse = (path: string, message: string, code = 'BAD_REQUEST') => ({ + error: { message, code: -32000, data: { code, httpStatus: code === 'FORBIDDEN' ? 403 : 400, path } }, +}); +const operationName = (route: Route): string => { + const url = new URL(route.request().url()); + return decodeURIComponent(url.pathname.slice(url.pathname.lastIndexOf('/trpc/') + 6)); +}; +const fulfillJson = (route: Route, body: unknown) => + route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(body) }); + +const nationPriority = [ + '불가침제의', + '선전포고', + '천도', + '유저장긴급포상', + '부대전방발령', + '유저장구출발령', + '유저장후방발령', + '부대유저장후방발령', + '유저장전방발령', + '유저장포상', + '부대구출발령', + '부대후방발령', + 'NPC긴급포상', + 'NPC구출발령', + 'NPC후방발령', + 'NPC포상', + 'NPC전방발령', + '유저장내정발령', + 'NPC내정발령', + 'NPC몰수', +]; +const generalPriority = [ + 'NPC사망대비', + '귀환', + '금쌀구매', + '출병', + '긴급내정', + '전투준비', + '전방워프', + 'NPC헌납', + '징병', + '후방워프', + '전쟁내정', + '소집해제', + '일반내정', + '내정워프', +]; +const policy = { + reqNationGold: 10_000, + reqNationRice: 12_000, + CombatForce: {}, + SupportForce: [], + DevelopForce: [], + reqHumanWarUrgentGold: 0, + reqHumanWarUrgentRice: 0, + reqHumanWarRecommandGold: 0, + reqHumanWarRecommandRice: 0, + reqHumanDevelGold: 10_000, + reqHumanDevelRice: 10_000, + reqNPCWarGold: 0, + reqNPCWarRice: 0, + reqNPCDevelGold: 0, + reqNPCDevelRice: 500, + minimumResourceActionAmount: 1_000, + maximumResourceActionAmount: 10_000, + minNPCWarLeadership: 40, + minWarCrew: 1_500, + minNPCRecruitCityPopulation: 50_000, + safeRecruitCityPopulationRatio: 0.5, + properWarTrainAtmos: 90, + cureThreshold: 10, +}; + +const policyFixture = (state: FixtureState) => ({ + nationId: 1, + nationName: '위', + nationLevel: 3, + defaultNationPolicy: policy, + currentNationPolicy: policy, + zeroPolicy: { + ...policy, + reqHumanWarUrgentGold: 7_600, + reqHumanWarUrgentRice: 7_600, + reqHumanWarRecommandGold: 15_200, + reqHumanWarRecommandRice: 15_200, + reqNPCWarGold: 2_700, + reqNPCWarRice: 2_700, + reqNPCDevelGold: 540, + }, + defaultNationPriority: nationPriority, + currentNationPriority: nationPriority, + availableNationPriorityItems: nationPriority, + defaultGeneralActionPriority: generalPriority, + currentGeneralActionPriority: generalPriority, + availableGeneralActionPriorityItems: generalPriority, + lastSetters: { + policy: { setter: null, date: null }, + nation: { setter: null, date: null }, + general: { setter: null, date: null }, + }, + defaultStatMax: 70, + defaultStatNpcMax: 75, + permissionLevel: state.permissionLevel, +}); + +const installFixture = async (page: Page, state: FixtureState) => { + await page.addInitScript(() => { + localStorage.setItem('sammo-game-token', 'ga_npc_policy_playwright'); + localStorage.setItem('sammo-game-profile', 'che:default'); + }); + for (const filename of ['back_walnut.jpg', 'back_green.jpg', 'back_blue.jpg']) { + await page.route(`**/image/game/${filename}`, async (route) => + route.fulfill({ + status: 200, + contentType: 'image/jpeg', + body: await referenceAsset(`game/${filename}`), + }) + ); + } + await page.route('**/che/api/trpc/**', async (route) => { + const operations = operationName(route).split(','); + const results = operations.map((operation) => { + if (operation === 'lobby.info') return response({ myGeneral: { id: 22, name: '정책담당' } }); + if (operation === 'join.getConfig') return response({}); + if (operation === 'npc.getPolicy') { + return state.failLoad + ? errorResponse(operation, '권한이 부족합니다.', 'FORBIDDEN') + : response(policyFixture(state)); + } + if ( + operation === 'npc.setNationPolicy' || + operation === 'npc.setNationPriority' || + operation === 'npc.setGeneralPriority' + ) { + state.mutations.push(operation); + if (state.failNextMutation) { + state.failNextMutation = false; + return errorResponse(operation, '권한이 부족합니다.', 'FORBIDDEN'); + } + return response({ ok: true }); + } + return errorResponse(operation, `Unhandled fixture operation: ${operation}`); + }); + await fulfillJson(route, results); + }); +}; + +const gotoPolicy = async (page: Page) => { + await page.goto('npc-control'); +}; + +const screenshot = async (page: Page, name: string) => { + if (!artifactRoot) return; + await mkdir(artifactRoot, { recursive: true }); + await page.screenshot({ path: resolve(artifactRoot, name), fullPage: true }); +}; + +test('desktop geometry, typography, textures, drag, focus, tooltip, and successful save match the reference', async ({ + page, +}) => { + const state: FixtureState = { permissionLevel: 4, mutations: [] }; + await installFixture(page, state); + await page.setViewportSize({ width: 1000, height: 900 }); + await gotoPolicy(page); + await expect(page.locator('#container')).toBeVisible(); + + const computed = await page.evaluate(() => { + const measure = (selector: string) => { + const element = document.querySelector(selector)!; + const rect = element.getBoundingClientRect(); + const style = getComputedStyle(element); + return { + x: rect.x, + y: rect.y, + width: rect.width, + height: rect.height, + display: style.display, + gridTemplateColumns: style.gridTemplateColumns, + fontFamily: style.fontFamily, + fontSize: style.fontSize, + lineHeight: style.lineHeight, + color: style.color, + backgroundImage: style.backgroundImage, + backgroundColor: style.backgroundColor, + }; + }; + return { + body: measure('body'), + container: measure('#container'), + topBar: measure('.top-back-bar'), + section: measure('.section_bar'), + form: measure('.form_list'), + field: measure('.policy-field'), + input: measure('.field-row input'), + control: measure('.control_bar'), + reset: measure('.reset_btn'), + submit: measure('.submit_btn'), + priorityPanel: measure('.priority-panel'), + priorityList: measure('.priority-list'), + inactiveHeader: measure('.inactive-header'), + activeItem: measure('.priority-column:nth-child(2) .priority-item'), + help: measure('.help-button'), + documentWidth: document.documentElement.scrollWidth, + }; + }); + + expect(computed.body).toMatchObject({ width: 1000, fontSize: '14px', lineHeight: '21px' }); + expect(computed.body.fontFamily).toContain('Pretendard'); + expect(computed.container).toMatchObject({ x: 0, y: 32, width: 1000 }); + expect(computed.container.backgroundImage).toContain('back_walnut.jpg'); + expect(computed.topBar).toMatchObject({ width: 1000, height: 32 }); + expect(computed.section).toMatchObject({ x: 1, y: 33, width: 998, height: 23 }); + expect(computed.section.backgroundImage).toContain('back_green.jpg'); + expect(computed.form).toMatchObject({ x: 9, width: 982 }); + expect(computed.form.gridTemplateColumns).toBe('491px 491px'); + expect(computed.field.width).toBeCloseTo(491, 0); + expect(computed.input).toMatchObject({ width: 224, height: 34 }); + expect(computed.reset).toMatchObject({ width: 150, height: 35.5, backgroundColor: 'rgb(48, 48, 48)' }); + expect(computed.submit).toMatchObject({ width: 150, height: 35.5, backgroundColor: 'rgb(55, 90, 127)' }); + expect(computed.priorityPanel.width).toBeCloseTo(499, 0); + expect(computed.priorityList.width).toBeCloseTo(229, 0); + expect(computed.inactiveHeader).toMatchObject({ height: 37, backgroundColor: 'rgb(214, 214, 214)' }); + expect(computed.activeItem.height).toBe(37); + expect(computed.help).toMatchObject({ width: 24, height: 22.375 }); + expect(computed.documentWidth).toBe(1000); + await screenshot(page, 'core-npc-policy-desktop-baseline.png'); + + const goldInput = page.getByLabel('국가 권장 금'); + await goldInput.focus(); + await expect(goldInput).toBeFocused(); + expect(await goldInput.evaluate((element) => getComputedStyle(element).outlineStyle)).not.toBe('none'); + + const help = page.getByRole('button', { name: '불가침제의 설명' }); + await help.hover(); + await expect.poll(() => help.evaluate((element) => getComputedStyle(element, '::after').opacity)).toBe('1'); + + const active = page.locator('.priority-panel').first().locator('.priority-column').nth(1).getByText('불가침제의'); + await active.dragTo( + page.locator('.priority-panel').first().locator('.priority-column').first().locator('.priority-list') + ); + await expect( + page.locator('.priority-panel').first().locator('.priority-column').first().getByText('불가침제의') + ).toBeVisible(); + + await goldInput.fill('12345'); + page.once('dialog', (dialog) => dialog.accept()); + await page.locator('#container > .control_bar').getByRole('button', { name: '설정' }).click(); + await expect(page.getByRole('status')).toContainText('NPC 정책이 반영되었습니다.'); + expect(state.mutations).toContain('npc.setNationPolicy'); + await screenshot(page, 'core-npc-policy-desktop.png'); +}); + +test('500px layout stacks policy fields and priority panels like the reference', async ({ page }) => { + await installFixture(page, { permissionLevel: 4, mutations: [] }); + await page.setViewportSize({ width: 500, height: 900 }); + await gotoPolicy(page); + await expect(page.locator('#container')).toBeVisible(); + + const geometry = await page.evaluate(() => { + const rect = (selector: string) => { + const value = document.querySelector(selector)!.getBoundingClientRect(); + return { x: value.x, y: value.y, width: value.width, height: value.height }; + }; + return { + container: rect('#container'), + form: rect('.form_list'), + firstField: rect('.policy-field'), + panels: [...document.querySelectorAll('.priority-panel')].map((element) => { + const value = element.getBoundingClientRect(); + return { x: value.x, y: value.y, width: value.width }; + }), + documentWidth: document.documentElement.scrollWidth, + }; + }); + + expect(geometry.container).toMatchObject({ x: 0, y: 32, width: 500 }); + expect(geometry.form).toMatchObject({ x: 9, width: 482 }); + expect(geometry.firstField.width).toBeCloseTo(482, 0); + expect(geometry.panels).toHaveLength(2); + expect(geometry.panels[0]).toMatchObject({ x: 1, width: 498 }); + expect(geometry.panels[1]?.x).toBe(1); + expect(geometry.panels[1]?.y).toBeGreaterThan(geometry.panels[0]?.y ?? 0); + expect(geometry.documentWidth).toBe(500); + await screenshot(page, 'core-npc-policy-mobile.png'); +}); + +test('a read-level user sees enabled legacy controls but a forbidden save retains the draft', async ({ page }) => { + const state: FixtureState = { permissionLevel: 1, failNextMutation: true, mutations: [] }; + await installFixture(page, state); + await gotoPolicy(page); + + const input = page.getByLabel('국가 권장 금'); + await expect(input).toBeEnabled(); + await input.fill('23456'); + page.once('dialog', (dialog) => dialog.accept()); + await page.locator('#container > .control_bar').getByRole('button', { name: '설정' }).click(); + await expect(page.getByRole('alert')).toContainText('권한이 부족합니다.'); + await expect(input).toHaveValue('23456'); + expect(state.mutations).toEqual(['npc.setNationPolicy']); +}); + +test('a user below secret read permission receives a recoverable page error', async ({ page }) => { + await installFixture(page, { permissionLevel: 0, failLoad: true, mutations: [] }); + await gotoPolicy(page); + await expect(page.getByRole('alert')).toContainText('권한이 부족합니다.'); + await expect(page.locator('#container')).toHaveCount(0); + await expect(page.getByRole('button', { name: '다시 시도' })).toBeVisible(); +}); diff --git a/app/game-frontend/e2e/playwright.config.mjs b/app/game-frontend/e2e/playwright.config.mjs index ddee57a..72a8a3f 100644 --- a/app/game-frontend/e2e/playwright.config.mjs +++ b/app/game-frontend/e2e/playwright.config.mjs @@ -8,7 +8,7 @@ const baseURL = `http://127.0.0.1:${port}/che/`; export default defineConfig({ testDir: '.', - testMatch: ['troop.spec.ts', 'board.spec.ts', 'inGameInfo.spec.ts', 'nationOffices.spec.ts'], + testMatch: ['troop.spec.ts', 'board.spec.ts', 'inGameInfo.spec.ts', 'nationOffices.spec.ts', 'npcPolicy.spec.ts'], fullyParallel: false, workers: 1, timeout: 30_000, diff --git a/app/game-frontend/package.json b/app/game-frontend/package.json index 48884db..a60d1d3 100644 --- a/app/game-frontend/package.json +++ b/app/game-frontend/package.json @@ -9,6 +9,7 @@ "preview": "vite preview", "test:e2e:troop": "playwright test troop.spec.ts --config e2e/playwright.config.mjs", "test:e2e:nation-offices": "playwright test nationOffices.spec.ts --config e2e/playwright.config.mjs", + "test:e2e:npc-policy": "playwright test npcPolicy.spec.ts --config e2e/playwright.config.mjs", "test:e2e:board": "playwright test board.spec.ts --config e2e/playwright.config.mjs", "lint": "eslint .", "lint:fix": "eslint . --fix", diff --git a/app/game-frontend/src/views/NpcControlView.vue b/app/game-frontend/src/views/NpcControlView.vue index e5b572c..94e34b9 100644 --- a/app/game-frontend/src/views/NpcControlView.vue +++ b/app/game-frontend/src/views/NpcControlView.vue @@ -1,78 +1,61 @@ diff --git a/docs/frontend-legacy-parity.md b/docs/frontend-legacy-parity.md index 0162c50..48721b0 100644 --- a/docs/frontend-legacy-parity.md +++ b/docs/frontend-legacy-parity.md @@ -57,8 +57,9 @@ storage, route guards, and image loading. | 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 | +| NPC policy | `hwe/v_NPCControl.php` | 1000/500px form and priority-list geometry, walnut/green textures, dynamic zero hints, drag/focus/tooltip, successful save and permission failures | | 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 | +| 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 @@ -96,6 +97,22 @@ reference collection, `tools/frontend-legacy-parity/reference-nation-offices.mjs records desktop/500px computed DOM and screenshots from the PHP service without changing its product code. +The NPC policy suite and its reference collector can be run independently: + +```sh +PLAYWRIGHT_FRONTEND_PORT=15126 \ + pnpm --filter @sammo-ts/game-frontend test:e2e:npc-policy + +REF_PARITY_USER=refuser1 \ +REF_PARITY_PASSWORD_FILE=/path/to/password-file \ +REF_PARITY_BASE_URL=http://127.0.0.1:3400/sam/ \ + node tools/frontend-legacy-parity/reference-npc-policy.mjs +``` + +The collector writes desktop and 500px screenshots plus computed DOM JSON only +when `REF_PARITY_ARTIFACT_DIR` is set. It requires an existing reference general +owned by the supplied account and never accepts a password on the command line. + For a review run that also writes full-page screenshots, create an ignored artifact directory and set `FRONTEND_PARITY_ARTIFACT_DIR` before invoking the suite. The ordinary CI run does not write screenshots after successful tests. diff --git a/tools/frontend-legacy-parity/reference-npc-policy.mjs b/tools/frontend-legacy-parity/reference-npc-policy.mjs new file mode 100644 index 0000000..16e6a8c --- /dev/null +++ b/tools/frontend-legacy-parity/reference-npc-policy.mjs @@ -0,0 +1,117 @@ +import { chromium } from '@playwright/test'; +import { createHash } from 'node:crypto'; +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; + +const baseUrl = process.env.REF_PARITY_URL ?? 'http://127.0.0.1:3400/sam/'; +const username = process.env.REF_PARITY_USER ?? 'refadmin'; +const passwordFile = process.env.REF_PARITY_PASSWORD_FILE; +const artifactRoot = resolve(process.env.REF_PARITY_ARTIFACT_DIR ?? 'test-results/reference-npc-policy'); + +if (!passwordFile) { + throw new Error('REF_PARITY_PASSWORD_FILE is required.'); +} + +const password = (await readFile(passwordFile, 'utf8')).trim(); +await mkdir(artifactRoot, { recursive: true }); + +const login = async (context, page) => { + await page.goto(baseUrl, { waitUntil: 'networkidle' }); + const globalSalt = await page.locator('#global_salt').inputValue(); + const passwordHash = createHash('sha512') + .update(globalSalt + password + globalSalt) + .digest('hex'); + const response = await context.request.post(new URL('api.php?path=Login/LoginByID', baseUrl).toString(), { + data: { username, password: passwordHash }, + }); + const result = await response.json(); + if (!response.ok() || result.result !== true) { + throw new Error('Reference login failed.'); + } +}; + +const browser = await chromium.launch({ headless: true }); +try { + const result = {}; + for (const viewport of [ + { name: 'desktop', width: 1000, height: 900 }, + { name: 'mobile', width: 500, height: 900 }, + ]) { + const context = await browser.newContext({ + viewport: { width: viewport.width, height: viewport.height }, + deviceScaleFactor: 1, + locale: 'ko-KR', + timezoneId: 'Asia/Seoul', + colorScheme: 'dark', + }); + const page = await context.newPage(); + await login(context, page); + await page.goto(new URL('hwe/', baseUrl).toString(), { waitUntil: 'networkidle' }); + await page.goto(new URL('hwe/v_NPCControl.php', baseUrl).toString(), { waitUntil: 'networkidle' }); + try { + await page.locator('#container').waitFor({ timeout: 10_000 }); + } catch { + throw new Error( + `Reference NPC policy failed to mount: ${JSON.stringify({ + url: page.url(), + text: (await page.locator('body').innerText()).slice(0, 500), + })}` + ); + } + result[viewport.name] = await page.evaluate(() => { + const measure = (selector) => { + const element = document.querySelector(selector); + if (!element) return null; + const rect = element.getBoundingClientRect(); + const style = getComputedStyle(element); + return { + rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height }, + style: { + display: style.display, + gridTemplateColumns: style.gridTemplateColumns, + fontFamily: style.fontFamily, + fontSize: style.fontSize, + lineHeight: style.lineHeight, + color: style.color, + backgroundColor: style.backgroundColor, + backgroundImage: style.backgroundImage, + borderColor: style.borderColor, + padding: style.padding, + margin: style.margin, + cursor: style.cursor, + }, + }; + }; + return { + body: measure('body'), + container: measure('#container'), + topBackBar: measure('body > :first-child'), + sectionBar: measure('.section_bar'), + formList: measure('.form_list'), + firstField: measure('.form_list > .col'), + firstInput: measure('input[type="number"]'), + firstInfoButton: measure('.form_list button'), + controlBar: measure('.control_bar'), + resetButton: measure('.reset_btn'), + submitButton: measure('.submit_btn'), + priorityGrid: measure('.half_section_left'), + priorityColumn: measure('.priority-list'), + priorityItem: measure('.priority-list .list-group-item'), + helpButton: measure('.priority_info button'), + document: { + width: document.documentElement.scrollWidth, + height: document.documentElement.scrollHeight, + }, + }; + }); + await page.screenshot({ + path: resolve(artifactRoot, `ref-npc-policy-${viewport.name}.png`), + fullPage: true, + }); + await context.close(); + } + await writeFile(resolve(artifactRoot, 'computed-dom.json'), `${JSON.stringify(result, null, 2)}\n`); + process.stdout.write(`${JSON.stringify({ ok: true, artifactRoot, viewports: Object.keys(result) })}\n`); +} finally { + await browser.close(); +} From 0127fd501a36c47764ec538bb0f2e4998a9de0ae Mon Sep 17 00:00:00 2001 From: hided62 Date: Sun, 26 Jul 2026 05:20:32 +0000 Subject: [PATCH 10/19] Complete legacy-compatible in-game information menus --- app/game-api/src/router/general/index.ts | 39 +- .../router/nation/endpoints/getGeneralList.ts | 317 +++++- app/game-api/src/router/nation/index.ts | 3 +- app/game-api/src/router/public/index.ts | 117 ++ .../test/inGameMenuPermissions.test.ts | 257 +++++ .../test/nationGeneralSecretRouter.test.ts | 209 ++++ app/game-api/test/publicTraffic.test.ts | 115 ++ .../src/turn/worldCommandHandler.ts | 77 +- .../test/myInformationCommands.test.ts | 179 +++ app/game-frontend/e2e/inGameMenus.spec.ts | 314 +++++ app/game-frontend/e2e/playwright.config.mjs | 2 +- app/game-frontend/src/router/index.ts | 20 +- .../src/views/BattleCenterView.vue | 236 +++- app/game-frontend/src/views/MainView.vue | 7 +- app/game-frontend/src/views/MyPageView.vue | 1010 +++++++++-------- .../src/views/NationGeneralsView.vue | 538 ++++----- .../src/views/NationSecretView.vue | 250 ++++ app/game-frontend/src/views/PublicView.vue | 1 + app/game-frontend/src/views/TrafficView.vue | 343 ++++++ .../reference-ingame-menus.mjs | 175 +++ 20 files changed, 3269 insertions(+), 940 deletions(-) create mode 100644 app/game-api/test/inGameMenuPermissions.test.ts create mode 100644 app/game-api/test/nationGeneralSecretRouter.test.ts create mode 100644 app/game-api/test/publicTraffic.test.ts create mode 100644 app/game-engine/test/myInformationCommands.test.ts create mode 100644 app/game-frontend/e2e/inGameMenus.spec.ts create mode 100644 app/game-frontend/src/views/NationSecretView.vue create mode 100644 app/game-frontend/src/views/TrafficView.vue create mode 100644 tools/frontend-legacy-parity/reference-ingame-menus.mjs diff --git a/app/game-api/src/router/general/index.ts b/app/game-api/src/router/general/index.ts index 97a0bf9..4405b25 100644 --- a/app/game-api/src/router/general/index.ts +++ b/app/game-api/src/router/general/index.ts @@ -37,15 +37,19 @@ const normalizeItemCode = (value: string | null): string | null => { }; const resolveUserSettings = (meta: Record) => { - const settings = asRecord(meta.userSettings); - const mysetRaw = settings.myset; + // The legacy general columns are persisted at the top level of General.meta. + // Keep reading the short-lived nested shape for installations that ran the + // initial rewrite implementation before this compatibility fix. + const nestedSettings = asRecord(meta.userSettings); + const readSetting = (key: string): unknown => meta[key] ?? nestedSettings[key]; + const mysetRaw = readSetting('myset'); const myset = typeof mysetRaw === 'number' && Number.isFinite(mysetRaw) ? mysetRaw : null; return { - tnmt: readNumber(settings.tnmt, 1), - defence_train: readNumber(settings.defence_train, 80), - use_treatment: readNumber(settings.use_treatment, 10), - use_auto_nation_turn: readNumber(settings.use_auto_nation_turn, 1), + tnmt: readNumber(readSetting('tnmt'), 1), + defence_train: readNumber(readSetting('defence_train'), 80), + use_treatment: readNumber(readSetting('use_treatment'), 10), + use_auto_nation_turn: readNumber(readSetting('use_auto_nation_turn'), 1), myset, }; }; @@ -262,29 +266,6 @@ export const generalRouter = router({ throw new TRPCError({ code: 'BAD_REQUEST', message: result.reason }); } - const metaRecord = asRecord(general.meta); - const prevSettings = asRecord(metaRecord.userSettings); - const prevMyset = typeof prevSettings.myset === 'number' && Number.isFinite(prevSettings.myset) - ? prevSettings.myset - : null; - const nextSettings = { - ...prevSettings, - ...input, - } as Record; - if (typeof prevMyset === 'number') { - nextSettings.myset = Math.max(0, prevMyset - 1); - } - - await ctx.db.general.update({ - where: { id: general.id }, - data: { - meta: { - ...metaRecord, - userSettings: nextSettings, - }, - } as any, - }); - return { ok: true }; }), dropItem: authedProcedure.input(z.object({ itemType: z.string() })).mutation(async ({ ctx, input }) => { diff --git a/app/game-api/src/router/nation/endpoints/getGeneralList.ts b/app/game-api/src/router/nation/endpoints/getGeneralList.ts index 8223e9b..171e452 100644 --- a/app/game-api/src/router/nation/endpoints/getGeneralList.ts +++ b/app/game-api/src/router/nation/endpoints/getGeneralList.ts @@ -1,84 +1,279 @@ import { TRPCError } from '@trpc/server'; +import { asRecord } from '@sammo-ts/common'; + +import type { GameApiContext } from '../../../context.js'; import { authedProcedure } from '../../../trpc.js'; import { getMyGeneral } from '../../shared/general.js'; -import { assertNationAccess, loadTraitNames, mapGeneralList, resolveChiefStatMin } from '../shared.js'; +import { + assertNationAccess, + loadTraitNames, + resolveNationPermission, + resolveOfficerCity, +} from '../shared.js'; -export const getGeneralList = authedProcedure.query(async ({ ctx }) => { - const general = await getMyGeneral(ctx); - assertNationAccess(general); +const MAX_DEDICATION_LEVEL = 10; - const [nation, cityRows, troopRows, generalRows, worldState] = await Promise.all([ - ctx.db.nation.findUnique({ - where: { id: general.nationId }, - select: { - id: true, - name: true, - color: true, - level: true, - typeCode: true, - capitalCityId: true, - meta: true, - }, - }), - ctx.db.city.findMany({ select: { id: true, name: true } }), - ctx.db.troop.findMany({ select: { troopLeaderId: true, name: true } }), - ctx.db.general.findMany({ - where: { nationId: general.nationId }, - select: { - id: true, - name: true, - npcState: true, - nationId: true, - cityId: true, - troopId: true, - picture: true, - imageServer: true, - officerLevel: true, - leadership: true, - strength: true, - intel: true, - experience: true, - dedication: true, - injury: true, - gold: true, - rice: true, - crew: true, - personalCode: true, - specialCode: true, - special2Code: true, - meta: true, - penalty: true, - }, - orderBy: { id: 'asc' }, - }), - ctx.db.worldState.findFirst(), - ]); +const readNumber = (record: Record, keys: string[], fallback = 0): number => { + for (const key of keys) { + const value = record[key]; + if (typeof value === 'number' && Number.isFinite(value)) { + return value; + } + } + return fallback; +}; +const experienceLevel = (experience: number): number => + Math.max(0, Math.min(100, experience < 1_000 ? Math.floor(experience / 100) : Math.floor(Math.sqrt(experience / 10)))); + +const dedicationLevel = (dedication: number): number => + Math.max(0, Math.min(MAX_DEDICATION_LEVEL, Math.ceil(Math.sqrt(Math.max(0, dedication)) / 10))); + +const dedicationLevelText = (level: number): string => + level === 0 ? '무품관' : `${MAX_DEDICATION_LEVEL - level + 1}품관`; + +const honorText = (experience: number): string => { + const levels: Array<[number, string]> = [ + [640, '전무'], + [2_560, '무명'], + [5_760, '신동'], + [10_240, '약간'], + [16_000, '평범'], + [23_040, '지역적'], + [31_360, '전국적'], + [40_960, '세계적'], + [45_000, '유명'], + [51_840, '명사'], + [55_000, '호걸'], + [64_000, '효웅'], + [77_440, '영웅'], + ]; + return levels.find(([limit]) => experience < limit)?.[1] ?? '구세주'; +}; + +const leadershipBonus = (officerLevel: number, nationLevel: number): number => { + if (officerLevel === 12) return nationLevel * 2; + if (officerLevel >= 5) return nationLevel; + return 0; +}; + +const woundedStat = (value: number, injury: number): number => + injury > 0 ? Math.floor((value * (100 - injury)) / 100) : value; + +const defenceTrainText = (value: number): string => { + if (value === 999) return '×'; + if (value >= 90) return '☆'; + if (value >= 80) return '◎'; + if (value >= 60) return '○'; + return '△'; +}; + +const loadNationGeneralData = async (ctx: GameApiContext) => { + const me = await getMyGeneral(ctx); + assertNationAccess(me); + + const nation = await ctx.db.nation.findUnique({ + where: { id: me.nationId }, + select: { + id: true, + name: true, + color: true, + level: true, + typeCode: true, + capitalCityId: true, + meta: true, + }, + }); if (!nation) { throw new TRPCError({ code: 'NOT_FOUND', message: 'Nation not found' }); } + const viewerPermission = resolveNationPermission(me, nation.meta, true); - const cityNameMap = new Map(cityRows.map((city) => [city.id, city.name])); - const troopNameMap = new Map(troopRows.map((troop) => [troop.troopLeaderId, troop.name])); - const list = await mapGeneralList(generalRows, cityNameMap, troopNameMap); - const nationTrait = (await loadTraitNames([nation.typeCode], 'nation')).get(nation.typeCode); + const [cityRows, troopRows, generalRows] = await Promise.all([ + ctx.db.city.findMany({ select: { id: true, name: true } }), + ctx.db.troop.findMany({ + where: { nationId: me.nationId }, + select: { troopLeaderId: true, name: true }, + }), + ctx.db.general.findMany({ + where: { nationId: me.nationId }, + orderBy: [{ turnTime: 'asc' }, { id: 'asc' }], + }), + ]); + const generalIds = generalRows.map((general) => general.id); + const [accessRows, turnRows] = await Promise.all([ + ctx.db.generalAccessLog.findMany({ + where: { generalId: { in: generalIds } }, + select: { generalId: true, refreshScore: true, refreshScoreTotal: true }, + }), + viewerPermission >= 1 + ? ctx.db.generalTurn.findMany({ + where: { generalId: { in: generalIds }, turnIdx: { lt: 5 } }, + select: { generalId: true, turnIdx: true, actionCode: true }, + orderBy: [{ generalId: 'asc' }, { turnIdx: 'asc' }], + }) + : Promise.resolve([]), + ]); + + const cityNames = new Map(cityRows.map((city) => [city.id, city.name])); + const troopNames = new Map(troopRows.map((troop) => [troop.troopLeaderId, troop.name])); + const accessByGeneral = new Map(accessRows.map((row) => [row.generalId, row])); + const turnsByGeneral = new Map(); + for (const turn of turnRows) { + const turns = turnsByGeneral.get(turn.generalId) ?? []; + turns[turn.turnIdx] = turn.actionCode; + turnsByGeneral.set(turn.generalId, turns); + } + + const [personalityMap, domesticMap, warMap] = await Promise.all([ + loadTraitNames(generalRows.map((general) => general.personalCode), 'personality'), + loadTraitNames(generalRows.map((general) => general.specialCode), 'domestic'), + loadTraitNames(generalRows.map((general) => general.special2Code), 'war'), + ]); + + const generals = generalRows.map((general) => { + const meta = asRecord(general.meta); + const officerCity = resolveOfficerCity(meta); + const access = accessByGeneral.get(general.id); + const dedLevel = dedicationLevel(general.dedication); + const actualOfficerLevel = general.officerLevel; + const visibleOfficerLevel = + viewerPermission >= 1 || actualOfficerLevel >= 5 ? actualOfficerLevel : Math.min(1, actualOfficerLevel); + const bonus = leadershipBonus(actualOfficerLevel, nation.level); + const detail = + viewerPermission >= 1 + ? { + officerLevel: actualOfficerLevel, + officerCity, + officerCityName: officerCity > 0 ? (cityNames.get(officerCity) ?? null) : null, + cityId: general.cityId, + cityName: cityNames.get(general.cityId) ?? null, + troopId: general.troopId, + troopName: troopNames.get(general.troopId) ?? null, + defenceTrain: readNumber(meta, ['defenceTrain', 'defence_train'], 80), + crewTypeId: general.crewTypeId, + crew: general.crew, + train: general.train, + atmos: general.atmos, + experience: general.experience, + dedication: general.dedication, + turnTime: general.turnTime.toISOString(), + recentWarTime: general.recentWarTime?.toISOString() ?? null, + killTurn: readNumber(meta, ['killturn', 'killTurn']), + refreshScore: access?.refreshScore ?? 0, + reservedCommands: general.npcState < 2 ? (turnsByGeneral.get(general.id) ?? []) : [], + } + : null; + + return { + id: general.id, + name: general.name, + npcState: general.npcState, + picture: general.picture, + imageServer: general.imageServer, + injury: general.injury, + stats: { + leadership: woundedStat(general.leadership, general.injury), + strength: woundedStat(general.strength, general.injury), + intelligence: woundedStat(general.intel, general.injury), + }, + leadershipBonus: bonus, + officerLevel: visibleOfficerLevel, + experienceLevel: experienceLevel(general.experience), + honorText: honorText(general.experience), + dedicationLevel: dedLevel, + dedicationLevelText: dedicationLevelText(dedLevel), + bill: dedLevel * 200 + 400, + gold: general.gold, + rice: general.rice, + age: general.age, + belong: readNumber(meta, ['belong']), + refreshScoreTotal: access?.refreshScoreTotal ?? 0, + personality: general.personalCode === 'None' ? null : (personalityMap.get(general.personalCode) ?? null), + specialDomestic: + general.specialCode === 'None' ? null : (domesticMap.get(general.specialCode) ?? null), + specialWar: general.special2Code === 'None' ? null : (warMap.get(general.special2Code) ?? null), + detail, + }; + }); return { + me, nation: { id: nation.id, name: nation.name, color: nation.color, level: nation.level, typeCode: nation.typeCode, - type: { - key: nation.typeCode, - name: nationTrait?.name ?? nation.typeCode, - info: nationTrait?.info ?? '', - }, capitalCityId: nation.capitalCityId ?? 0, }, - chiefStatMin: resolveChiefStatMin(worldState), - generals: list, + viewerPermission, + generals, + }; +}; + +export const getGeneralList = authedProcedure.query(async ({ ctx }) => { + const data = await loadNationGeneralData(ctx); + return { + nation: data.nation, + viewer: { generalId: data.me.id, permission: data.viewerPermission }, + generals: data.generals, + }; +}); + +export const getSecretGeneralList = authedProcedure.query(async ({ ctx }) => { + const data = await loadNationGeneralData(ctx); + if (data.viewerPermission < 1) { + throw new TRPCError({ + code: 'FORBIDDEN', + message: '권한이 부족합니다. 수뇌부가 아니거나 사관년도가 부족합니다.', + }); + } + + const visibleGenerals = data.generals.filter((general) => general.npcState !== 5); + const summaryBase = visibleGenerals.reduce( + (summary, general) => { + const detail = general.detail; + if (!detail) return summary; + summary.gold += general.gold; + summary.rice += general.rice; + summary.crew += detail.crew; + if (detail.crew > 0) { + for (const threshold of [90, 80, 60] as const) { + if (detail.train >= threshold && detail.atmos >= threshold) { + summary.readiness[threshold].crew += detail.crew; + summary.readiness[threshold].generals += 1; + } + } + } + return summary; + }, + { + gold: 0, + rice: 0, + crew: 0, + readiness: { + 90: { crew: 0, generals: 0 }, + 80: { crew: 0, generals: 0 }, + 60: { crew: 0, generals: 0 }, + }, + } + ); + const generalCount = visibleGenerals.length; + + return { + nation: data.nation, + viewer: { generalId: data.me.id, permission: data.viewerPermission }, + summary: { + ...summaryBase, + generalCount, + averageGold: generalCount ? summaryBase.gold / generalCount : 0, + averageRice: generalCount ? summaryBase.rice / generalCount : 0, + }, + generals: data.generals.map((general) => ({ + ...general, + defenceTrainText: defenceTrainText(general.detail?.defenceTrain ?? 0), + })), }; }); diff --git a/app/game-api/src/router/nation/index.ts b/app/game-api/src/router/nation/index.ts index e810b96..4fece4a 100644 --- a/app/game-api/src/router/nation/index.ts +++ b/app/game-api/src/router/nation/index.ts @@ -4,7 +4,7 @@ import { changePermission } from './endpoints/changePermission.js'; import { getBattleCenter } from './endpoints/getBattleCenter.js'; import { getChiefCenter } from './endpoints/getChiefCenter.js'; import { getCityOverview } from './endpoints/getCityOverview.js'; -import { getGeneralList } from './endpoints/getGeneralList.js'; +import { getGeneralList, getSecretGeneralList } from './endpoints/getGeneralList.js'; import { getGeneralLog } from './endpoints/getGeneralLog.js'; import { getNationInfo } from './endpoints/getNationInfo.js'; import { getPersonnelInfo } from './endpoints/getPersonnelInfo.js'; @@ -21,6 +21,7 @@ import { setSecretLimit } from './endpoints/setSecretLimit.js'; export const nationRouter = router({ getNationInfo, getGeneralList, + getSecretGeneralList, getCityOverview, getPersonnelInfo, getStratFinan, diff --git a/app/game-api/src/router/public/index.ts b/app/game-api/src/router/public/index.ts index e7a0f59..f4ef4d8 100644 --- a/app/game-api/src/router/public/index.ts +++ b/app/game-api/src/router/public/index.ts @@ -42,6 +42,14 @@ type NationCountRow = { type NpcListSort = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8; +type TrafficHistoryItem = { + year: number; + month: number; + refresh: number; + online: number; + date: string; +}; + const PUBLIC_CACHE_TTL_SECONDS = 600; const buildPublicCacheKey = (ctx: GameApiContext, key: string): string => @@ -163,6 +171,26 @@ const readFiniteMetaNumber = (meta: Record, key: string): numbe return typeof value === 'number' && Number.isFinite(value) ? value : 0; }; +const parseTrafficHistory = (value: unknown): TrafficHistoryItem[] => { + if (!Array.isArray(value)) { + return []; + } + + const result: TrafficHistoryItem[] = []; + for (const item of value) { + const row = asRecord(item); + const year = readFiniteMetaNumber(row, 'year'); + const month = readFiniteMetaNumber(row, 'month'); + const refresh = readFiniteMetaNumber(row, 'refresh'); + const online = readFiniteMetaNumber(row, 'online'); + const date = typeof row.date === 'string' ? row.date : ''; + if (year > 0 && month > 0 && date) { + result.push({ year, month, refresh, online, date }); + } + } + return result; +}; + const compareString = (left: string, right: string): number => { if (left === right) { return 0; @@ -222,6 +250,95 @@ export const publicRouter = router({ getNationList: procedure.query(async ({ ctx }) => { return loadCachedNationList(ctx); }), + getTraffic: procedure.query(async ({ ctx }) => { + const worldState = await ctx.db.worldState.findFirst(); + if (!worldState) { + throw new TRPCError({ + code: 'PRECONDITION_FAILED', + message: 'World state is not initialized.', + }); + } + + const meta = asRecord(worldState.meta); + const rawOnlineSince = meta.lastTurnTime ?? meta.turntime; + const parsedOnlineSince = + typeof rawOnlineSince === 'string' || rawOnlineSince instanceof Date + ? new Date(rawOnlineSince) + : null; + const onlineSince = + parsedOnlineSince && Number.isFinite(parsedOnlineSince.getTime()) + ? parsedOnlineSince + : new Date(Date.now() - worldState.tickSeconds * 1_000); + const [accessTotal, currentOnline, topAccess] = await Promise.all([ + ctx.db.generalAccessLog.aggregate({ + _sum: { + refresh: true, + refreshScoreTotal: true, + }, + }), + ctx.db.generalAccessLog.count({ + where: { + lastRefresh: { + gte: onlineSince, + }, + }, + }), + ctx.db.generalAccessLog.findMany({ + orderBy: [{ refresh: 'desc' }, { generalId: 'asc' }], + take: 5, + select: { + generalId: true, + refresh: true, + refreshScoreTotal: true, + }, + }), + ]); + + const generalIds = topAccess.map((entry) => entry.generalId); + const generalRows = + generalIds.length > 0 + ? await ctx.db.general.findMany({ + where: { id: { in: generalIds } }, + select: { id: true, name: true }, + }) + : []; + const generalName = new Map(generalRows.map((general) => [general.id, general.name])); + const totalRefresh = accessTotal._sum.refresh ?? 0; + const totalRefreshScore = accessTotal._sum.refreshScoreTotal ?? 0; + const currentRefresh = Math.max(readFiniteMetaNumber(meta, 'refresh'), totalRefresh); + const history = parseTrafficHistory(meta.recentTraffic); + history.push({ + year: worldState.currentYear, + month: worldState.currentMonth, + refresh: currentRefresh, + online: currentOnline, + date: new Date().toISOString(), + }); + + return { + history, + maxRefresh: Math.max( + 1, + readFiniteMetaNumber(meta, 'maxrefresh'), + ...history.map((entry) => entry.refresh) + ), + maxOnline: Math.max(1, readFiniteMetaNumber(meta, 'maxonline'), ...history.map((entry) => entry.online)), + suspects: [ + { + generalId: null, + name: '접속자 총합', + refresh: totalRefresh, + refreshScoreTotal: totalRefreshScore, + }, + ...topAccess.map((entry) => ({ + generalId: entry.generalId, + name: generalName.get(entry.generalId) ?? `장수 ${entry.generalId}`, + refresh: entry.refresh, + refreshScoreTotal: entry.refreshScoreTotal, + })), + ], + }; + }), getGeneralList: procedure.query(async ({ ctx }) => { const [generals, nations] = await Promise.all([ ctx.db.general.findMany({ diff --git a/app/game-api/test/inGameMenuPermissions.test.ts b/app/game-api/test/inGameMenuPermissions.test.ts new file mode 100644 index 0000000..ea2263c --- /dev/null +++ b/app/game-api/test/inGameMenuPermissions.test.ts @@ -0,0 +1,257 @@ +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 now = new Date('2026-01-01T00:00:00.000Z'); +const buildGeneral = (overrides: Partial = {}): GeneralRow => ({ + id: 7, + userId: 'user-7', + name: '검증장수', + nationId: 1, + cityId: 1, + troopId: 0, + npcState: 0, + affinity: null, + bornYear: 180, + deadYear: 300, + picture: 'default.jpg', + imageServer: 0, + leadership: 70, + strength: 60, + intel: 50, + injury: 0, + experience: 10, + dedication: 20, + officerLevel: 1, + gold: 1_000, + rice: 1_000, + crew: 100, + crewTypeId: 0, + train: 80, + atmos: 80, + weaponCode: 'None', + bookCode: 'None', + horseCode: 'None', + itemCode: 'None', + turnTime: now, + recentWarTime: null, + age: 20, + startAge: 20, + personalCode: 'None', + specialCode: 'None', + special2Code: 'None', + lastTurn: {}, + meta: { + belong: 1, + permission: 'normal', + myset: 3, + tnmt: 0, + defence_train: 80, + use_treatment: 21, + use_auto_nation_turn: 1, + }, + penalty: {}, + createdAt: now, + updatedAt: now, + ...overrides, +}); + +const auth: GameSessionTokenPayload = { + version: 1, + profile: 'che:default', + issuedAt: now.toISOString(), + expiresAt: new Date(now.getTime() + 86_400_000).toISOString(), + sessionId: 'session-7', + user: { id: 'user-7', username: 'tester', displayName: 'Tester', roles: [] }, + sanctions: {}, +}; + +const createContext = (options: { + me?: GeneralRow; + targets?: GeneralRow[]; + nationMeta?: Record; + requestCommand?: ReturnType; +}) => { + const me = options.me ?? buildGeneral(); + const targets = options.targets ?? [me]; + const requestCommand = + options.requestCommand ?? vi.fn(async () => ({ type: 'setMySetting', ok: true, generalId: me.id })); + const generalFindUnique = vi.fn( + async ({ where }: { where: { id: number } }) => targets.find((general) => general.id === where.id) ?? null + ); + const db = { + general: { + findFirst: vi.fn(async () => me), + findUnique: generalFindUnique, + findMany: vi.fn(async () => targets.filter((general) => general.nationId === me.nationId)), + update: vi.fn(), + }, + city: { findUnique: vi.fn(async () => null) }, + nation: { + findUnique: vi.fn(async () => ({ + id: 1, + name: '위', + color: '#777777', + level: 3, + gold: 10_000, + rice: 20_000, + tech: 100, + typeCode: 'che_법가', + capitalCityId: 1, + meta: options.nationMeta ?? { secretlimit: 3 }, + })), + }, + worldState: { + findFirst: vi.fn(async () => ({ + currentYear: 185, + currentMonth: 1, + tickSeconds: 600, + })), + }, + logEntry: { + groupBy: vi.fn(async () => []), + findMany: vi.fn(async () => [{ id: 1, text: '기록' }]), + }, + }; + const redisClient = { get: async () => null, set: async () => null }; + 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: new RedisAccessTokenStore(redisClient, 'che:default'), + flushStore: new InMemoryFlushStore(), + gameTokenSecret: 'test-secret', + }; + return { context, db, requestCommand }; +}; + +describe('in-game my information ownership', () => { + 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 }); + const caller = appRouter.createCaller(fixture.context); + + const me = await caller.general.me(); + expect(me?.settings).toEqual({ + tnmt: 0, + defence_train: 80, + use_treatment: 21, + use_auto_nation_turn: 1, + myset: 3, + }); + + await caller.general.setMySetting({ tnmt: 1, defence_train: 999 }); + expect(requestCommand).toHaveBeenCalledWith({ + type: 'setMySetting', + generalId: 7, + settings: { tnmt: 1, defence_train: 999 }, + }); + expect(fixture.db.general.update).not.toHaveBeenCalled(); + }); + + it('uses the authenticated user for both the page and its logs without accepting a target general id', async () => { + const otherUser = buildGeneral({ id: 8, userId: 'user-8', name: '타유저' }); + const fixture = createContext({ targets: [buildGeneral(), otherUser] }); + const caller = appRouter.createCaller(fixture.context); + + await expect(caller.general.me()).resolves.toMatchObject({ + general: { id: 7, name: '검증장수' }, + }); + await expect(caller.general.getMyLog({ type: 'generalAction' })).resolves.toMatchObject({ + type: 'generalAction', + logs: [{ id: 1 }], + }); + + expect(fixture.db.general.findFirst).toHaveBeenCalledWith( + expect.objectContaining({ + where: { userId: 'user-7' }, + }) + ); + expect(fixture.db.logEntry.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ generalId: 7 }), + }) + ); + }); +}); + +describe('battle-center general and user permissions', () => { + it('distinguishes an ordinary member, a tenured member, and an auditor', async () => { + const ordinary = createContext({ + me: buildGeneral({ officerLevel: 1, meta: { belong: 1, permission: 'normal' } }), + nationMeta: { secretlimit: 3 }, + }); + await expect(appRouter.createCaller(ordinary.context).nation.getBattleCenter()).rejects.toMatchObject({ + code: 'FORBIDDEN', + }); + + const tenured = createContext({ + me: buildGeneral({ officerLevel: 1, meta: { belong: 3, permission: 'normal' } }), + nationMeta: { secretlimit: 3 }, + }); + await expect(appRouter.createCaller(tenured.context).nation.getBattleCenter()).resolves.toMatchObject({ + me: { id: 7, permissionLevel: 1 }, + }); + + const auditor = createContext({ + me: buildGeneral({ officerLevel: 1, meta: { belong: 0, permission: 'auditor' } }), + nationMeta: { secretlimit: 3 }, + }); + await expect(appRouter.createCaller(auditor.context).nation.getBattleCenter()).resolves.toMatchObject({ + me: { id: 7, permissionLevel: 3 }, + }); + }); + + it('redacts another user action log while allowing own, NPC, chief, and non-private logs', async () => { + const me = buildGeneral({ meta: { belong: 3, permission: 'normal' } }); + const otherUser = buildGeneral({ id: 8, userId: 'user-8', name: '타유저', npcState: 0 }); + const npc = buildGeneral({ id: 9, userId: null, name: 'NPC', npcState: 2 }); + const foreign = buildGeneral({ id: 10, userId: 'user-10', name: '타국', nationId: 2 }); + const memberFixture = createContext({ + me, + targets: [me, otherUser, npc, foreign], + nationMeta: { secretlimit: 3 }, + }); + const member = appRouter.createCaller(memberFixture.context); + + await expect(member.nation.getGeneralLog({ generalId: me.id, type: 'generalAction' })).resolves.toMatchObject({ + generalId: me.id, + }); + await expect( + member.nation.getGeneralLog({ generalId: otherUser.id, type: 'generalAction' }) + ).rejects.toMatchObject({ code: 'FORBIDDEN' }); + await expect( + member.nation.getGeneralLog({ generalId: otherUser.id, type: 'battleDetail' }) + ).resolves.toMatchObject({ generalId: otherUser.id }); + await expect(member.nation.getGeneralLog({ generalId: npc.id, type: 'generalAction' })).resolves.toMatchObject({ + generalId: npc.id, + }); + await expect( + member.nation.getGeneralLog({ generalId: foreign.id, type: 'battleDetail' }) + ).rejects.toMatchObject({ code: 'FORBIDDEN' }); + + const chiefFixture = createContext({ + me: buildGeneral({ officerLevel: 5 }), + targets: [buildGeneral({ officerLevel: 5 }), otherUser], + nationMeta: { secretlimit: 3 }, + }); + await expect( + appRouter + .createCaller(chiefFixture.context) + .nation.getGeneralLog({ generalId: otherUser.id, type: 'generalAction' }) + ).resolves.toMatchObject({ generalId: otherUser.id }); + }); +}); diff --git a/app/game-api/test/nationGeneralSecretRouter.test.ts b/app/game-api/test/nationGeneralSecretRouter.test.ts new file mode 100644 index 0000000..329ad5e --- /dev/null +++ b/app/game-api/test/nationGeneralSecretRouter.test.ts @@ -0,0 +1,209 @@ +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 { appRouter } from '../src/router.js'; + +const now = new Date('2026-01-01T01:02:00.000Z'); +const buildGeneral = (overrides: Partial = {}): GeneralRow => ({ + id: 1, + userId: 'user-1', + name: '일반장수', + nationId: 1, + cityId: 1, + troopId: 0, + npcState: 0, + affinity: null, + bornYear: 180, + deadYear: 300, + picture: 'default.jpg', + imageServer: 0, + leadership: 70, + strength: 60, + intel: 50, + injury: 0, + experience: 900, + dedication: 100, + officerLevel: 1, + gold: 1_000, + rice: 2_000, + crew: 300, + crewTypeId: 1, + train: 90, + atmos: 90, + weaponCode: 'None', + bookCode: 'None', + horseCode: 'None', + itemCode: 'None', + turnTime: now, + recentWarTime: null, + age: 20, + startAge: 20, + personalCode: 'None', + specialCode: 'None', + special2Code: 'None', + lastTurn: {}, + meta: { belong: 1, defence_train: 80, killturn: 7 }, + penalty: {}, + createdAt: now, + updatedAt: now, + ...overrides, +}); + +const auth = (userId: string): GameSessionTokenPayload => ({ + version: 1, + profile: 'che:default', + issuedAt: now.toISOString(), + expiresAt: new Date(now.getTime() + 86_400_000).toISOString(), + sessionId: `session-${userId}`, + user: { id: userId, username: userId, displayName: userId, roles: [] }, + sanctions: {}, +}); + +const createContext = (options: { + sessionUserId?: string; + generals?: GeneralRow[]; + nationMeta?: Record; +}) => { + const sessionUserId = options.sessionUserId ?? 'user-1'; + const generals = options.generals ?? [buildGeneral()]; + const db = { + general: { + findFirst: vi.fn(async ({ where }: { where: { userId: string } }) => + generals.find((general) => general.userId === where.userId) + ), + findMany: vi.fn(async ({ where }: { where: { nationId: number } }) => + generals.filter((general) => general.nationId === where.nationId) + ), + }, + nation: { + findUnique: vi.fn(async () => ({ + id: 1, + name: '위', + color: '#008000', + level: 3, + typeCode: 'che_중립', + capitalCityId: 1, + meta: options.nationMeta ?? { secretlimit: 3 }, + })), + }, + city: { findMany: vi.fn(async () => [{ id: 1, name: '업' }]) }, + troop: { findMany: vi.fn(async () => [{ troopLeaderId: 2, name: '선봉대' }]) }, + generalAccessLog: { + findMany: vi.fn(async () => + generals.map((general) => ({ + generalId: general.id, + refreshScore: general.id, + refreshScoreTotal: general.id * 10, + })) + ), + }, + generalTurn: { + findMany: vi.fn(async () => [ + { generalId: 1, turnIdx: 0, actionCode: '징병' }, + { generalId: 1, turnIdx: 1, actionCode: '훈련' }, + ]), + }, + }; + const redis = { get: vi.fn(async () => null), set: vi.fn(async () => null) } as unknown as RedisConnector['client']; + return { + context: { + db: db as unknown as DatabaseClient, + redis, + turnDaemon: {} as GameApiContext['turnDaemon'], + battleSim: {} as GameApiContext['battleSim'], + profile: { id: 'che', scenario: 'default', name: 'che:default' }, + auth: auth(sessionUserId), + uploadDir: 'uploads', + uploadPath: '/uploads', + uploadPublicUrl: null, + accessTokenStore: new RedisAccessTokenStore(redis, 'che:default'), + flushStore: new InMemoryFlushStore(), + gameTokenSecret: 'test-secret', + } satisfies GameApiContext, + db, + }; +}; + +describe('nation general and secret-office permissions', () => { + it('redacts confidential columns for an ordinary member and denies the secret office', async () => { + const fixture = createContext({}); + const caller = appRouter.createCaller(fixture.context); + + const list = await caller.nation.getGeneralList(); + expect(list.viewer).toEqual({ generalId: 1, permission: 0 }); + expect(list.generals[0]).toMatchObject({ + officerLevel: 1, + gold: 1_000, + rice: 2_000, + detail: null, + }); + expect(fixture.db.generalTurn.findMany).not.toHaveBeenCalled(); + await expect(caller.nation.getSecretGeneralList()).rejects.toMatchObject({ code: 'FORBIDDEN' }); + }); + + it('allows a tenured member, scopes rows to the actor nation, and returns legacy summary and turns', async () => { + const me = buildGeneral({ meta: { belong: 3, defence_train: 90, killturn: 7 } }); + const ally = buildGeneral({ + id: 2, + userId: 'user-2', + name: '아군', + troopId: 2, + gold: 3_000, + rice: 4_000, + crew: 200, + train: 80, + atmos: 80, + }); + const hiddenNpc = buildGeneral({ id: 3, userId: null, name: '가상', npcState: 5, gold: 99_999 }); + const foreign = buildGeneral({ id: 4, userId: 'foreign', nationId: 2, gold: 88_888 }); + const fixture = createContext({ generals: [me, ally, hiddenNpc, foreign] }); + + const result = await appRouter.createCaller(fixture.context).nation.getSecretGeneralList(); + expect(result.viewer.permission).toBe(1); + expect(result.generals.map((general) => general.id)).toEqual([1, 2, 3]); + expect(result.generals[0]?.detail).toMatchObject({ + cityName: '업', + defenceTrain: 90, + killTurn: 7, + reservedCommands: ['징병', '훈련'], + }); + expect(result.generals[1]?.detail?.troopName).toBe('선봉대'); + expect(result.summary).toMatchObject({ + gold: 4_000, + rice: 6_000, + crew: 500, + generalCount: 2, + readiness: { + 90: { crew: 300, generals: 1 }, + 80: { crew: 500, generals: 2 }, + 60: { crew: 500, generals: 2 }, + }, + }); + expect(fixture.db.general.findMany).toHaveBeenCalledWith( + expect.objectContaining({ where: { nationId: 1 } }) + ); + }); + + it('derives the acting general from the authenticated user and applies that general permission', async () => { + const first = buildGeneral({ userId: 'user-1', meta: { belong: 1 } }); + const second = buildGeneral({ id: 2, userId: 'user-2', officerLevel: 5, meta: { belong: 1 } }); + const fixture = createContext({ sessionUserId: 'user-2', generals: [first, second] }); + + const result = await appRouter.createCaller(fixture.context).nation.getSecretGeneralList(); + expect(result.viewer).toEqual({ generalId: 2, permission: 2 }); + expect(fixture.db.general.findFirst).toHaveBeenCalledWith({ where: { userId: 'user-2' } }); + }); + + it('keeps penalty-based secret denial even for an otherwise qualified general', async () => { + const me = buildGeneral({ officerLevel: 5, penalty: { noChief: true } }); + const fixture = createContext({ generals: [me] }); + await expect(appRouter.createCaller(fixture.context).nation.getSecretGeneralList()).rejects.toMatchObject({ + code: 'FORBIDDEN', + }); + }); +}); diff --git a/app/game-api/test/publicTraffic.test.ts b/app/game-api/test/publicTraffic.test.ts new file mode 100644 index 0000000..78008d8 --- /dev/null +++ b/app/game-api/test/publicTraffic.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, it } from 'vitest'; + +import type { RedisConnector } from '@sammo-ts/infra'; + +import { RedisAccessTokenStore } from '../src/auth/accessTokenStore.js'; +import { InMemoryFlushStore } from '../src/auth/flushStore.js'; +import { InMemoryBattleSimTransport } from '../src/battleSim/inMemoryTransport.js'; +import type { DatabaseClient, GameApiContext, GameProfile } from '../src/context.js'; +import { InMemoryTurnDaemonTransport } from '../src/daemon/inMemoryTransport.js'; +import { appRouter } from '../src/router.js'; + +const profile: GameProfile = { + id: 'che', + scenario: 'default', + name: 'che:default', +}; + +const buildContext = (): GameApiContext => { + const db = { + worldState: { + findFirst: async () => ({ + id: 1, + currentYear: 185, + currentMonth: 3, + tickSeconds: 600, + config: {}, + meta: { + lastTurnTime: '2026-07-26T03:00:00.000Z', + refresh: 12, + maxrefresh: 30, + maxonline: 5, + recentTraffic: [ + { + year: 185, + month: 2, + refresh: 30, + online: 5, + date: '2026-07-26 02:50:00', + }, + ], + }, + }), + }, + generalAccessLog: { + aggregate: async () => ({ + _sum: { + refresh: 12, + refreshScoreTotal: 21, + }, + }), + count: async (args: { where: { lastRefresh: { gte: Date } } }) => { + expect(args.where.lastRefresh.gte).toEqual(new Date('2026-07-26T03:00:00.000Z')); + return 2; + }, + findMany: async () => [ + { generalId: 7, refresh: 9, refreshScoreTotal: 15 }, + { generalId: 8, refresh: 3, refreshScoreTotal: 6 }, + ], + }, + general: { + findMany: async () => [ + { id: 7, name: '갑' }, + { id: 8, name: '을' }, + ], + }, + }; + const redis = { + get: async () => null, + set: async () => null, + } as unknown as RedisConnector['client']; + + return { + db: db as unknown as DatabaseClient, + turnDaemon: new InMemoryTurnDaemonTransport(), + battleSim: new InMemoryBattleSimTransport(), + profile, + auth: null, + uploadDir: 'uploads', + uploadPath: '/uploads', + uploadPublicUrl: null, + redis, + accessTokenStore: new RedisAccessTokenStore(redis, profile.name), + flushStore: new InMemoryFlushStore(), + gameTokenSecret: 'test-secret', + }; +}; + +describe('public.getTraffic', () => { + it('is public and returns only aggregate traffic plus allowlisted general names', async () => { + const result = await appRouter.createCaller(buildContext()).public.getTraffic(); + + expect(result.history).toHaveLength(2); + expect(result.history[0]).toEqual({ + year: 185, + month: 2, + refresh: 30, + online: 5, + date: '2026-07-26 02:50:00', + }); + expect(result.history[1]).toMatchObject({ + year: 185, + month: 3, + refresh: 12, + online: 2, + }); + expect(result.maxRefresh).toBe(30); + expect(result.maxOnline).toBe(5); + expect(result.suspects).toEqual([ + { generalId: null, name: '접속자 총합', refresh: 12, refreshScoreTotal: 21 }, + { generalId: 7, name: '갑', refresh: 9, refreshScoreTotal: 15 }, + { generalId: 8, name: '을', refresh: 3, refreshScoreTotal: 6 }, + ]); + expect(JSON.stringify(result)).not.toContain('userId'); + }); +}); diff --git a/app/game-engine/src/turn/worldCommandHandler.ts b/app/game-engine/src/turn/worldCommandHandler.ts index e358955..42efbb9 100644 --- a/app/game-engine/src/turn/worldCommandHandler.ts +++ b/app/game-engine/src/turn/worldCommandHandler.ts @@ -809,9 +809,35 @@ async function handleVacation( if (!general) { return { type: 'vacation', ok: false, generalId: command.generalId, reason: '장수 정보를 찾을 수 없습니다.' }; } + const autorunUser = asRecord(world.getState().meta.autorun_user); + if (autorunUser.limit_minutes) { + return { + type: 'vacation', + ok: false, + generalId: command.generalId, + reason: '자동 턴인 경우에는 휴가 명령이 불가능합니다.', + }; + } + const killturn = readMetaNumber(asRecord(world.getState().meta), 'killturn', 0); + world.updateGeneral(general.id, { + meta: { + ...general.meta, + killturn: killturn * 3, + }, + }); return { type: 'vacation', ok: true, generalId: command.generalId }; } +const normalizeDefenceTrain = (value: number): number => { + if (value <= 40) { + return 40; + } + if (value <= 90) { + return Math.round(value / 10) * 10; + } + return 999; +}; + async function handleSetMySetting( ctx: CommandHandlerContext, command: Extract @@ -826,11 +852,48 @@ async function handleSetMySetting( reason: '장수 정보를 찾을 수 없습니다.', }; } + + const settings = command.settings; + const previousDefenceTrain = readMetaNumber(general.meta, 'defence_train', 80); + const nextDefenceTrain = + settings.defence_train === undefined ? previousDefenceTrain : normalizeDefenceTrain(settings.defence_train); + const nextMeta = { ...general.meta }; + + if (settings.tnmt !== undefined) { + nextMeta.tnmt = settings.tnmt < 0 || settings.tnmt > 1 ? 1 : settings.tnmt; + } + if (settings.use_treatment !== undefined) { + nextMeta.use_treatment = Math.max(10, Math.min(100, settings.use_treatment)); + } + if (settings.use_auto_nation_turn !== undefined) { + nextMeta.use_auto_nation_turn = settings.use_auto_nation_turn; + } + + let nextTrain = general.train; + let nextAtmos = general.atmos; + if (nextDefenceTrain !== previousDefenceTrain) { + nextMeta.myset = readMetaNumber(general.meta, 'myset', 0) - 1; + nextMeta.defence_train = nextDefenceTrain; + if (nextDefenceTrain === 999) { + const scenarioEffect = world.getScenarioConfig().environment.scenarioEffect; + const ignoresPenalty = + scenarioEffect === 'event_UnlimitedDefenceThresholdChange' || + scenarioEffect === 'event_StrongAttacker' || + scenarioEffect === 'event_MoreEffect'; + const constValues = asRecord(world.getScenarioConfig().const); + const maxTrain = readMetaNumber(constValues, 'maxTrainByWar', 100); + const maxAtmos = readMetaNumber(constValues, 'maxAtmosByWar', 100); + const trainDelta = ignoresPenalty ? 0 : -3; + const atmosDelta = ignoresPenalty ? 0 : -6; + nextTrain = Math.max(20, Math.min(maxTrain, general.train + trainDelta)); + nextAtmos = Math.max(20, Math.min(maxAtmos, general.atmos + atmosDelta)); + } + } + world.updateGeneral(command.generalId, { - meta: { - ...general.meta, - ...command.settings, - }, + meta: nextMeta, + train: nextTrain, + atmos: nextAtmos, }); return { type: 'setMySetting', ok: true, generalId: command.generalId }; } @@ -844,10 +907,8 @@ async function handleDropItem( if (!general) { return { type: 'dropItem', ok: false, generalId: command.generalId, reason: '장수 정보를 찾을 수 없습니다.' }; } - const slot = (['horse', 'weapon', 'book', 'item'] as const).find( - (candidate) => general.role.items[candidate] === command.itemType - ); - if (!slot) { + const slot = (['horse', 'weapon', 'book', 'item'] as const).find((candidate) => candidate === command.itemType); + if (!slot || !general.role.items[slot]) { return { type: 'dropItem', ok: false, generalId: command.generalId, reason: '아이템을 가지고 있지 않습니다.' }; } const nextGeneral = { diff --git a/app/game-engine/test/myInformationCommands.test.ts b/app/game-engine/test/myInformationCommands.test.ts new file mode 100644 index 0000000..86c72b8 --- /dev/null +++ b/app/game-engine/test/myInformationCommands.test.ts @@ -0,0 +1,179 @@ +import { describe, expect, it } from 'vitest'; + +import type { TurnSchedule } from '@sammo-ts/logic'; + +import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js'; +import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js'; +import { createTurnDaemonCommandHandler } from '../src/turn/worldCommandHandler.js'; + +const schedule: TurnSchedule = { entries: [{ startMinute: 0, tickMinutes: 10 }] }; + +const buildGeneral = (overrides: Partial = {}): TurnGeneral => ({ + id: 7, + userId: 'user-7', + name: '테스트장수', + nationId: 1, + cityId: 1, + troopId: 0, + stats: { leadership: 70, strength: 60, intelligence: 50 }, + turnTime: new Date('0185-01-01T00:00:00Z'), + recentWarTime: null, + role: { + items: { horse: 'che_명마', weapon: null, book: null, item: null }, + personality: null, + specialDomestic: null, + specialWar: null, + }, + triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} }, + meta: { + killturn: 12, + myset: 3, + defence_train: 80, + tnmt: 0, + use_treatment: 10, + use_auto_nation_turn: 1, + }, + penalty: {}, + officerLevel: 1, + experience: 0, + dedication: 0, + injury: 0, + gold: 1_000, + rice: 1_000, + crew: 100, + crewTypeId: 0, + train: 90, + atmos: 90, + age: 20, + npcState: 0, + ...overrides, +}); + +const buildWorld = ( + general = buildGeneral(), + options: { autorunLimit?: boolean; scenarioEffect?: string | null } = {} +) => { + const state: TurnWorldState = { + id: 1, + currentYear: 185, + currentMonth: 1, + tickSeconds: 600, + lastTurnTime: new Date('0185-01-01T00:00:00Z'), + meta: { + killturn: 24, + autorun_user: options.autorunLimit ? { limit_minutes: 60 } : {}, + }, + }; + const snapshot: TurnWorldSnapshot = { + generals: [general], + cities: [], + nations: [], + troops: [], + diplomacy: [], + events: [], + initialEvents: [], + scenarioConfig: { + stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 50, npcMin: 10, chiefMin: 65 }, + iconPath: '', + map: {}, + const: { maxTrainByWar: 100, maxAtmosByWar: 100 }, + environment: { + mapName: 'test', + unitSet: 'test', + ...(options.scenarioEffect !== undefined ? { scenarioEffect: options.scenarioEffect } : {}), + }, + }, + scenarioMeta: { + title: 'test', + startYear: 180, + life: null, + fiction: null, + history: [], + ignoreDefaultEvents: false, + }, + map: { + id: 'test', + name: 'test', + cities: [], + defaults: { trust: 50, trade: 100, supplyState: 1, frontState: 0 }, + }, + }; + const world = new InMemoryTurnWorld(state, snapshot, { schedule }); + return { world, handler: createTurnDaemonCommandHandler({ world }) }; +}; + +describe('my information world commands', () => { + it('normalizes legacy settings and charges myset only when defence mode changes', async () => { + const fixture = buildWorld(); + + await expect( + fixture.handler.handle({ + type: 'setMySetting', + generalId: 7, + settings: { + tnmt: 9, + defence_train: 94, + use_treatment: 200, + use_auto_nation_turn: 0, + }, + }) + ).resolves.toMatchObject({ ok: true }); + + expect(fixture.world.getGeneralById(7)).toMatchObject({ + train: 87, + atmos: 84, + meta: { + tnmt: 1, + defence_train: 999, + use_treatment: 100, + use_auto_nation_turn: 0, + myset: 2, + }, + }); + + await fixture.handler.handle({ + type: 'setMySetting', + generalId: 7, + settings: { tnmt: 0, defence_train: 999, use_treatment: 1 }, + }); + expect(fixture.world.getGeneralById(7)?.meta).toMatchObject({ + tnmt: 0, + use_treatment: 10, + myset: 2, + }); + }); + + it('preserves the event scenarios that waive the no-defence penalty', async () => { + const fixture = buildWorld(buildGeneral(), { scenarioEffect: 'event_StrongAttacker' }); + await fixture.handler.handle({ + type: 'setMySetting', + generalId: 7, + settings: { defence_train: 999 }, + }); + expect(fixture.world.getGeneralById(7)).toMatchObject({ train: 90, atmos: 90 }); + }); + + it('applies vacation killturn and rejects it in automatic-turn mode', async () => { + const allowed = buildWorld(); + await expect(allowed.handler.handle({ type: 'vacation', generalId: 7 })).resolves.toMatchObject({ ok: true }); + expect(allowed.world.getGeneralById(7)?.meta.killturn).toBe(72); + + const blocked = buildWorld(buildGeneral(), { autorunLimit: true }); + await expect(blocked.handler.handle({ type: 'vacation', generalId: 7 })).resolves.toMatchObject({ + ok: false, + reason: '자동 턴인 경우에는 휴가 명령이 불가능합니다.', + }); + expect(blocked.world.getGeneralById(7)?.meta.killturn).toBe(12); + }); + + it('drops only the authenticated command target slot and rejects an empty slot', async () => { + const fixture = buildWorld(); + await expect( + fixture.handler.handle({ type: 'dropItem', generalId: 7, itemType: 'weapon' }) + ).resolves.toMatchObject({ ok: false }); + await expect( + fixture.handler.handle({ type: 'dropItem', generalId: 7, itemType: 'horse' }) + ).resolves.toMatchObject({ ok: true }); + expect(fixture.world.getGeneralById(7)?.role.items.horse).toBeNull(); + }); +}); diff --git a/app/game-frontend/e2e/inGameMenus.spec.ts b/app/game-frontend/e2e/inGameMenus.spec.ts new file mode 100644 index 0000000..ca33b24 --- /dev/null +++ b/app/game-frontend/e2e/inGameMenus.spec.ts @@ -0,0 +1,314 @@ +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { basename, resolve } from 'node:path'; +import { expect, test, type Page, type Route } from '@playwright/test'; + +const response = (data: unknown) => ({ result: { data } }); +const parityArtifactDir = process.env.MENU_PARITY_ARTIFACT_DIR; +const legacyImageRoot = process.env.LEGACY_IMAGE_ROOT; +const operationNames = (route: Route) => + decodeURIComponent(new URL(route.request().url()).pathname.split('/trpc/')[1] ?? '').split(','); + +const persistParityArtifact = async (page: Page, name: string, geometry: unknown) => { + if (!parityArtifactDir) { + return; + } + await mkdir(parityArtifactDir, { recursive: true }); + await Promise.all([ + page.screenshot({ path: resolve(parityArtifactDir, `${name}.png`), fullPage: true }), + writeFile(resolve(parityArtifactDir, `${name}.json`), `${JSON.stringify(geometry, null, 2)}\n`), + ]); +}; + +type FixtureState = { + permission: 'head' | 'member'; + myset: number; + settingMutations: Array>; +}; + +const myGeneral = (state: FixtureState) => ({ + general: { + id: 7, + name: '검증장수', + npcState: 0, + nationId: 1, + cityId: 1, + troopId: 0, + picture: null, + imageServer: 0, + officerLevel: state.permission === 'head' ? 5 : 1, + stats: { leadership: 70, strength: 60, intelligence: 50 }, + gold: 1_000, + rice: 2_000, + crew: 300, + train: 80, + atmos: 90, + injury: 0, + experience: 100, + dedication: 200, + items: { horse: 'che_명마', weapon: null, book: null, item: null }, + }, + city: { id: 1, name: '업', level: 8, nationId: 1 }, + nation: { id: 1, name: '위', color: '#777777', level: 3 }, + settings: { + tnmt: 0, + defence_train: 80, + use_treatment: 21, + use_auto_nation_turn: 1, + myset: state.myset, + }, + penalties: {}, +}); + +const battleCenter = (state: FixtureState) => ({ + me: { + id: 7, + officerLevel: state.permission === 'head' ? 5 : 1, + permissionLevel: state.permission === 'head' ? 2 : 0, + }, + nation: { id: 1, name: '위', color: '#777777', level: 3 }, + currentYear: 185, + currentMonth: 1, + turnTermMinutes: 10, + generals: [ + { + id: 7, + name: '검증장수', + npcState: 0, + officerLevel: state.permission === 'head' ? 5 : 1, + cityId: 1, + turnTime: '2026-01-01 00:10:00', + recentWar: '2026-01-01 00:00:00', + warnum: 3, + stats: { leadership: 70, strength: 60, intelligence: 50 }, + experience: 100, + dedication: 200, + injury: 0, + gold: 1_000, + rice: 2_000, + crew: 300, + train: 80, + atmos: 90, + }, + { + id: 8, + name: '다른장수', + npcState: 2, + officerLevel: 1, + cityId: 1, + turnTime: '2026-01-01 00:20:00', + recentWar: null, + warnum: 0, + stats: { leadership: 50, strength: 50, intelligence: 50 }, + experience: 0, + dedication: 0, + injury: 0, + gold: 500, + rice: 500, + crew: 100, + train: 60, + atmos: 60, + }, + ], +}); + +const install = async (page: Page, state: FixtureState) => { + await page.addInitScript(() => { + localStorage.setItem('sammo-game-token', 'menu-token'); + localStorage.setItem('sammo-game-profile', 'che:default'); + }); + await page.route('**/image/game/**', async (route) => { + const filename = basename(new URL(route.request().url()).pathname); + if (legacyImageRoot && ['back_walnut.jpg', 'back_green.jpg', 'back_blue.jpg'].includes(filename)) { + await route.fulfill({ + status: 200, + contentType: 'image/jpeg', + body: await readFile(resolve(legacyImageRoot, filename)), + }); + return; + } + await route.fulfill({ status: 200, contentType: 'image/jpeg', body: Buffer.from('') }); + }); + await page.route('**/che/api/trpc/**', async (route) => { + const operations = operationNames(route); + const results = operations.map((operation) => { + if (operation === 'lobby.info') return response({ myGeneral: { id: 7, name: '검증장수' } }); + if (operation === 'join.getConfig') return response({}); + if (operation === 'general.me') return response(myGeneral(state)); + if (operation === 'world.getState') + return response({ + currentYear: 185, + currentMonth: 1, + tickSeconds: 600, + config: { npcMode: 0, const: { availableInstantAction: {} } }, + meta: { + turntime: '2026-01-01T00:00:00.000Z', + opentime: '2025-12-01T00:00:00.000Z', + autorun_user: {}, + }, + }); + if (operation === 'general.getMyLog') + return response({ type: 'generalAction', logs: [{ id: 1, text: '기록' }] }); + if (operation === 'general.setMySetting') { + const raw = route.request().postDataJSON() as { input?: { json?: Record } }; + state.settingMutations.push(raw.input?.json ?? {}); + state.myset = Math.max(0, state.myset - 1); + return response({ ok: true }); + } + if (operation === 'nation.getBattleCenter') { + if (state.permission === 'member') { + return { + error: { + message: '권한이 부족합니다.', + code: -32000, + data: { code: 'FORBIDDEN', httpStatus: 403, path: operation }, + }, + }; + } + return response(battleCenter(state)); + } + if (operation === 'nation.getGeneralLog') { + const type = new URL(route.request().url()).searchParams.get('input')?.includes('generalAction') + ? 'generalAction' + : operation; + return response({ type, generalId: 7, logs: [{ id: 1, text: '감찰 기록' }] }); + } + return response({ ok: true }); + }); + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(operations.length === 1 ? results[0] : results), + }); + }); +}; + +test('내 정보&설정 keeps the legacy 1000px/500px geometry and saves in place', async ({ page }) => { + const state: FixtureState = { permission: 'head', myset: 3, settingMutations: [] }; + await install(page, state); + await page.setViewportSize({ width: 1200, height: 900 }); + await page.goto('my-page'); + await expect(page.locator('.title-row')).toContainText('내 정 보'); + await expect(page.locator('#set_my_setting')).toBeVisible(); + + const desktop = await page.locator('#container').evaluate((element) => { + const rect = element.getBoundingClientRect(); + const title = element.querySelector('.title-row')!.getBoundingClientRect(); + const settings = element.querySelector('.settings-column')!.getBoundingClientRect(); + const saveButton = element.querySelector('#set_my_setting')!; + const save = saveButton.getBoundingClientRect(); + const customCss = element.querySelector('#custom_css')!.getBoundingClientRect(); + const columns = getComputedStyle(element.querySelector('.top-grid')!).gridTemplateColumns; + return { + width: rect.width, + minWidth: getComputedStyle(element).minWidth, + fontSize: getComputedStyle(element).fontSize, + columns, + titleHeight: title.height, + settingsOffset: settings.x - rect.x, + saveWidth: save.width, + saveHeight: save.height, + saveBackground: getComputedStyle(saveButton).backgroundColor, + customCssWidth: customCss.width, + customCssHeight: customCss.height, + backgroundImage: getComputedStyle(element).backgroundImage, + sectionBackgroundImage: getComputedStyle(element.querySelector('.section-title')!).backgroundImage, + }; + }); + expect(desktop.width).toBe(1000); + expect(desktop.minWidth).toBe('500px'); + expect(desktop.fontSize).toBe('14px'); + expect(desktop.columns.split(' ')).toHaveLength(2); + expect(desktop.titleHeight).toBeCloseTo(54, 0); + expect(desktop.settingsOffset).toBeCloseTo(500, 0); + expect(desktop.saveWidth).toBe(160); + expect(desktop.saveHeight).toBe(30); + expect(desktop.saveBackground).toBe('rgb(34, 85, 0)'); + expect(desktop.customCssWidth).toBe(420); + expect(desktop.customCssHeight).toBe(150); + expect(desktop.backgroundImage).toContain('back_walnut.jpg'); + expect(desktop.sectionBackgroundImage).toContain('back_green.jpg'); + await persistParityArtifact(page, 'core-my-page-desktop', desktop); + + await page + .locator('select') + .filter({ has: page.locator('option[value="999"]') }) + .selectOption('999'); + await page.locator('#set_my_setting').click(); + await expect.poll(() => state.settingMutations.length).toBe(1); + expect(state.settingMutations[0]).not.toHaveProperty('generalId'); + + await page.setViewportSize({ width: 500, height: 900 }); + await page.reload(); + const mobile = await page.locator('#container').evaluate((element) => { + const rect = element.getBoundingClientRect(); + const settings = element.querySelector('.settings-column')!.getBoundingClientRect(); + return { + width: rect.width, + scrollWidth: document.documentElement.scrollWidth, + columns: getComputedStyle(element.querySelector('.top-grid')!).gridTemplateColumns, + settingsOffset: settings.x - rect.x, + settingsWidth: settings.width, + }; + }); + expect(mobile).toMatchObject({ + width: 500, + scrollWidth: 500, + columns: '500px', + settingsOffset: 0, + settingsWidth: 500, + }); + await persistParityArtifact(page, 'core-my-page-mobile', mobile); +}); + +test('감찰부 keeps the selector interaction and shows the permission error path', async ({ page }) => { + const head: FixtureState = { permission: 'head', myset: 3, settingMutations: [] }; + await install(page, head); + await page.setViewportSize({ width: 1000, height: 900 }); + await page.goto('battle-center'); + await expect(page.getByRole('heading', { name: '감찰부' })).toBeVisible(); + await expect(page.locator('.selector-row select').nth(1)).toHaveValue('8'); + await page.getByRole('button', { name: '다음 ▶' }).click(); + await expect(page.locator('.selector-row select').nth(1)).toHaveValue('7'); + const geometry = await page.locator('.battle-page').evaluate((element) => { + const selector = element.querySelector('.selector-row')!; + const controls = [...selector.children].map((child) => (child as HTMLElement).getBoundingClientRect()); + const logBlock = element.querySelector('.log-block')!.getBoundingClientRect(); + return { + width: element.getBoundingClientRect().width, + fontSize: getComputedStyle(element).fontSize, + selectorColumns: getComputedStyle(selector).gridTemplateColumns, + selectorHeight: selector.getBoundingClientRect().height, + controlWidths: controls.map((control) => control.width), + logBlockWidth: logBlock.width, + backgroundImage: getComputedStyle(element).backgroundImage, + generalBackgroundImage: getComputedStyle(element.querySelector('.battle-general-card')!) + .backgroundImage, + }; + }); + expect(geometry.width).toBe(1000); + expect(geometry.fontSize).toBe('14px'); + expect(geometry.selectorColumns.split(' ')).toHaveLength(4); + expect(geometry.selectorHeight).toBeCloseTo(36, 0); + expect(geometry.controlWidths[0]).toBeCloseTo(83.33, 0); + expect(geometry.controlWidths[1]).toBeCloseTo(333.33, 0); + expect(geometry.logBlockWidth).toBeCloseTo(500, 0); + expect(geometry.backgroundImage).toContain('back_walnut.jpg'); + expect(geometry.generalBackgroundImage).toContain('back_blue.jpg'); + await persistParityArtifact(page, 'core-battle-center-desktop', geometry); + + await page.setViewportSize({ width: 500, height: 900 }); + const mobileGeometry = await page.locator('.selector-row').evaluate((element) => ({ + columns: getComputedStyle(element).gridTemplateColumns, + controlWidths: [...element.children].map((child) => (child as HTMLElement).getBoundingClientRect().width), + })); + expect(mobileGeometry.columns.split(' ')).toHaveLength(4); + expect(mobileGeometry.controlWidths[0]).toBeCloseTo(83.33, 0); + expect(mobileGeometry.controlWidths[1]).toBeCloseTo(125, 0); + await persistParityArtifact(page, 'core-battle-center-mobile', mobileGeometry); + + await page.unrouteAll({ behavior: 'wait' }); + const member: FixtureState = { permission: 'member', myset: 3, settingMutations: [] }; + await install(page, member); + await page.reload(); + await expect(page.locator('.error')).toContainText('권한이 부족합니다.'); +}); diff --git a/app/game-frontend/e2e/playwright.config.mjs b/app/game-frontend/e2e/playwright.config.mjs index ddee57a..b6090fe 100644 --- a/app/game-frontend/e2e/playwright.config.mjs +++ b/app/game-frontend/e2e/playwright.config.mjs @@ -8,7 +8,7 @@ const baseURL = `http://127.0.0.1:${port}/che/`; export default defineConfig({ testDir: '.', - testMatch: ['troop.spec.ts', 'board.spec.ts', 'inGameInfo.spec.ts', 'nationOffices.spec.ts'], + testMatch: ['troop.spec.ts', 'board.spec.ts', 'inGameInfo.spec.ts', 'inGameMenus.spec.ts', 'nationOffices.spec.ts'], fullyParallel: false, workers: 1, timeout: 30_000, diff --git a/app/game-frontend/src/router/index.ts b/app/game-frontend/src/router/index.ts index a412e4c..90532bf 100644 --- a/app/game-frontend/src/router/index.ts +++ b/app/game-frontend/src/router/index.ts @@ -10,6 +10,7 @@ import NationInfoView from '../views/NationInfoView.vue'; import GlobalInfoView from '../views/GlobalInfoView.vue'; import CurrentCityView from '../views/CurrentCityView.vue'; import NationGeneralsView from '../views/NationGeneralsView.vue'; +import NationSecretView from '../views/NationSecretView.vue'; import NationPersonnelView from '../views/NationPersonnelView.vue'; import NationStratFinanView from '../views/NationStratFinanView.vue'; import ChiefCenterView from '../views/ChiefCenterView.vue'; @@ -20,7 +21,6 @@ import NotFoundView from '../views/NotFoundView.vue'; import TournamentView from '../views/TournamentView.vue'; import BettingView from '../views/BettingView.vue'; import MyPageView from '../views/MyPageView.vue'; -import MySettingsView from '../views/MySettingsView.vue'; import BoardView from '../views/BoardView.vue'; import DiplomacyView from '../views/DiplomacyView.vue'; import BestGeneralView from '../views/BestGeneralView.vue'; @@ -32,6 +32,7 @@ import TroopView from '../views/TroopView.vue'; import YearbookView from '../views/YearbookView.vue'; import NationBettingView from '../views/NationBettingView.vue'; import NpcListView from '../views/NpcListView.vue'; +import TrafficView from '../views/TrafficView.vue'; import { useSessionStore } from '../stores/session'; const routes = [ @@ -148,6 +149,15 @@ const routes = [ requiresGeneral: true, }, }, + { + path: '/nation/secret', + name: 'nation-secret', + component: NationSecretView, + meta: { + requiresAuth: true, + requiresGeneral: true, + }, + }, { path: '/nation/personnel', name: 'nation-personnel', @@ -251,6 +261,11 @@ const routes = [ requiresGeneral: true, }, }, + { + path: '/traffic', + name: 'traffic', + component: TrafficView, + }, { path: '/npc-list', name: 'npc-list', @@ -276,8 +291,7 @@ const routes = [ }, { path: '/my-settings', - name: 'my-settings', - component: MySettingsView, + redirect: '/my-page', meta: { requiresAuth: true, requiresGeneral: true, diff --git a/app/game-frontend/src/views/BattleCenterView.vue b/app/game-frontend/src/views/BattleCenterView.vue index 1d3f0e6..8b48183 100644 --- a/app/game-frontend/src/views/BattleCenterView.vue +++ b/app/game-frontend/src/views/BattleCenterView.vue @@ -3,7 +3,6 @@ import { computed, onMounted, reactive, ref, watch } from 'vue'; import { useRoute } from 'vue-router'; import PanelCard from '../components/ui/PanelCard.vue'; import SkeletonLines from '../components/ui/SkeletonLines.vue'; -import GeneralBasicCard from '../components/main/GeneralBasicCard.vue'; import { trpc } from '../utils/trpc'; import { getNpcColor } from '../utils/npcColor'; import { formatLog } from '../utils/formatLog'; @@ -269,7 +268,26 @@ onMounted(() => { - + +
+
+ {{ selectedGeneral.name }} (관직 {{ selectedGeneral.officerLevel }}) +
+
+ 통솔{{ selectedGeneral.stats.leadership }} 무력{{ selectedGeneral.stats.strength }} 지력{{ selectedGeneral.stats.intelligence }} 자금{{ selectedGeneral.gold }} 군량{{ selectedGeneral.rice }} 병력{{ selectedGeneral.crew }} 훈련{{ selectedGeneral.train }} 사기{{ selectedGeneral.atmos }} 부상{{ selectedGeneral.injury }} 경험{{ selectedGeneral.experience }} 공헌{{ selectedGeneral.dedication }} 전투{{ selectedGeneral.warnum }}회 +
+
최근 턴: {{ selectedGeneral.turnTime ? selectedGeneral.turnTime.slice(-5) : '-' }}
최근 전투: {{ selectedGeneral.recentWar || '-' }}
@@ -286,12 +304,7 @@ onMounted(() => {
@@ -303,126 +316,237 @@ onMounted(() => { diff --git a/app/game-frontend/src/views/NationSecretView.vue b/app/game-frontend/src/views/NationSecretView.vue new file mode 100644 index 0000000..e622cbc --- /dev/null +++ b/app/game-frontend/src/views/NationSecretView.vue @@ -0,0 +1,250 @@ + + + + + diff --git a/app/game-frontend/src/views/PublicView.vue b/app/game-frontend/src/views/PublicView.vue index 6c3684a..1624fcf 100644 --- a/app/game-frontend/src/views/PublicView.vue +++ b/app/game-frontend/src/views/PublicView.vue @@ -108,6 +108,7 @@ onMounted(() => { 장수 생성/빙의 메인으로 빙의일람 + 접속량정보 diff --git a/app/game-frontend/src/views/TrafficView.vue b/app/game-frontend/src/views/TrafficView.vue new file mode 100644 index 0000000..58b5f30 --- /dev/null +++ b/app/game-frontend/src/views/TrafficView.vue @@ -0,0 +1,343 @@ + + + + + diff --git a/tools/frontend-legacy-parity/reference-ingame-menus.mjs b/tools/frontend-legacy-parity/reference-ingame-menus.mjs new file mode 100644 index 0000000..219dd75 --- /dev/null +++ b/tools/frontend-legacy-parity/reference-ingame-menus.mjs @@ -0,0 +1,175 @@ +import { chromium } from '@playwright/test'; +import { createHash } from 'node:crypto'; +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; + +const baseUrl = process.env.REF_MENU_URL ?? 'http://127.0.0.1:3400/sam/'; +const username = process.env.REF_MENU_USER ?? 'refuser1'; +const passwordFile = process.env.REF_MENU_PASSWORD_FILE; +const artifactRoot = resolve(process.env.REF_MENU_ARTIFACT_DIR ?? 'test-results/reference-ingame-menus'); + +if (!passwordFile) { + throw new Error('REF_MENU_PASSWORD_FILE is required.'); +} + +const password = (await readFile(passwordFile, 'utf8')).trim(); +await mkdir(artifactRoot, { recursive: true }); + +const login = async (context, page) => { + await page.goto(baseUrl, { waitUntil: 'networkidle' }); + const globalSalt = await page.locator('#global_salt').inputValue(); + const passwordHash = createHash('sha512') + .update(globalSalt + password + globalSalt) + .digest('hex'); + const response = await context.request.post(new URL('api.php?path=Login/LoginByID', baseUrl).toString(), { + data: { username, password: passwordHash }, + }); + const result = await response.json(); + if (!response.ok() || result.result !== true) { + throw new Error('Reference login failed.'); + } +}; + +const rectAndStyle = (element) => { + const rect = element.getBoundingClientRect(); + const style = getComputedStyle(element); + return { + rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height }, + style: { + display: style.display, + gridTemplateColumns: style.gridTemplateColumns, + fontFamily: style.fontFamily, + fontSize: style.fontSize, + lineHeight: style.lineHeight, + color: style.color, + backgroundColor: style.backgroundColor, + backgroundImage: style.backgroundImage, + borderTopColor: style.borderTopColor, + borderTopWidth: style.borderTopWidth, + padding: style.padding, + margin: style.margin, + cursor: style.cursor, + }, + }; +}; + +const measure = async (page, selectors) => + page.evaluate( + ({ selectors, measureSource }) => { + const measureElement = new Function(`return (${measureSource})`)(); + const result = {}; + for (const [name, selector] of Object.entries(selectors)) { + const element = document.querySelector(selector); + result[name] = element ? measureElement(element) : null; + } + return { + elements: result, + document: { + width: document.documentElement.scrollWidth, + height: document.documentElement.scrollHeight, + }, + }; + }, + { selectors, measureSource: rectAndStyle.toString() } + ); + +const browser = await chromium.launch({ headless: true }); +try { + const output = {}; + for (const viewport of [ + { name: 'desktop', width: 1000, height: 900 }, + { name: 'mobile', width: 500, height: 900 }, + ]) { + const context = await browser.newContext({ + viewport: { width: viewport.width, height: viewport.height }, + deviceScaleFactor: 1, + locale: 'ko-KR', + timezoneId: 'Asia/Seoul', + colorScheme: 'dark', + }); + const page = await context.newPage(); + const consoleErrors = []; + const failedResources = []; + page.on('console', (message) => { + if (message.type() === 'error') consoleErrors.push(message.text()); + }); + page.on('response', (response) => { + if (response.status() >= 400) failedResources.push(`${response.status()} ${response.url()}`); + }); + await login(context, page); + await page.goto(new URL('hwe/', baseUrl).toString(), { waitUntil: 'networkidle' }); + + await page.goto(new URL('hwe/b_myPage.php', baseUrl).toString(), { waitUntil: 'networkidle' }); + await page.locator('#container').waitFor(); + const myPage = await measure(page, { + body: 'body', + container: '#container', + title: '#container > .row:first-child', + infoColumn: '#container > .row:nth-child(2) > .col:first-child', + settingsColumn: '#container > .row:nth-child(2) > .col:nth-child(2)', + saveButton: '#set_my_setting', + firstSelect: 'select', + customCss: '#custom_css', + firstLogTitle: '#generalActionPlate', + }); + await page.screenshot({ path: resolve(artifactRoot, `ref-my-page-${viewport.name}.png`), fullPage: true }); + + await page.goto(new URL('hwe/a_traffic.php', baseUrl).toString(), { waitUntil: 'networkidle' }); + const traffic = await measure(page, { + body: 'body', + title: 'body > table:first-of-type', + chartLayout: 'body > table:nth-of-type(2)', + refreshChart: 'body > table:nth-of-type(2) > tbody > tr > td:first-child > table', + onlineChart: 'body > table:nth-of-type(2) > tbody > tr > td:nth-child(2) > table', + firstBigBar: '.big_bar', + suspectTable: 'body > table:nth-of-type(3)', + }); + await page.screenshot({ path: resolve(artifactRoot, `ref-traffic-${viewport.name}.png`), fullPage: true }); + + await page.goto(new URL('hwe/a_npcList.php', baseUrl).toString(), { waitUntil: 'networkidle' }); + const npcList = await measure(page, { + body: 'body', + title: 'body > table:first-of-type', + sortSelect: 'select[name="type"]', + list: 'body > table:nth-of-type(2)', + header: 'body > table:nth-of-type(2) tr:first-child', + footer: 'body > table:nth-of-type(3)', + }); + await page.screenshot({ path: resolve(artifactRoot, `ref-npc-list-${viewport.name}.png`), fullPage: true }); + + await page.goto(new URL('hwe/v_battleCenter.php', baseUrl).toString(), { waitUntil: 'networkidle' }); + try { + await page.locator('#container').waitFor({ timeout: 10_000 }); + } catch { + throw new Error( + `Reference battle center failed to mount: ${JSON.stringify({ + url: page.url(), + text: (await page.locator('body').innerText()).slice(0, 500), + html: (await page.content()).slice(-1_000), + consoleErrors, + failedResources, + })}` + ); + } + const battleCenter = await measure(page, { + body: 'body', + container: '#container', + topBar: '#container > :first-child', + selectorRow: '#container > .row:nth-child(2)', + previousButton: '#container > .row:nth-child(2) button:first-child', + firstSelect: '#container > .row:nth-child(2) select:first-of-type', + generalCard: '.header-cell', + firstLogHeader: '.header-cell:nth-of-type(1)', + }); + await page.screenshot({ + path: resolve(artifactRoot, `ref-battle-center-${viewport.name}.png`), + fullPage: true, + }); + output[viewport.name] = { myPage, traffic, npcList, battleCenter }; + await context.close(); + } + await writeFile(resolve(artifactRoot, 'computed-dom.json'), `${JSON.stringify(output, null, 2)}\n`); + process.stdout.write(`${JSON.stringify({ ok: true, artifactRoot, viewports: Object.keys(output) })}\n`); +} finally { + await browser.close(); +} From 7fc0850f83e722cdc9e074cba058c2700c87ce72 Mon Sep 17 00:00:00 2001 From: hided62 Date: Sun, 26 Jul 2026 05:22:30 +0000 Subject: [PATCH 11/19] Exclude concurrent public traffic work --- app/game-api/src/router/public/index.ts | 117 ------- app/game-api/test/publicTraffic.test.ts | 115 ------- app/game-frontend/src/router/index.ts | 6 - app/game-frontend/src/views/MainView.vue | 1 - app/game-frontend/src/views/PublicView.vue | 1 - app/game-frontend/src/views/TrafficView.vue | 343 -------------------- 6 files changed, 583 deletions(-) delete mode 100644 app/game-api/test/publicTraffic.test.ts delete mode 100644 app/game-frontend/src/views/TrafficView.vue diff --git a/app/game-api/src/router/public/index.ts b/app/game-api/src/router/public/index.ts index f4ef4d8..e7a0f59 100644 --- a/app/game-api/src/router/public/index.ts +++ b/app/game-api/src/router/public/index.ts @@ -42,14 +42,6 @@ type NationCountRow = { type NpcListSort = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8; -type TrafficHistoryItem = { - year: number; - month: number; - refresh: number; - online: number; - date: string; -}; - const PUBLIC_CACHE_TTL_SECONDS = 600; const buildPublicCacheKey = (ctx: GameApiContext, key: string): string => @@ -171,26 +163,6 @@ const readFiniteMetaNumber = (meta: Record, key: string): numbe return typeof value === 'number' && Number.isFinite(value) ? value : 0; }; -const parseTrafficHistory = (value: unknown): TrafficHistoryItem[] => { - if (!Array.isArray(value)) { - return []; - } - - const result: TrafficHistoryItem[] = []; - for (const item of value) { - const row = asRecord(item); - const year = readFiniteMetaNumber(row, 'year'); - const month = readFiniteMetaNumber(row, 'month'); - const refresh = readFiniteMetaNumber(row, 'refresh'); - const online = readFiniteMetaNumber(row, 'online'); - const date = typeof row.date === 'string' ? row.date : ''; - if (year > 0 && month > 0 && date) { - result.push({ year, month, refresh, online, date }); - } - } - return result; -}; - const compareString = (left: string, right: string): number => { if (left === right) { return 0; @@ -250,95 +222,6 @@ export const publicRouter = router({ getNationList: procedure.query(async ({ ctx }) => { return loadCachedNationList(ctx); }), - getTraffic: procedure.query(async ({ ctx }) => { - const worldState = await ctx.db.worldState.findFirst(); - if (!worldState) { - throw new TRPCError({ - code: 'PRECONDITION_FAILED', - message: 'World state is not initialized.', - }); - } - - const meta = asRecord(worldState.meta); - const rawOnlineSince = meta.lastTurnTime ?? meta.turntime; - const parsedOnlineSince = - typeof rawOnlineSince === 'string' || rawOnlineSince instanceof Date - ? new Date(rawOnlineSince) - : null; - const onlineSince = - parsedOnlineSince && Number.isFinite(parsedOnlineSince.getTime()) - ? parsedOnlineSince - : new Date(Date.now() - worldState.tickSeconds * 1_000); - const [accessTotal, currentOnline, topAccess] = await Promise.all([ - ctx.db.generalAccessLog.aggregate({ - _sum: { - refresh: true, - refreshScoreTotal: true, - }, - }), - ctx.db.generalAccessLog.count({ - where: { - lastRefresh: { - gte: onlineSince, - }, - }, - }), - ctx.db.generalAccessLog.findMany({ - orderBy: [{ refresh: 'desc' }, { generalId: 'asc' }], - take: 5, - select: { - generalId: true, - refresh: true, - refreshScoreTotal: true, - }, - }), - ]); - - const generalIds = topAccess.map((entry) => entry.generalId); - const generalRows = - generalIds.length > 0 - ? await ctx.db.general.findMany({ - where: { id: { in: generalIds } }, - select: { id: true, name: true }, - }) - : []; - const generalName = new Map(generalRows.map((general) => [general.id, general.name])); - const totalRefresh = accessTotal._sum.refresh ?? 0; - const totalRefreshScore = accessTotal._sum.refreshScoreTotal ?? 0; - const currentRefresh = Math.max(readFiniteMetaNumber(meta, 'refresh'), totalRefresh); - const history = parseTrafficHistory(meta.recentTraffic); - history.push({ - year: worldState.currentYear, - month: worldState.currentMonth, - refresh: currentRefresh, - online: currentOnline, - date: new Date().toISOString(), - }); - - return { - history, - maxRefresh: Math.max( - 1, - readFiniteMetaNumber(meta, 'maxrefresh'), - ...history.map((entry) => entry.refresh) - ), - maxOnline: Math.max(1, readFiniteMetaNumber(meta, 'maxonline'), ...history.map((entry) => entry.online)), - suspects: [ - { - generalId: null, - name: '접속자 총합', - refresh: totalRefresh, - refreshScoreTotal: totalRefreshScore, - }, - ...topAccess.map((entry) => ({ - generalId: entry.generalId, - name: generalName.get(entry.generalId) ?? `장수 ${entry.generalId}`, - refresh: entry.refresh, - refreshScoreTotal: entry.refreshScoreTotal, - })), - ], - }; - }), getGeneralList: procedure.query(async ({ ctx }) => { const [generals, nations] = await Promise.all([ ctx.db.general.findMany({ diff --git a/app/game-api/test/publicTraffic.test.ts b/app/game-api/test/publicTraffic.test.ts deleted file mode 100644 index 78008d8..0000000 --- a/app/game-api/test/publicTraffic.test.ts +++ /dev/null @@ -1,115 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import type { RedisConnector } from '@sammo-ts/infra'; - -import { RedisAccessTokenStore } from '../src/auth/accessTokenStore.js'; -import { InMemoryFlushStore } from '../src/auth/flushStore.js'; -import { InMemoryBattleSimTransport } from '../src/battleSim/inMemoryTransport.js'; -import type { DatabaseClient, GameApiContext, GameProfile } from '../src/context.js'; -import { InMemoryTurnDaemonTransport } from '../src/daemon/inMemoryTransport.js'; -import { appRouter } from '../src/router.js'; - -const profile: GameProfile = { - id: 'che', - scenario: 'default', - name: 'che:default', -}; - -const buildContext = (): GameApiContext => { - const db = { - worldState: { - findFirst: async () => ({ - id: 1, - currentYear: 185, - currentMonth: 3, - tickSeconds: 600, - config: {}, - meta: { - lastTurnTime: '2026-07-26T03:00:00.000Z', - refresh: 12, - maxrefresh: 30, - maxonline: 5, - recentTraffic: [ - { - year: 185, - month: 2, - refresh: 30, - online: 5, - date: '2026-07-26 02:50:00', - }, - ], - }, - }), - }, - generalAccessLog: { - aggregate: async () => ({ - _sum: { - refresh: 12, - refreshScoreTotal: 21, - }, - }), - count: async (args: { where: { lastRefresh: { gte: Date } } }) => { - expect(args.where.lastRefresh.gte).toEqual(new Date('2026-07-26T03:00:00.000Z')); - return 2; - }, - findMany: async () => [ - { generalId: 7, refresh: 9, refreshScoreTotal: 15 }, - { generalId: 8, refresh: 3, refreshScoreTotal: 6 }, - ], - }, - general: { - findMany: async () => [ - { id: 7, name: '갑' }, - { id: 8, name: '을' }, - ], - }, - }; - const redis = { - get: async () => null, - set: async () => null, - } as unknown as RedisConnector['client']; - - return { - db: db as unknown as DatabaseClient, - turnDaemon: new InMemoryTurnDaemonTransport(), - battleSim: new InMemoryBattleSimTransport(), - profile, - auth: null, - uploadDir: 'uploads', - uploadPath: '/uploads', - uploadPublicUrl: null, - redis, - accessTokenStore: new RedisAccessTokenStore(redis, profile.name), - flushStore: new InMemoryFlushStore(), - gameTokenSecret: 'test-secret', - }; -}; - -describe('public.getTraffic', () => { - it('is public and returns only aggregate traffic plus allowlisted general names', async () => { - const result = await appRouter.createCaller(buildContext()).public.getTraffic(); - - expect(result.history).toHaveLength(2); - expect(result.history[0]).toEqual({ - year: 185, - month: 2, - refresh: 30, - online: 5, - date: '2026-07-26 02:50:00', - }); - expect(result.history[1]).toMatchObject({ - year: 185, - month: 3, - refresh: 12, - online: 2, - }); - expect(result.maxRefresh).toBe(30); - expect(result.maxOnline).toBe(5); - expect(result.suspects).toEqual([ - { generalId: null, name: '접속자 총합', refresh: 12, refreshScoreTotal: 21 }, - { generalId: 7, name: '갑', refresh: 9, refreshScoreTotal: 15 }, - { generalId: 8, name: '을', refresh: 3, refreshScoreTotal: 6 }, - ]); - expect(JSON.stringify(result)).not.toContain('userId'); - }); -}); diff --git a/app/game-frontend/src/router/index.ts b/app/game-frontend/src/router/index.ts index 63d0a16..ac0ccb2 100644 --- a/app/game-frontend/src/router/index.ts +++ b/app/game-frontend/src/router/index.ts @@ -32,7 +32,6 @@ import TroopView from '../views/TroopView.vue'; import YearbookView from '../views/YearbookView.vue'; import NationBettingView from '../views/NationBettingView.vue'; import NpcListView from '../views/NpcListView.vue'; -import TrafficView from '../views/TrafficView.vue'; import { useSessionStore } from '../stores/session'; const routes = [ @@ -258,11 +257,6 @@ const routes = [ requiresGeneral: true, }, }, - { - path: '/traffic', - name: 'traffic', - component: TrafficView, - }, { path: '/npc-list', name: 'npc-list', diff --git a/app/game-frontend/src/views/MainView.vue b/app/game-frontend/src/views/MainView.vue index 8459ebc..857e327 100644 --- a/app/game-frontend/src/views/MainView.vue +++ b/app/game-frontend/src/views/MainView.vue @@ -119,7 +119,6 @@ watch( 왕조일람 연감 천통국 베팅 - 접속량정보 빙의일람 게시판 전투 시뮬레이터 diff --git a/app/game-frontend/src/views/PublicView.vue b/app/game-frontend/src/views/PublicView.vue index 1624fcf..6c3684a 100644 --- a/app/game-frontend/src/views/PublicView.vue +++ b/app/game-frontend/src/views/PublicView.vue @@ -108,7 +108,6 @@ onMounted(() => { 장수 생성/빙의 메인으로 빙의일람 - 접속량정보 diff --git a/app/game-frontend/src/views/TrafficView.vue b/app/game-frontend/src/views/TrafficView.vue deleted file mode 100644 index 58b5f30..0000000 --- a/app/game-frontend/src/views/TrafficView.vue +++ /dev/null @@ -1,343 +0,0 @@ - - - - - From fa87f5565c9160b00aa640f3ff1b9dd5e4d36cab Mon Sep 17 00:00:00 2001 From: hided62 Date: Sun, 26 Jul 2026 05:23:25 +0000 Subject: [PATCH 12/19] Revert "Exclude concurrent public traffic work" This reverts commit 7fc0850f83e722cdc9e074cba058c2700c87ce72. --- app/game-api/src/router/public/index.ts | 117 +++++++ app/game-api/test/publicTraffic.test.ts | 115 +++++++ app/game-frontend/src/router/index.ts | 6 + app/game-frontend/src/views/MainView.vue | 1 + app/game-frontend/src/views/PublicView.vue | 1 + app/game-frontend/src/views/TrafficView.vue | 343 ++++++++++++++++++++ 6 files changed, 583 insertions(+) create mode 100644 app/game-api/test/publicTraffic.test.ts create mode 100644 app/game-frontend/src/views/TrafficView.vue diff --git a/app/game-api/src/router/public/index.ts b/app/game-api/src/router/public/index.ts index e7a0f59..f4ef4d8 100644 --- a/app/game-api/src/router/public/index.ts +++ b/app/game-api/src/router/public/index.ts @@ -42,6 +42,14 @@ type NationCountRow = { type NpcListSort = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8; +type TrafficHistoryItem = { + year: number; + month: number; + refresh: number; + online: number; + date: string; +}; + const PUBLIC_CACHE_TTL_SECONDS = 600; const buildPublicCacheKey = (ctx: GameApiContext, key: string): string => @@ -163,6 +171,26 @@ const readFiniteMetaNumber = (meta: Record, key: string): numbe return typeof value === 'number' && Number.isFinite(value) ? value : 0; }; +const parseTrafficHistory = (value: unknown): TrafficHistoryItem[] => { + if (!Array.isArray(value)) { + return []; + } + + const result: TrafficHistoryItem[] = []; + for (const item of value) { + const row = asRecord(item); + const year = readFiniteMetaNumber(row, 'year'); + const month = readFiniteMetaNumber(row, 'month'); + const refresh = readFiniteMetaNumber(row, 'refresh'); + const online = readFiniteMetaNumber(row, 'online'); + const date = typeof row.date === 'string' ? row.date : ''; + if (year > 0 && month > 0 && date) { + result.push({ year, month, refresh, online, date }); + } + } + return result; +}; + const compareString = (left: string, right: string): number => { if (left === right) { return 0; @@ -222,6 +250,95 @@ export const publicRouter = router({ getNationList: procedure.query(async ({ ctx }) => { return loadCachedNationList(ctx); }), + getTraffic: procedure.query(async ({ ctx }) => { + const worldState = await ctx.db.worldState.findFirst(); + if (!worldState) { + throw new TRPCError({ + code: 'PRECONDITION_FAILED', + message: 'World state is not initialized.', + }); + } + + const meta = asRecord(worldState.meta); + const rawOnlineSince = meta.lastTurnTime ?? meta.turntime; + const parsedOnlineSince = + typeof rawOnlineSince === 'string' || rawOnlineSince instanceof Date + ? new Date(rawOnlineSince) + : null; + const onlineSince = + parsedOnlineSince && Number.isFinite(parsedOnlineSince.getTime()) + ? parsedOnlineSince + : new Date(Date.now() - worldState.tickSeconds * 1_000); + const [accessTotal, currentOnline, topAccess] = await Promise.all([ + ctx.db.generalAccessLog.aggregate({ + _sum: { + refresh: true, + refreshScoreTotal: true, + }, + }), + ctx.db.generalAccessLog.count({ + where: { + lastRefresh: { + gte: onlineSince, + }, + }, + }), + ctx.db.generalAccessLog.findMany({ + orderBy: [{ refresh: 'desc' }, { generalId: 'asc' }], + take: 5, + select: { + generalId: true, + refresh: true, + refreshScoreTotal: true, + }, + }), + ]); + + const generalIds = topAccess.map((entry) => entry.generalId); + const generalRows = + generalIds.length > 0 + ? await ctx.db.general.findMany({ + where: { id: { in: generalIds } }, + select: { id: true, name: true }, + }) + : []; + const generalName = new Map(generalRows.map((general) => [general.id, general.name])); + const totalRefresh = accessTotal._sum.refresh ?? 0; + const totalRefreshScore = accessTotal._sum.refreshScoreTotal ?? 0; + const currentRefresh = Math.max(readFiniteMetaNumber(meta, 'refresh'), totalRefresh); + const history = parseTrafficHistory(meta.recentTraffic); + history.push({ + year: worldState.currentYear, + month: worldState.currentMonth, + refresh: currentRefresh, + online: currentOnline, + date: new Date().toISOString(), + }); + + return { + history, + maxRefresh: Math.max( + 1, + readFiniteMetaNumber(meta, 'maxrefresh'), + ...history.map((entry) => entry.refresh) + ), + maxOnline: Math.max(1, readFiniteMetaNumber(meta, 'maxonline'), ...history.map((entry) => entry.online)), + suspects: [ + { + generalId: null, + name: '접속자 총합', + refresh: totalRefresh, + refreshScoreTotal: totalRefreshScore, + }, + ...topAccess.map((entry) => ({ + generalId: entry.generalId, + name: generalName.get(entry.generalId) ?? `장수 ${entry.generalId}`, + refresh: entry.refresh, + refreshScoreTotal: entry.refreshScoreTotal, + })), + ], + }; + }), getGeneralList: procedure.query(async ({ ctx }) => { const [generals, nations] = await Promise.all([ ctx.db.general.findMany({ diff --git a/app/game-api/test/publicTraffic.test.ts b/app/game-api/test/publicTraffic.test.ts new file mode 100644 index 0000000..78008d8 --- /dev/null +++ b/app/game-api/test/publicTraffic.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, it } from 'vitest'; + +import type { RedisConnector } from '@sammo-ts/infra'; + +import { RedisAccessTokenStore } from '../src/auth/accessTokenStore.js'; +import { InMemoryFlushStore } from '../src/auth/flushStore.js'; +import { InMemoryBattleSimTransport } from '../src/battleSim/inMemoryTransport.js'; +import type { DatabaseClient, GameApiContext, GameProfile } from '../src/context.js'; +import { InMemoryTurnDaemonTransport } from '../src/daemon/inMemoryTransport.js'; +import { appRouter } from '../src/router.js'; + +const profile: GameProfile = { + id: 'che', + scenario: 'default', + name: 'che:default', +}; + +const buildContext = (): GameApiContext => { + const db = { + worldState: { + findFirst: async () => ({ + id: 1, + currentYear: 185, + currentMonth: 3, + tickSeconds: 600, + config: {}, + meta: { + lastTurnTime: '2026-07-26T03:00:00.000Z', + refresh: 12, + maxrefresh: 30, + maxonline: 5, + recentTraffic: [ + { + year: 185, + month: 2, + refresh: 30, + online: 5, + date: '2026-07-26 02:50:00', + }, + ], + }, + }), + }, + generalAccessLog: { + aggregate: async () => ({ + _sum: { + refresh: 12, + refreshScoreTotal: 21, + }, + }), + count: async (args: { where: { lastRefresh: { gte: Date } } }) => { + expect(args.where.lastRefresh.gte).toEqual(new Date('2026-07-26T03:00:00.000Z')); + return 2; + }, + findMany: async () => [ + { generalId: 7, refresh: 9, refreshScoreTotal: 15 }, + { generalId: 8, refresh: 3, refreshScoreTotal: 6 }, + ], + }, + general: { + findMany: async () => [ + { id: 7, name: '갑' }, + { id: 8, name: '을' }, + ], + }, + }; + const redis = { + get: async () => null, + set: async () => null, + } as unknown as RedisConnector['client']; + + return { + db: db as unknown as DatabaseClient, + turnDaemon: new InMemoryTurnDaemonTransport(), + battleSim: new InMemoryBattleSimTransport(), + profile, + auth: null, + uploadDir: 'uploads', + uploadPath: '/uploads', + uploadPublicUrl: null, + redis, + accessTokenStore: new RedisAccessTokenStore(redis, profile.name), + flushStore: new InMemoryFlushStore(), + gameTokenSecret: 'test-secret', + }; +}; + +describe('public.getTraffic', () => { + it('is public and returns only aggregate traffic plus allowlisted general names', async () => { + const result = await appRouter.createCaller(buildContext()).public.getTraffic(); + + expect(result.history).toHaveLength(2); + expect(result.history[0]).toEqual({ + year: 185, + month: 2, + refresh: 30, + online: 5, + date: '2026-07-26 02:50:00', + }); + expect(result.history[1]).toMatchObject({ + year: 185, + month: 3, + refresh: 12, + online: 2, + }); + expect(result.maxRefresh).toBe(30); + expect(result.maxOnline).toBe(5); + expect(result.suspects).toEqual([ + { generalId: null, name: '접속자 총합', refresh: 12, refreshScoreTotal: 21 }, + { generalId: 7, name: '갑', refresh: 9, refreshScoreTotal: 15 }, + { generalId: 8, name: '을', refresh: 3, refreshScoreTotal: 6 }, + ]); + expect(JSON.stringify(result)).not.toContain('userId'); + }); +}); diff --git a/app/game-frontend/src/router/index.ts b/app/game-frontend/src/router/index.ts index ac0ccb2..63d0a16 100644 --- a/app/game-frontend/src/router/index.ts +++ b/app/game-frontend/src/router/index.ts @@ -32,6 +32,7 @@ import TroopView from '../views/TroopView.vue'; import YearbookView from '../views/YearbookView.vue'; import NationBettingView from '../views/NationBettingView.vue'; import NpcListView from '../views/NpcListView.vue'; +import TrafficView from '../views/TrafficView.vue'; import { useSessionStore } from '../stores/session'; const routes = [ @@ -257,6 +258,11 @@ const routes = [ requiresGeneral: true, }, }, + { + path: '/traffic', + name: 'traffic', + component: TrafficView, + }, { path: '/npc-list', name: 'npc-list', diff --git a/app/game-frontend/src/views/MainView.vue b/app/game-frontend/src/views/MainView.vue index 857e327..8459ebc 100644 --- a/app/game-frontend/src/views/MainView.vue +++ b/app/game-frontend/src/views/MainView.vue @@ -119,6 +119,7 @@ watch( 왕조일람 연감 천통국 베팅 + 접속량정보 빙의일람 게시판 전투 시뮬레이터 diff --git a/app/game-frontend/src/views/PublicView.vue b/app/game-frontend/src/views/PublicView.vue index 6c3684a..1624fcf 100644 --- a/app/game-frontend/src/views/PublicView.vue +++ b/app/game-frontend/src/views/PublicView.vue @@ -108,6 +108,7 @@ onMounted(() => { 장수 생성/빙의 메인으로 빙의일람 + 접속량정보 diff --git a/app/game-frontend/src/views/TrafficView.vue b/app/game-frontend/src/views/TrafficView.vue new file mode 100644 index 0000000..58b5f30 --- /dev/null +++ b/app/game-frontend/src/views/TrafficView.vue @@ -0,0 +1,343 @@ + + + + + From a77957aae346a9a3249474fc12e2bd3aed92c234 Mon Sep 17 00:00:00 2001 From: hided62 Date: Sun, 26 Jul 2026 05:24:00 +0000 Subject: [PATCH 13/19] feat: complete legacy ranking menu parity --- app/game-api/src/router/ranking/index.ts | 161 +++++--- app/game-api/test/rankingRouter.test.ts | 248 ++++++++++++ .../src/views/BestGeneralView.vue | 353 +++++++++++++++--- .../src/views/HallOfFameView.vue | 50 ++- docs/frontend-legacy-parity.md | 15 +- .../fixtures/canonical.ts | 76 ++++ .../reference-rankings.mjs | 155 ++++++++ .../visual-parity.spec.ts | 162 +++++++- 8 files changed, 1096 insertions(+), 124 deletions(-) create mode 100644 app/game-api/test/rankingRouter.test.ts create mode 100644 tools/frontend-legacy-parity/reference-rankings.mjs diff --git a/app/game-api/src/router/ranking/index.ts b/app/game-api/src/router/ranking/index.ts index e73f240..b170a83 100644 --- a/app/game-api/src/router/ranking/index.ts +++ b/app/game-api/src/router/ranking/index.ts @@ -2,8 +2,11 @@ import { z } from 'zod'; import { asRecord, HALL_OF_FAME_TYPES, type HallOfFameType } from '@sammo-ts/common'; import { ITEM_KEYS, ItemLoader, loadItemModules } from '@sammo-ts/logic/items/index.js'; +import type { ItemModule } from '@sammo-ts/logic/items/types.js'; +import { buildLegacyDefaultUniqueItemPool } from '@sammo-ts/logic/rewards/legacyUniqueItemPool.js'; +import { resolveUniqueConfig } from '@sammo-ts/logic/rewards/uniqueLottery.js'; -import { procedure, router } from '../../trpc.js'; +import { authedProcedure, procedure, router } from '../../trpc.js'; const DEFAULT_BG_COLOR = '#2b2b2b'; const DEFAULT_FG_COLOR = '#ffffff'; @@ -23,31 +26,31 @@ const readMetaNumber = (value: unknown): number => { const percentText = (value: number): string => `${(value * 100).toFixed(2)}%`; +const readOwnerDisplayName = (value: unknown): string | null => { + const meta = asRecord(value); + if (typeof meta.ownerName === 'string' && meta.ownerName.length > 0) { + return meta.ownerName; + } + if (typeof meta.owner_name === 'string' && meta.owner_name.length > 0) { + return meta.owner_name; + } + return null; +}; + const itemLoader = new ItemLoader(); -let cachedUniqueItems: Promise< - Array<{ key: string; name: string; slot: string; unique: boolean; buyable: boolean; info: string }> -> | null = null; +let cachedUniqueItems: Promise | null = null; const loadUniqueItems = () => { if (!cachedUniqueItems) { cachedUniqueItems = loadItemModules([...ITEM_KEYS], itemLoader).then((modules) => - modules - .filter((module) => module.unique && !module.buyable) - .map((module) => ({ - key: module.key, - name: module.name, - slot: module.slot, - unique: module.unique, - buyable: module.buyable, - info: module.info, - })) + modules.filter((module) => module.unique && !module.buyable) ); } return cachedUniqueItems; }; export const rankingRouter = router({ - getBestGeneral: procedure + getBestGeneral: authedProcedure .input( z .object({ @@ -57,7 +60,7 @@ export const rankingRouter = router({ ) .query(async ({ ctx, input }) => { const worldState = await ctx.db.worldState.findFirst({ - select: { meta: true }, + select: { meta: true, config: true }, }); const meta = asRecord(worldState?.meta); const isUnited = typeof meta.isUnited === 'number' && meta.isUnited !== 0; @@ -76,6 +79,7 @@ export const rankingRouter = router({ userId: true, picture: true, imageServer: true, + meta: true, experience: true, dedication: true, horseCode: true, @@ -185,7 +189,7 @@ export const rankingRouter = router({ let display = { id: general.id, name: general.name, - ownerName: general.userId ?? null, + ownerName: isUnited ? readOwnerDisplayName(general.meta) : null, nationName: nation?.name ?? '재야', bgColor: nation?.color ?? DEFAULT_BG_COLOR, fgColor: DEFAULT_FG_COLOR, @@ -217,46 +221,91 @@ export const rankingRouter = router({ }); const uniqueItems = await loadUniqueItems(); - const itemEntries = uniqueItems.map((item) => { - const owners = generals.filter((general) => { - if (item.slot === 'horse') { - return general.horseCode === item.key; + const itemRegistry = new Map(uniqueItems.map((item) => [item.key, item])); + const uniqueConfig = resolveUniqueConfig(asRecord(asRecord(worldState?.config).const)); + if (Object.keys(uniqueConfig.allItems).length === 0) { + uniqueConfig.allItems = buildLegacyDefaultUniqueItemPool(itemRegistry); + } + const activeAuctions = await ctx.db.auction.findMany({ + where: { + type: 'UNIQUE_ITEM', + status: { in: ['OPEN', 'FINALIZING'] }, + targetCode: { not: null }, + }, + select: { targetCode: true }, + }); + const auctionCounts = new Map(); + for (const auction of activeAuctions) { + if (auction.targetCode) { + auctionCounts.set(auction.targetCode, (auctionCounts.get(auction.targetCode) ?? 0) + 1); + } + } + const slotTitles = { + horse: '명 마', + weapon: '명 검', + book: '명 서', + item: '도 구', + } as const; + const itemEntries = (['horse', 'weapon', 'book', 'item'] as const).map((slot) => { + const configuredItems = Object.entries(uniqueConfig.allItems[slot] ?? {}).reverse(); + const entries = configuredItems.flatMap(([itemKey, rawCount]) => { + const item = itemRegistry.get(itemKey); + if (!item || item.buyable) { + return []; } - if (item.slot === 'weapon') { - return general.weaponCode === item.key; + const owners = generals + .filter((general) => { + if (slot === 'horse') { + return general.horseCode === itemKey; + } + if (slot === 'weapon') { + return general.weaponCode === itemKey; + } + if (slot === 'book') { + return general.bookCode === itemKey; + } + return general.itemCode === itemKey; + }) + .map((general) => { + const nation = nationMap.get(general.nationId) ?? null; + return { + id: general.id, + name: general.name, + nationName: nation?.name ?? '재야', + bgColor: nation?.color ?? DEFAULT_BG_COLOR, + fgColor: DEFAULT_FG_COLOR, + picture: general.picture ?? null, + imageServer: general.imageServer ?? 0, + }; + }); + for (let index = 0; index < (auctionCounts.get(itemKey) ?? 0); index += 1) { + owners.push({ + id: 0, + name: '경매중', + nationName: '-', + bgColor: '#00582c', + fgColor: '#ffffff', + picture: null, + imageServer: 0, + }); } - if (item.slot === 'book') { - return general.bookCode === item.key; - } - return general.itemCode === item.key; + const count = Math.max(0, Math.floor(rawCount)); + return Array.from({ length: count }, (_, index) => ({ + itemKey, + itemName: item.name, + itemInfo: item.info, + owner: owners[index] ?? { + id: 0, + name: '미발견', + nationName: '-', + bgColor: DEFAULT_BG_COLOR, + fgColor: DEFAULT_FG_COLOR, + picture: null, + imageServer: 0, + }, + })); }); - - const displayOwners = owners.length - ? owners.map((general) => { - const nation = nationMap.get(general.nationId) ?? null; - return { - id: general.id, - name: general.name, - nationName: nation?.name ?? '재야', - bgColor: nation?.color ?? DEFAULT_BG_COLOR, - fgColor: DEFAULT_FG_COLOR, - }; - }) - : [ - { - id: 0, - name: '미발견', - nationName: '-', - bgColor: DEFAULT_BG_COLOR, - fgColor: DEFAULT_FG_COLOR, - }, - ]; - - return { - title: item.name, - slot: item.slot, - owners: displayOwners, - }; + return { title: slotTitles[slot], slot, entries }; }); return { @@ -339,6 +388,10 @@ export const rankingRouter = router({ return { generalId: row.generalNo, name: String(aux.name ?? ''), + ownerName: + typeof aux.ownerDisplayName === 'string' && aux.ownerDisplayName.length > 0 + ? aux.ownerDisplayName + : null, nationName: String(aux.nationName ?? ''), bgColor: String(aux.bgColor ?? DEFAULT_BG_COLOR), fgColor: String(aux.fgColor ?? DEFAULT_FG_COLOR), diff --git a/app/game-api/test/rankingRouter.test.ts b/app/game-api/test/rankingRouter.test.ts new file mode 100644 index 0000000..7f897a6 --- /dev/null +++ b/app/game-api/test/rankingRouter.test.ts @@ -0,0 +1,248 @@ +import { describe, expect, it } 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 { InMemoryBattleSimTransport } from '../src/battleSim/inMemoryTransport.js'; +import type { DatabaseClient, GameApiContext, GameProfile } from '../src/context.js'; +import { InMemoryTurnDaemonTransport } from '../src/daemon/inMemoryTransport.js'; +import { appRouter } from '../src/router.js'; + +const profile: GameProfile = { + id: 'che', + scenario: 'default', + name: 'che:default', +}; + +const auth: GameSessionTokenPayload = { + version: 1, + profile: 'che', + issuedAt: '2026-07-26T00:00:00.000Z', + expiresAt: '2026-07-27T00:00:00.000Z', + sessionId: 'ranking-session', + user: { + id: 'request-user-id', + username: 'ranking-user', + displayName: '조회자', + roles: [], + }, + sanctions: {}, +}; + +const generalRows = [ + { + id: 1, + name: '유비', + nationId: 1, + userId: 'private-user-id-1', + npcState: 0, + picture: '1.jpg', + imageServer: 0, + meta: { ownerName: '공개소유자' }, + experience: 1200, + dedication: 900, + horseCode: 'che_명마_15_적토마', + weaponCode: 'None', + bookCode: 'None', + itemCode: 'None', + }, + { + id: 2, + name: '빙의관우', + nationId: 1, + userId: 'private-user-id-2', + npcState: 1, + picture: null, + imageServer: 0, + meta: { owner_name: '빙의소유자' }, + experience: 1100, + dedication: 800, + horseCode: 'None', + weaponCode: 'None', + bookCode: 'None', + itemCode: 'None', + }, + { + id: 3, + name: 'NPC조조', + nationId: 2, + userId: null, + npcState: 2, + picture: null, + imageServer: 0, + meta: {}, + experience: 1300, + dedication: 1000, + horseCode: 'None', + weaponCode: 'None', + bookCode: 'None', + itemCode: 'None', + }, +] as const; + +const buildContext = (options?: { + authenticated?: boolean; + isUnited?: boolean; + includeOwnerDisplayName?: boolean; +}): GameApiContext => { + const db = { + worldState: { + findFirst: async () => ({ + meta: { isUnited: options?.isUnited ? 1 : 0 }, + config: { + const: { + allItems: { + horse: { che_명마_15_적토마: 2 }, + weapon: {}, + book: {}, + item: {}, + }, + }, + }, + }), + }, + nation: { + findMany: async () => [ + { id: 1, name: '촉', color: '#006400' }, + { id: 2, name: '위', color: '#8b0000' }, + ], + }, + general: { + findMany: async (args: { where: { npcState: { lt?: number; gte?: number } } }) => + generalRows.filter((general) => + args.where.npcState.gte !== undefined + ? general.npcState >= args.where.npcState.gte + : general.npcState < (args.where.npcState.lt ?? Number.POSITIVE_INFINITY) + ), + }, + rankData: { + findMany: async () => [ + { generalId: 1, type: 'firenum', value: 10 }, + { generalId: 2, type: 'firenum', value: 20 }, + { generalId: 3, type: 'firenum', value: 30 }, + ], + }, + auction: { + findMany: async () => [{ targetCode: 'che_명마_15_적토마' }], + }, + gameHistory: { + findMany: async () => [ + { season: 3, scenario: 22, scenarioName: '가상모드22' }, + { season: 3, scenario: 22, scenarioName: '가상모드22' }, + ], + }, + hallOfFame: { + findMany: async (args: { where: { type: string } }) => + args.where.type === 'experience' + ? [ + { + generalNo: 1, + value: 1200, + aux: { + name: '유비', + ownerName: 'private-hall-user-id', + ...(options?.includeOwnerDisplayName ? { ownerDisplayName: '공개소유자' } : {}), + nationName: '촉', + bgColor: '#006400', + fgColor: '#ffffff', + }, + }, + ] + : [], + }, + }; + const redis = { + get: async () => null, + set: async () => null, + } as unknown as RedisConnector['client']; + + return { + db: db as unknown as DatabaseClient, + turnDaemon: new InMemoryTurnDaemonTransport(), + battleSim: new InMemoryBattleSimTransport(), + profile, + auth: options?.authenticated === false ? null : auth, + uploadDir: 'uploads', + uploadPath: '/uploads', + uploadPublicUrl: null, + redis, + accessTokenStore: new RedisAccessTokenStore(redis, profile.name), + flushStore: new InMemoryFlushStore(), + gameTokenSecret: 'test-secret', + }; +}; + +describe('ranking.getBestGeneral', () => { + it('requires a game login even though the ranking is the same for every authenticated user', async () => { + await expect( + appRouter.createCaller(buildContext({ authenticated: false })).ranking.getBestGeneral({ view: 'user' }) + ).rejects.toMatchObject({ code: 'UNAUTHORIZED' }); + }); + + it('keeps possessed generals in the user view and redacts account identifiers before unification', async () => { + const result = await appRouter.createCaller(buildContext({ isUnited: false })).ranking.getBestGeneral({ + view: 'user', + }); + + expect(result.sections[0]?.entries.map((entry) => entry.id)).toEqual([1, 2]); + expect(result.sections[0]?.entries.map((entry) => entry.ownerName)).toEqual([null, null]); + expect(result.sections.find((section) => section.title === '계 략 성 공')?.entries).toEqual([ + expect.objectContaining({ id: 2, name: '???', nationName: '???', ownerName: null }), + expect.objectContaining({ id: 1, name: '???', nationName: '???', ownerName: null }), + ]); + expect(JSON.stringify(result)).not.toContain('private-user-id'); + }); + + it('uses display names only after unification and preserves configured item copies plus auctions', async () => { + const result = await appRouter.createCaller(buildContext({ isUnited: true })).ranking.getBestGeneral({ + view: 'user', + }); + + expect(result.sections[0]?.entries.map((entry) => entry.ownerName)).toEqual(['공개소유자', '빙의소유자']); + expect(result.uniqueItems.find((section) => section.slot === 'horse')?.entries).toEqual([ + expect.objectContaining({ + itemKey: 'che_명마_15_적토마', + owner: expect.objectContaining({ id: 1, name: '유비' }), + }), + expect.objectContaining({ + itemKey: 'che_명마_15_적토마', + owner: expect.objectContaining({ id: 0, name: '경매중' }), + }), + ]); + expect(JSON.stringify(result)).not.toContain('private-user-id'); + }); + + it('separates autonomous NPCs from users and possessed generals', async () => { + const result = await appRouter.createCaller(buildContext()).ranking.getBestGeneral({ view: 'npc' }); + expect(result.sections[0]?.entries.map((entry) => entry.id)).toEqual([3]); + }); +}); + +describe('ranking hall of fame', () => { + it('remains public and groups scenario counts', async () => { + const options = await appRouter + .createCaller(buildContext({ authenticated: false })) + .ranking.getHallOfFameOptions(); + expect(options).toEqual([ + { + season: 3, + scenarios: [{ id: 22, name: '가상모드22', count: 2 }], + }, + ]); + }); + + it('returns an explicit display name but never exposes the stored account identifier', async () => { + const result = await appRouter + .createCaller(buildContext({ authenticated: false, includeOwnerDisplayName: true })) + .ranking.getHallOfFame({ season: 3 }); + expect(result.sections[0]?.entries[0]?.ownerName).toBe('공개소유자'); + expect(JSON.stringify(result)).not.toContain('private-hall-user-id'); + + const redacted = await appRouter + .createCaller(buildContext({ authenticated: false })) + .ranking.getHallOfFame({ season: 3 }); + expect(redacted.sections[0]?.entries[0]?.ownerName).toBeNull(); + }); +}); diff --git a/app/game-frontend/src/views/BestGeneralView.vue b/app/game-frontend/src/views/BestGeneralView.vue index 63e2add..cf4345b 100644 --- a/app/game-frontend/src/views/BestGeneralView.vue +++ b/app/game-frontend/src/views/BestGeneralView.vue @@ -1,5 +1,7 @@ + + diff --git a/app/game-frontend/src/views/HallOfFameView.vue b/app/game-frontend/src/views/HallOfFameView.vue index 3ea0afa..f112db3 100644 --- a/app/game-frontend/src/views/HallOfFameView.vue +++ b/app/game-frontend/src/views/HallOfFameView.vue @@ -135,7 +135,7 @@ onMounted(async () => { -
{{ errorMessage }}
+
불러오는 중...
표시할 데이터가 없습니다.
@@ -171,6 +171,10 @@ onMounted(async () => {
+
+ 삼국지 모의전투 HiDCHe core2026 / KOEI의 이미지를 사용, 응용하였습니다 / 제작 : HideD / + Credit +
@@ -191,7 +195,7 @@ onMounted(async () => { .legacy-hall-title, .legacy-hall-bottom { - text-align: center; + text-align: left; } .legacy-hall-title { @@ -205,12 +209,33 @@ onMounted(async () => { } .scenario-search select { - min-width: 220px; + width: 189px; + height: 20px; border: 1px solid #555; background: #ddd; color: #303030; } +.legacy-button { + border: 0; + border-radius: 5.25px; + background: #375a7f; + padding: 5.25px 10.5px; + font-weight: 700; + line-height: 21px; +} + +.legacy-button:hover, +.legacy-button:focus, +.legacy-button:active { + background: #6b6b6b; +} + +.legacy-button:focus-visible { + outline: revert; + outline-offset: 0; +} + .legacy-message { border: 1px solid gray; padding: 12px; @@ -221,6 +246,15 @@ onMounted(async () => { color: #ff6b6b; } +.legacy-banner { + font-size: 13px; +} + +.legacy-banner a { + color: #fff; + text-decoration: underline; +} + .hall-sections { display: block; } @@ -235,7 +269,9 @@ onMounted(async () => { margin: 0; border-bottom: 1px solid gray; padding: 2px; - font-size: 1.17em; + font-size: calc(19px + 0.784615vw); + font-weight: 500; + line-height: 1.2; text-align: center; } @@ -275,7 +311,7 @@ onMounted(async () => { display: inline-block; width: 64px; height: 64px; - object-fit: cover; + object-fit: fill; } .hall-server, @@ -309,5 +345,9 @@ onMounted(async () => { .legacy-hall-page { width: 1000px; } + + .rankType { + font-size: 28px; + } } diff --git a/docs/frontend-legacy-parity.md b/docs/frontend-legacy-parity.md index 0162c50..252506f 100644 --- a/docs/frontend-legacy-parity.md +++ b/docs/frontend-legacy-parity.md @@ -19,6 +19,8 @@ tree instead of replacing images with layout-neutral placeholders. NPC list, including mutations and recoverable API failures. `tournament-betting.spec.ts` covers the separate tournament and tournament betting routes, including a recoverable failed bet. +`reference-rankings.mjs` records the authenticated PHP 명장일람 and public +명예의 전당 computed DOM without embedding the reference password. Run the suite from the core2026 repository root: @@ -50,7 +52,8 @@ storage, route guards, and image loading. | 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 | +| best general | `hwe/a_bestGeneral.php` | authenticated 500/1000px ranking and unique-item grids, user/NPC switch, 100/64px cell/image geometry, title/button computed styles, retained-data API error | +| hall of fame | `hwe/a_hallOfFame.php` | public 500/1000px container, 100px ranking cells, 64px natural image, title/button/select computed styles, scenario switch and retained-data API error | | 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 | @@ -85,6 +88,16 @@ Adding or changing a frontend route requires: Pixel snapshots may be added after these structural assertions pass. Dynamic regions must not be hidden merely to make a pixel threshold pass. +To refresh the PHP ranking evidence after building the ignored reference +webpack assets, run: + +```sh +REF_RANKING_URL=http://127.0.0.1:3400/sam/ \ +REF_RANKING_PASSWORD_FILE=/path/to/ignored/user1_password \ +REF_RANKING_ARTIFACT_DIR=/path/to/ignored/artifacts \ +node tools/frontend-legacy-parity/reference-rankings.mjs +``` + The nation office suite can be run independently: ```sh diff --git a/tools/frontend-legacy-parity/fixtures/canonical.ts b/tools/frontend-legacy-parity/fixtures/canonical.ts index 8d7d5cf..fd14ef8 100644 --- a/tools/frontend-legacy-parity/fixtures/canonical.ts +++ b/tools/frontend-legacy-parity/fixtures/canonical.ts @@ -75,6 +75,82 @@ export const canonicalFrontendFixture = { [2, '진', '#1976d2', 2], ], }, + bestGeneral: { + isUnited: true, + sections: [ + { + title: '명 성', + valueType: 'int', + entries: [ + { + id: 1, + name: '유비', + ownerName: '시각검증', + nationName: '촉', + bgColor: '#006400', + fgColor: '#ffffff', + picture: 'default.jpg', + imageServer: 0, + value: 12000, + printValue: '12,000', + }, + { + id: 2, + name: '조조', + ownerName: '검증계정', + nationName: '위', + bgColor: '#8b0000', + fgColor: '#ffffff', + picture: 'default.jpg', + imageServer: 0, + value: 11000, + printValue: '11,000', + }, + ], + }, + { + title: '계 급', + valueType: 'int', + entries: [], + }, + ], + uniqueItems: [ + { + title: '명 마', + slot: 'horse', + entries: [ + { + itemKey: 'che_명마_15_적토마', + itemName: '적토마', + itemInfo: '최고의 명마', + owner: { + id: 1, + name: '유비', + nationName: '촉', + bgColor: '#006400', + fgColor: '#ffffff', + picture: 'default.jpg', + imageServer: 0, + }, + }, + { + itemKey: 'che_명마_15_적토마', + itemName: '적토마', + itemInfo: '최고의 명마', + owner: { + id: 0, + name: '경매중', + nationName: '-', + bgColor: '#00582c', + fgColor: '#ffffff', + picture: null, + imageServer: 0, + }, + }, + ], + }, + ], + }, hallOptions: [ { season: 1, diff --git a/tools/frontend-legacy-parity/reference-rankings.mjs b/tools/frontend-legacy-parity/reference-rankings.mjs new file mode 100644 index 0000000..36e1a12 --- /dev/null +++ b/tools/frontend-legacy-parity/reference-rankings.mjs @@ -0,0 +1,155 @@ +import { chromium } from '@playwright/test'; +import { createHash } from 'node:crypto'; +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; + +const baseUrl = process.env.REF_RANKING_URL ?? 'https://dev-sam-ref.hided.net/sam/'; +const username = process.env.REF_RANKING_USER ?? 'refuser1'; +const passwordFile = process.env.REF_RANKING_PASSWORD_FILE; +const artifactRoot = resolve(process.env.REF_RANKING_ARTIFACT_DIR ?? 'test-results/reference-rankings'); + +if (!passwordFile) { + throw new Error('REF_RANKING_PASSWORD_FILE is required.'); +} + +const password = (await readFile(passwordFile, 'utf8')).trim(); +await mkdir(artifactRoot, { recursive: true }); + +const login = async (context, page) => { + await page.goto(baseUrl, { waitUntil: 'networkidle', timeout: 60_000 }); + const globalSalt = await page.locator('#global_salt').inputValue(); + const passwordHash = createHash('sha512') + .update(globalSalt + password + globalSalt) + .digest('hex'); + const response = await context.request.post(new URL('api.php?path=Login/LoginByID', baseUrl).toString(), { + data: { username, password: passwordHash }, + }); + const result = await response.json(); + if (!response.ok() || result.result !== true) { + throw new Error('Reference login failed.'); + } +}; + +const measureRanking = async (page) => + page.evaluate(() => { + const pick = (selector) => { + const element = document.querySelector(selector); + if (!element) { + return null; + } + const rect = element.getBoundingClientRect(); + const style = getComputedStyle(element); + return { + rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height }, + style: { + fontFamily: style.fontFamily, + fontSize: style.fontSize, + lineHeight: style.lineHeight, + backgroundImage: style.backgroundImage, + backgroundColor: style.backgroundColor, + color: style.color, + borderTopColor: style.borderTopColor, + borderTopWidth: style.borderTopWidth, + borderRadius: style.borderRadius, + padding: style.padding, + fontWeight: style.fontWeight, + cursor: style.cursor, + minHeight: style.minHeight, + objectFit: style.objectFit, + }, + }; + }; + const image = document.querySelector('.generalIcon'); + return { + title: document.title, + container: pick('#container'), + rankType: pick('.rankType'), + rankCell: pick('.rankView li'), + uniqueCell: pick('.rankView li.no_value'), + image: image + ? { + ...pick('.generalIcon'), + naturalWidth: image.naturalWidth, + naturalHeight: image.naturalHeight, + } + : null, + firstButton: pick('button, input[type="submit"], input[type="button"]'), + rankSectionCount: document.querySelectorAll('.rankView').length, + document: { + width: document.documentElement.scrollWidth, + height: document.documentElement.scrollHeight, + }, + }; + }); + +const browser = await chromium.launch({ headless: true }); +try { + const result = {}; + for (const viewport of [ + { name: 'desktop', width: 1365, height: 768 }, + { name: 'mobile', width: 390, height: 844 }, + ]) { + const context = await browser.newContext({ + viewport: { width: viewport.width, height: viewport.height }, + deviceScaleFactor: 1, + locale: 'ko-KR', + timezoneId: 'UTC', + colorScheme: 'dark', + ignoreHTTPSErrors: true, + }); + const page = await context.newPage(); + await login(context, page); + await page.goto(new URL('hwe/', baseUrl).toString(), { waitUntil: 'networkidle', timeout: 60_000 }); + + await page.goto(new URL('hwe/a_bestGeneral.php', baseUrl).toString(), { + waitUntil: 'networkidle', + timeout: 60_000, + }); + await page.locator('#container').waitFor(); + const bestGeneral = await measureRanking(page); + const userButton = page.getByRole('button', { name: '유저 보기' }); + await userButton.hover(); + bestGeneral.userButtonHover = await userButton.evaluate((element) => { + const style = getComputedStyle(element); + return { backgroundColor: style.backgroundColor, cursor: style.cursor }; + }); + await userButton.focus(); + bestGeneral.userButtonFocus = await userButton.evaluate((element) => getComputedStyle(element).outline); + await page.screenshot({ + path: resolve(artifactRoot, `ref-best-general-${viewport.name}.png`), + fullPage: true, + animations: 'disabled', + }); + + await page.goto(new URL('hwe/a_hallOfFame.php', baseUrl).toString(), { + waitUntil: 'networkidle', + timeout: 60_000, + }); + await page.locator('#container').waitFor(); + const hallOfFame = await measureRanking(page); + const scenario = page.locator('#by_scenario'); + hallOfFame.scenario = await scenario.evaluate((element) => { + const rect = element.getBoundingClientRect(); + const style = getComputedStyle(element); + return { + rect: { width: rect.width, height: rect.height }, + fontFamily: style.fontFamily, + fontSize: style.fontSize, + }; + }); + await scenario.focus(); + hallOfFame.scenarioFocus = await scenario.evaluate((element) => getComputedStyle(element).outline); + await page.screenshot({ + path: resolve(artifactRoot, `ref-hall-of-fame-${viewport.name}.png`), + fullPage: true, + animations: 'disabled', + }); + + result[viewport.name] = { bestGeneral, hallOfFame }; + await context.close(); + } + await writeFile(resolve(artifactRoot, 'computed-dom.json'), `${JSON.stringify(result, null, 2)}\n`); + process.stdout.write(`${JSON.stringify({ ok: true, artifactRoot, viewports: Object.keys(result) })}\n`); +} finally { + await browser.close(); +} diff --git a/tools/frontend-legacy-parity/visual-parity.spec.ts b/tools/frontend-legacy-parity/visual-parity.spec.ts index 85bba67..899a70a 100644 --- a/tools/frontend-legacy-parity/visual-parity.spec.ts +++ b/tools/frontend-legacy-parity/visual-parity.spec.ts @@ -139,6 +139,7 @@ const installAuthenticatedGameFixture = async (page: Page): Promise => { }; } if (operation === 'public.getMapLayout') return fixture.game.mapLayout; + if (operation === 'ranking.getBestGeneral') return fixture.game.bestGeneral; if (operation === 'yearbook.getRange') return fixture.game.yearbookRange; if (operation === 'yearbook.getHistory') return fixture.game.yearbook; if (operation === 'vote.getVoteList') return fixture.game.surveyList; @@ -387,6 +388,117 @@ test.describe('gateway legacy parity', () => { }); }); +test.describe('best general legacy parity', () => { + test.beforeEach(async ({ page }) => { + await installAuthenticatedGameFixture(page); + }); + + for (const viewport of [ + { name: 'desktop', width: 1365, height: 768, expectedWidth: 1000 }, + { name: 'mobile', width: 390, height: 844, expectedWidth: 500 }, + ]) { + test(`matches the ref fixed ranking grid on ${viewport.name}`, async ({ page }) => { + await page.setViewportSize(viewport); + await page.goto('http://127.0.0.1:15102/che/best-general'); + await expect(page.getByText('유비').first()).toBeVisible(); + await expect(page.locator('.rankView')).toHaveCount(3); + if (artifactRoot) { + await page.screenshot({ + path: resolve(artifactRoot, `best-general-core-${viewport.name}.png`), + fullPage: true, + animations: 'disabled', + }); + } + + const geometry = await page.evaluate(() => { + const container = document.querySelector('#best-general-container')!; + const item = document.querySelector('.rankView li')!; + const uniqueItem = document.querySelector('.rankView li.no-value')!; + const title = document.querySelector('.rankType')!; + const image = document.querySelector('.generalIcon')!; + return { + container: { + x: container.getBoundingClientRect().x, + width: container.getBoundingClientRect().width, + fontFamily: getComputedStyle(container).fontFamily, + fontSize: getComputedStyle(container).fontSize, + backgroundImage: getComputedStyle(container).backgroundImage, + }, + item: { + width: item.getBoundingClientRect().width, + minHeight: getComputedStyle(item).minHeight, + }, + uniqueItem: { + minHeight: getComputedStyle(uniqueItem).minHeight, + }, + title: { + fontSize: getComputedStyle(title).fontSize, + lineHeight: getComputedStyle(title).lineHeight, + backgroundImage: getComputedStyle(title).backgroundImage, + }, + image: { + width: image.getBoundingClientRect().width, + height: image.getBoundingClientRect().height, + naturalWidth: image.naturalWidth, + objectFit: getComputedStyle(image).objectFit, + }, + closeX: document + .querySelector('.legacy-ranking-title .legacy-button')! + .getBoundingClientRect().x, + }; + }); + + expect(geometry.container.width).toBe(viewport.expectedWidth); + expect(geometry.container.fontFamily).toContain('Pretendard'); + expect(geometry.container.fontSize).toBe('14px'); + expect(geometry.container.backgroundImage).toContain('back_walnut.jpg'); + expect(geometry.closeX).toBe(geometry.container.x); + expect(geometry.item).toEqual({ width: 100, minHeight: '149px' }); + expect(geometry.uniqueItem.minHeight).toBe('128px'); + expect(geometry.title).toMatchObject({ + fontSize: viewport.name === 'desktop' ? '28px' : '22.06px', + lineHeight: viewport.name === 'desktop' ? '33.6px' : '26.472px', + }); + expect(geometry.title.backgroundImage).toContain('back_green.jpg'); + expect(geometry.image).toMatchObject({ width: 64, height: 64, objectFit: 'fill' }); + expect(geometry.image.naturalWidth).toBeGreaterThan(0); + + const npcButton = page.getByRole('button', { name: 'NPC 보기' }); + await npcButton.hover(); + await expect(npcButton).toHaveCSS('background-color', 'rgb(107, 107, 107)'); + await npcButton.focus(); + await expect(npcButton).toBeFocused(); + await npcButton.click(); + await expect(npcButton).toHaveAttribute('aria-pressed', 'true'); + + const itemName = page.locator('.item-name').first(); + await itemName.hover(); + await expect(itemName).toHaveAttribute('title', '최고의 명마'); + }); + } + + test('keeps the current ranking and selected user type after an API error', async ({ page }) => { + await page.goto('http://127.0.0.1:15102/che/best-general'); + await expect(page.getByText('유비').first()).toBeVisible(); + await page.route('**/che/api/trpc/**', async (route) => { + if (operationNames(route).includes('ranking.getBestGeneral')) { + await route.fulfill({ + status: 500, + contentType: 'application/json', + body: JSON.stringify({ error: { message: '명장일람 조회에 실패했습니다.' } }), + }); + return; + } + await route.fallback(); + }); + const npcButton = page.getByRole('button', { name: 'NPC 보기' }); + await npcButton.click(); + await expect(page.getByRole('alert')).toBeVisible(); + await expect(page.getByText('유비').first()).toBeVisible(); + await expect(npcButton).toHaveAttribute('aria-pressed', 'true'); + }); +}); + test.describe('hall of fame legacy parity', () => { test.beforeEach(async ({ page }) => { await installHallFixture(page); @@ -401,6 +513,13 @@ test.describe('hall of fame legacy parity', () => { await page.goto('http://127.0.0.1:15102/che/hall-of-fame'); await expect(page.getByText('유비')).toBeVisible(); await expect(page.locator('.rankView')).toHaveCount(2); + if (artifactRoot) { + await page.screenshot({ + path: resolve(artifactRoot, `hall-of-fame-core-${viewport.name}.png`), + fullPage: true, + animations: 'disabled', + }); + } const geometry = await page.evaluate(() => { const container = document.querySelector('#container')!; @@ -409,7 +528,10 @@ test.describe('hall of fame legacy parity', () => { const titleStyle = getComputedStyle(document.querySelector('.rankType')!); const image = document.querySelector('.generalIcon')!; return { - container: container.getBoundingClientRect().width, + container: { + x: container.getBoundingClientRect().x, + width: container.getBoundingClientRect().width, + }, containerBackgroundImage: getComputedStyle(container).backgroundImage, item: { width: item.getBoundingClientRect().width, @@ -427,23 +549,57 @@ test.describe('hall of fame legacy parity', () => { naturalHeight: image.naturalHeight, objectFit: getComputedStyle(image).objectFit, }, + closeX: document + .querySelector('.legacy-hall-title .legacy-button')! + .getBoundingClientRect().x, }; }); - expect(geometry.container).toBe(viewport.expectedWidth); + expect(geometry.container.width).toBe(viewport.expectedWidth); + expect(geometry.closeX).toBe(geometry.container.x); expect(geometry.containerBackgroundImage).toContain('back_walnut.jpg'); expect(geometry.item.width).toBe(100); expect(geometry.title.fontFamily).toContain('Pretendard'); + expect(geometry.title.fontSize).toBe(viewport.name === 'desktop' ? '28px' : '22.06px'); expect(geometry.title.backgroundImage).toContain('back_green.jpg'); - expect(geometry.image).toMatchObject({ width: 64, height: 64, objectFit: 'cover' }); + expect(geometry.image).toMatchObject({ width: 64, height: 64, objectFit: 'fill' }); expect(geometry.image.naturalWidth).toBeGreaterThan(0); const close = page.getByRole('button', { name: '창 닫기' }).first(); await close.hover(); + await expect(close).toHaveCSS('background-color', 'rgb(107, 107, 107)'); await close.focus(); await expect(close).toBeFocused(); + + const scenario = page.getByLabel('시나리오 검색'); + await expect(scenario).toHaveCSS('width', '189px'); + await scenario.focus(); + await expect(scenario).toBeFocused(); + await scenario.selectOption('scenario:1:22'); + await expect(scenario).toHaveValue('scenario:1:22'); }); } + + test('keeps the selected scenario after a hall API error', async ({ page }) => { + await page.goto('http://127.0.0.1:15102/che/hall-of-fame'); + await expect(page.getByText('유비')).toBeVisible(); + await page.route('**/che/api/trpc/**', async (route) => { + if (operationNames(route).includes('ranking.getHallOfFame')) { + await route.fulfill({ + status: 500, + contentType: 'application/json', + body: JSON.stringify({ error: { message: '명예의 전당 조회에 실패했습니다.' } }), + }); + return; + } + await route.fallback(); + }); + const scenario = page.getByLabel('시나리오 검색'); + await scenario.selectOption('scenario:1:22'); + await expect(page.getByRole('alert')).toBeVisible(); + await expect(scenario).toHaveValue('scenario:1:22'); + await expect(page.getByText('유비')).toBeVisible(); + }); }); test('game login delegates to the gateway like the ref entry point', async ({ page }) => { From 48924428eed5c5a32f8a73cae9a510dbab506923 Mon Sep 17 00:00:00 2001 From: hided62 Date: Sun, 26 Jul 2026 05:24:31 +0000 Subject: [PATCH 14/19] Reapply "Exclude concurrent public traffic work" This reverts commit fa87f5565c9160b00aa640f3ff1b9dd5e4d36cab. --- app/game-api/src/router/public/index.ts | 117 ------- app/game-api/test/publicTraffic.test.ts | 115 ------- app/game-frontend/src/router/index.ts | 6 - app/game-frontend/src/views/MainView.vue | 1 - app/game-frontend/src/views/PublicView.vue | 1 - app/game-frontend/src/views/TrafficView.vue | 343 -------------------- 6 files changed, 583 deletions(-) delete mode 100644 app/game-api/test/publicTraffic.test.ts delete mode 100644 app/game-frontend/src/views/TrafficView.vue diff --git a/app/game-api/src/router/public/index.ts b/app/game-api/src/router/public/index.ts index f4ef4d8..e7a0f59 100644 --- a/app/game-api/src/router/public/index.ts +++ b/app/game-api/src/router/public/index.ts @@ -42,14 +42,6 @@ type NationCountRow = { type NpcListSort = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8; -type TrafficHistoryItem = { - year: number; - month: number; - refresh: number; - online: number; - date: string; -}; - const PUBLIC_CACHE_TTL_SECONDS = 600; const buildPublicCacheKey = (ctx: GameApiContext, key: string): string => @@ -171,26 +163,6 @@ const readFiniteMetaNumber = (meta: Record, key: string): numbe return typeof value === 'number' && Number.isFinite(value) ? value : 0; }; -const parseTrafficHistory = (value: unknown): TrafficHistoryItem[] => { - if (!Array.isArray(value)) { - return []; - } - - const result: TrafficHistoryItem[] = []; - for (const item of value) { - const row = asRecord(item); - const year = readFiniteMetaNumber(row, 'year'); - const month = readFiniteMetaNumber(row, 'month'); - const refresh = readFiniteMetaNumber(row, 'refresh'); - const online = readFiniteMetaNumber(row, 'online'); - const date = typeof row.date === 'string' ? row.date : ''; - if (year > 0 && month > 0 && date) { - result.push({ year, month, refresh, online, date }); - } - } - return result; -}; - const compareString = (left: string, right: string): number => { if (left === right) { return 0; @@ -250,95 +222,6 @@ export const publicRouter = router({ getNationList: procedure.query(async ({ ctx }) => { return loadCachedNationList(ctx); }), - getTraffic: procedure.query(async ({ ctx }) => { - const worldState = await ctx.db.worldState.findFirst(); - if (!worldState) { - throw new TRPCError({ - code: 'PRECONDITION_FAILED', - message: 'World state is not initialized.', - }); - } - - const meta = asRecord(worldState.meta); - const rawOnlineSince = meta.lastTurnTime ?? meta.turntime; - const parsedOnlineSince = - typeof rawOnlineSince === 'string' || rawOnlineSince instanceof Date - ? new Date(rawOnlineSince) - : null; - const onlineSince = - parsedOnlineSince && Number.isFinite(parsedOnlineSince.getTime()) - ? parsedOnlineSince - : new Date(Date.now() - worldState.tickSeconds * 1_000); - const [accessTotal, currentOnline, topAccess] = await Promise.all([ - ctx.db.generalAccessLog.aggregate({ - _sum: { - refresh: true, - refreshScoreTotal: true, - }, - }), - ctx.db.generalAccessLog.count({ - where: { - lastRefresh: { - gte: onlineSince, - }, - }, - }), - ctx.db.generalAccessLog.findMany({ - orderBy: [{ refresh: 'desc' }, { generalId: 'asc' }], - take: 5, - select: { - generalId: true, - refresh: true, - refreshScoreTotal: true, - }, - }), - ]); - - const generalIds = topAccess.map((entry) => entry.generalId); - const generalRows = - generalIds.length > 0 - ? await ctx.db.general.findMany({ - where: { id: { in: generalIds } }, - select: { id: true, name: true }, - }) - : []; - const generalName = new Map(generalRows.map((general) => [general.id, general.name])); - const totalRefresh = accessTotal._sum.refresh ?? 0; - const totalRefreshScore = accessTotal._sum.refreshScoreTotal ?? 0; - const currentRefresh = Math.max(readFiniteMetaNumber(meta, 'refresh'), totalRefresh); - const history = parseTrafficHistory(meta.recentTraffic); - history.push({ - year: worldState.currentYear, - month: worldState.currentMonth, - refresh: currentRefresh, - online: currentOnline, - date: new Date().toISOString(), - }); - - return { - history, - maxRefresh: Math.max( - 1, - readFiniteMetaNumber(meta, 'maxrefresh'), - ...history.map((entry) => entry.refresh) - ), - maxOnline: Math.max(1, readFiniteMetaNumber(meta, 'maxonline'), ...history.map((entry) => entry.online)), - suspects: [ - { - generalId: null, - name: '접속자 총합', - refresh: totalRefresh, - refreshScoreTotal: totalRefreshScore, - }, - ...topAccess.map((entry) => ({ - generalId: entry.generalId, - name: generalName.get(entry.generalId) ?? `장수 ${entry.generalId}`, - refresh: entry.refresh, - refreshScoreTotal: entry.refreshScoreTotal, - })), - ], - }; - }), getGeneralList: procedure.query(async ({ ctx }) => { const [generals, nations] = await Promise.all([ ctx.db.general.findMany({ diff --git a/app/game-api/test/publicTraffic.test.ts b/app/game-api/test/publicTraffic.test.ts deleted file mode 100644 index 78008d8..0000000 --- a/app/game-api/test/publicTraffic.test.ts +++ /dev/null @@ -1,115 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import type { RedisConnector } from '@sammo-ts/infra'; - -import { RedisAccessTokenStore } from '../src/auth/accessTokenStore.js'; -import { InMemoryFlushStore } from '../src/auth/flushStore.js'; -import { InMemoryBattleSimTransport } from '../src/battleSim/inMemoryTransport.js'; -import type { DatabaseClient, GameApiContext, GameProfile } from '../src/context.js'; -import { InMemoryTurnDaemonTransport } from '../src/daemon/inMemoryTransport.js'; -import { appRouter } from '../src/router.js'; - -const profile: GameProfile = { - id: 'che', - scenario: 'default', - name: 'che:default', -}; - -const buildContext = (): GameApiContext => { - const db = { - worldState: { - findFirst: async () => ({ - id: 1, - currentYear: 185, - currentMonth: 3, - tickSeconds: 600, - config: {}, - meta: { - lastTurnTime: '2026-07-26T03:00:00.000Z', - refresh: 12, - maxrefresh: 30, - maxonline: 5, - recentTraffic: [ - { - year: 185, - month: 2, - refresh: 30, - online: 5, - date: '2026-07-26 02:50:00', - }, - ], - }, - }), - }, - generalAccessLog: { - aggregate: async () => ({ - _sum: { - refresh: 12, - refreshScoreTotal: 21, - }, - }), - count: async (args: { where: { lastRefresh: { gte: Date } } }) => { - expect(args.where.lastRefresh.gte).toEqual(new Date('2026-07-26T03:00:00.000Z')); - return 2; - }, - findMany: async () => [ - { generalId: 7, refresh: 9, refreshScoreTotal: 15 }, - { generalId: 8, refresh: 3, refreshScoreTotal: 6 }, - ], - }, - general: { - findMany: async () => [ - { id: 7, name: '갑' }, - { id: 8, name: '을' }, - ], - }, - }; - const redis = { - get: async () => null, - set: async () => null, - } as unknown as RedisConnector['client']; - - return { - db: db as unknown as DatabaseClient, - turnDaemon: new InMemoryTurnDaemonTransport(), - battleSim: new InMemoryBattleSimTransport(), - profile, - auth: null, - uploadDir: 'uploads', - uploadPath: '/uploads', - uploadPublicUrl: null, - redis, - accessTokenStore: new RedisAccessTokenStore(redis, profile.name), - flushStore: new InMemoryFlushStore(), - gameTokenSecret: 'test-secret', - }; -}; - -describe('public.getTraffic', () => { - it('is public and returns only aggregate traffic plus allowlisted general names', async () => { - const result = await appRouter.createCaller(buildContext()).public.getTraffic(); - - expect(result.history).toHaveLength(2); - expect(result.history[0]).toEqual({ - year: 185, - month: 2, - refresh: 30, - online: 5, - date: '2026-07-26 02:50:00', - }); - expect(result.history[1]).toMatchObject({ - year: 185, - month: 3, - refresh: 12, - online: 2, - }); - expect(result.maxRefresh).toBe(30); - expect(result.maxOnline).toBe(5); - expect(result.suspects).toEqual([ - { generalId: null, name: '접속자 총합', refresh: 12, refreshScoreTotal: 21 }, - { generalId: 7, name: '갑', refresh: 9, refreshScoreTotal: 15 }, - { generalId: 8, name: '을', refresh: 3, refreshScoreTotal: 6 }, - ]); - expect(JSON.stringify(result)).not.toContain('userId'); - }); -}); diff --git a/app/game-frontend/src/router/index.ts b/app/game-frontend/src/router/index.ts index 63d0a16..ac0ccb2 100644 --- a/app/game-frontend/src/router/index.ts +++ b/app/game-frontend/src/router/index.ts @@ -32,7 +32,6 @@ import TroopView from '../views/TroopView.vue'; import YearbookView from '../views/YearbookView.vue'; import NationBettingView from '../views/NationBettingView.vue'; import NpcListView from '../views/NpcListView.vue'; -import TrafficView from '../views/TrafficView.vue'; import { useSessionStore } from '../stores/session'; const routes = [ @@ -258,11 +257,6 @@ const routes = [ requiresGeneral: true, }, }, - { - path: '/traffic', - name: 'traffic', - component: TrafficView, - }, { path: '/npc-list', name: 'npc-list', diff --git a/app/game-frontend/src/views/MainView.vue b/app/game-frontend/src/views/MainView.vue index 8459ebc..857e327 100644 --- a/app/game-frontend/src/views/MainView.vue +++ b/app/game-frontend/src/views/MainView.vue @@ -119,7 +119,6 @@ watch( 왕조일람 연감 천통국 베팅 - 접속량정보 빙의일람 게시판 전투 시뮬레이터 diff --git a/app/game-frontend/src/views/PublicView.vue b/app/game-frontend/src/views/PublicView.vue index 1624fcf..6c3684a 100644 --- a/app/game-frontend/src/views/PublicView.vue +++ b/app/game-frontend/src/views/PublicView.vue @@ -108,7 +108,6 @@ onMounted(() => { 장수 생성/빙의 메인으로 빙의일람 - 접속량정보 diff --git a/app/game-frontend/src/views/TrafficView.vue b/app/game-frontend/src/views/TrafficView.vue deleted file mode 100644 index 58b5f30..0000000 --- a/app/game-frontend/src/views/TrafficView.vue +++ /dev/null @@ -1,343 +0,0 @@ - - - - - From d15ccb478f0208ca1b70cd91902a424e10e6424c Mon Sep 17 00:00:00 2001 From: hided62 Date: Sun, 26 Jul 2026 05:25:54 +0000 Subject: [PATCH 15/19] Revert "Reapply "Exclude concurrent public traffic work"" This reverts commit 48924428eed5c5a32f8a73cae9a510dbab506923. --- app/game-api/src/router/public/index.ts | 117 +++++++ app/game-api/test/publicTraffic.test.ts | 115 +++++++ app/game-frontend/src/router/index.ts | 6 + app/game-frontend/src/views/MainView.vue | 1 + app/game-frontend/src/views/PublicView.vue | 1 + app/game-frontend/src/views/TrafficView.vue | 343 ++++++++++++++++++++ 6 files changed, 583 insertions(+) create mode 100644 app/game-api/test/publicTraffic.test.ts create mode 100644 app/game-frontend/src/views/TrafficView.vue diff --git a/app/game-api/src/router/public/index.ts b/app/game-api/src/router/public/index.ts index e7a0f59..f4ef4d8 100644 --- a/app/game-api/src/router/public/index.ts +++ b/app/game-api/src/router/public/index.ts @@ -42,6 +42,14 @@ type NationCountRow = { type NpcListSort = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8; +type TrafficHistoryItem = { + year: number; + month: number; + refresh: number; + online: number; + date: string; +}; + const PUBLIC_CACHE_TTL_SECONDS = 600; const buildPublicCacheKey = (ctx: GameApiContext, key: string): string => @@ -163,6 +171,26 @@ const readFiniteMetaNumber = (meta: Record, key: string): numbe return typeof value === 'number' && Number.isFinite(value) ? value : 0; }; +const parseTrafficHistory = (value: unknown): TrafficHistoryItem[] => { + if (!Array.isArray(value)) { + return []; + } + + const result: TrafficHistoryItem[] = []; + for (const item of value) { + const row = asRecord(item); + const year = readFiniteMetaNumber(row, 'year'); + const month = readFiniteMetaNumber(row, 'month'); + const refresh = readFiniteMetaNumber(row, 'refresh'); + const online = readFiniteMetaNumber(row, 'online'); + const date = typeof row.date === 'string' ? row.date : ''; + if (year > 0 && month > 0 && date) { + result.push({ year, month, refresh, online, date }); + } + } + return result; +}; + const compareString = (left: string, right: string): number => { if (left === right) { return 0; @@ -222,6 +250,95 @@ export const publicRouter = router({ getNationList: procedure.query(async ({ ctx }) => { return loadCachedNationList(ctx); }), + getTraffic: procedure.query(async ({ ctx }) => { + const worldState = await ctx.db.worldState.findFirst(); + if (!worldState) { + throw new TRPCError({ + code: 'PRECONDITION_FAILED', + message: 'World state is not initialized.', + }); + } + + const meta = asRecord(worldState.meta); + const rawOnlineSince = meta.lastTurnTime ?? meta.turntime; + const parsedOnlineSince = + typeof rawOnlineSince === 'string' || rawOnlineSince instanceof Date + ? new Date(rawOnlineSince) + : null; + const onlineSince = + parsedOnlineSince && Number.isFinite(parsedOnlineSince.getTime()) + ? parsedOnlineSince + : new Date(Date.now() - worldState.tickSeconds * 1_000); + const [accessTotal, currentOnline, topAccess] = await Promise.all([ + ctx.db.generalAccessLog.aggregate({ + _sum: { + refresh: true, + refreshScoreTotal: true, + }, + }), + ctx.db.generalAccessLog.count({ + where: { + lastRefresh: { + gte: onlineSince, + }, + }, + }), + ctx.db.generalAccessLog.findMany({ + orderBy: [{ refresh: 'desc' }, { generalId: 'asc' }], + take: 5, + select: { + generalId: true, + refresh: true, + refreshScoreTotal: true, + }, + }), + ]); + + const generalIds = topAccess.map((entry) => entry.generalId); + const generalRows = + generalIds.length > 0 + ? await ctx.db.general.findMany({ + where: { id: { in: generalIds } }, + select: { id: true, name: true }, + }) + : []; + const generalName = new Map(generalRows.map((general) => [general.id, general.name])); + const totalRefresh = accessTotal._sum.refresh ?? 0; + const totalRefreshScore = accessTotal._sum.refreshScoreTotal ?? 0; + const currentRefresh = Math.max(readFiniteMetaNumber(meta, 'refresh'), totalRefresh); + const history = parseTrafficHistory(meta.recentTraffic); + history.push({ + year: worldState.currentYear, + month: worldState.currentMonth, + refresh: currentRefresh, + online: currentOnline, + date: new Date().toISOString(), + }); + + return { + history, + maxRefresh: Math.max( + 1, + readFiniteMetaNumber(meta, 'maxrefresh'), + ...history.map((entry) => entry.refresh) + ), + maxOnline: Math.max(1, readFiniteMetaNumber(meta, 'maxonline'), ...history.map((entry) => entry.online)), + suspects: [ + { + generalId: null, + name: '접속자 총합', + refresh: totalRefresh, + refreshScoreTotal: totalRefreshScore, + }, + ...topAccess.map((entry) => ({ + generalId: entry.generalId, + name: generalName.get(entry.generalId) ?? `장수 ${entry.generalId}`, + refresh: entry.refresh, + refreshScoreTotal: entry.refreshScoreTotal, + })), + ], + }; + }), getGeneralList: procedure.query(async ({ ctx }) => { const [generals, nations] = await Promise.all([ ctx.db.general.findMany({ diff --git a/app/game-api/test/publicTraffic.test.ts b/app/game-api/test/publicTraffic.test.ts new file mode 100644 index 0000000..78008d8 --- /dev/null +++ b/app/game-api/test/publicTraffic.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, it } from 'vitest'; + +import type { RedisConnector } from '@sammo-ts/infra'; + +import { RedisAccessTokenStore } from '../src/auth/accessTokenStore.js'; +import { InMemoryFlushStore } from '../src/auth/flushStore.js'; +import { InMemoryBattleSimTransport } from '../src/battleSim/inMemoryTransport.js'; +import type { DatabaseClient, GameApiContext, GameProfile } from '../src/context.js'; +import { InMemoryTurnDaemonTransport } from '../src/daemon/inMemoryTransport.js'; +import { appRouter } from '../src/router.js'; + +const profile: GameProfile = { + id: 'che', + scenario: 'default', + name: 'che:default', +}; + +const buildContext = (): GameApiContext => { + const db = { + worldState: { + findFirst: async () => ({ + id: 1, + currentYear: 185, + currentMonth: 3, + tickSeconds: 600, + config: {}, + meta: { + lastTurnTime: '2026-07-26T03:00:00.000Z', + refresh: 12, + maxrefresh: 30, + maxonline: 5, + recentTraffic: [ + { + year: 185, + month: 2, + refresh: 30, + online: 5, + date: '2026-07-26 02:50:00', + }, + ], + }, + }), + }, + generalAccessLog: { + aggregate: async () => ({ + _sum: { + refresh: 12, + refreshScoreTotal: 21, + }, + }), + count: async (args: { where: { lastRefresh: { gte: Date } } }) => { + expect(args.where.lastRefresh.gte).toEqual(new Date('2026-07-26T03:00:00.000Z')); + return 2; + }, + findMany: async () => [ + { generalId: 7, refresh: 9, refreshScoreTotal: 15 }, + { generalId: 8, refresh: 3, refreshScoreTotal: 6 }, + ], + }, + general: { + findMany: async () => [ + { id: 7, name: '갑' }, + { id: 8, name: '을' }, + ], + }, + }; + const redis = { + get: async () => null, + set: async () => null, + } as unknown as RedisConnector['client']; + + return { + db: db as unknown as DatabaseClient, + turnDaemon: new InMemoryTurnDaemonTransport(), + battleSim: new InMemoryBattleSimTransport(), + profile, + auth: null, + uploadDir: 'uploads', + uploadPath: '/uploads', + uploadPublicUrl: null, + redis, + accessTokenStore: new RedisAccessTokenStore(redis, profile.name), + flushStore: new InMemoryFlushStore(), + gameTokenSecret: 'test-secret', + }; +}; + +describe('public.getTraffic', () => { + it('is public and returns only aggregate traffic plus allowlisted general names', async () => { + const result = await appRouter.createCaller(buildContext()).public.getTraffic(); + + expect(result.history).toHaveLength(2); + expect(result.history[0]).toEqual({ + year: 185, + month: 2, + refresh: 30, + online: 5, + date: '2026-07-26 02:50:00', + }); + expect(result.history[1]).toMatchObject({ + year: 185, + month: 3, + refresh: 12, + online: 2, + }); + expect(result.maxRefresh).toBe(30); + expect(result.maxOnline).toBe(5); + expect(result.suspects).toEqual([ + { generalId: null, name: '접속자 총합', refresh: 12, refreshScoreTotal: 21 }, + { generalId: 7, name: '갑', refresh: 9, refreshScoreTotal: 15 }, + { generalId: 8, name: '을', refresh: 3, refreshScoreTotal: 6 }, + ]); + expect(JSON.stringify(result)).not.toContain('userId'); + }); +}); diff --git a/app/game-frontend/src/router/index.ts b/app/game-frontend/src/router/index.ts index ac0ccb2..63d0a16 100644 --- a/app/game-frontend/src/router/index.ts +++ b/app/game-frontend/src/router/index.ts @@ -32,6 +32,7 @@ import TroopView from '../views/TroopView.vue'; import YearbookView from '../views/YearbookView.vue'; import NationBettingView from '../views/NationBettingView.vue'; import NpcListView from '../views/NpcListView.vue'; +import TrafficView from '../views/TrafficView.vue'; import { useSessionStore } from '../stores/session'; const routes = [ @@ -257,6 +258,11 @@ const routes = [ requiresGeneral: true, }, }, + { + path: '/traffic', + name: 'traffic', + component: TrafficView, + }, { path: '/npc-list', name: 'npc-list', diff --git a/app/game-frontend/src/views/MainView.vue b/app/game-frontend/src/views/MainView.vue index 857e327..8459ebc 100644 --- a/app/game-frontend/src/views/MainView.vue +++ b/app/game-frontend/src/views/MainView.vue @@ -119,6 +119,7 @@ watch( 왕조일람 연감 천통국 베팅 + 접속량정보 빙의일람 게시판 전투 시뮬레이터 diff --git a/app/game-frontend/src/views/PublicView.vue b/app/game-frontend/src/views/PublicView.vue index 6c3684a..1624fcf 100644 --- a/app/game-frontend/src/views/PublicView.vue +++ b/app/game-frontend/src/views/PublicView.vue @@ -108,6 +108,7 @@ onMounted(() => { 장수 생성/빙의 메인으로 빙의일람 + 접속량정보 diff --git a/app/game-frontend/src/views/TrafficView.vue b/app/game-frontend/src/views/TrafficView.vue new file mode 100644 index 0000000..58b5f30 --- /dev/null +++ b/app/game-frontend/src/views/TrafficView.vue @@ -0,0 +1,343 @@ + + + + + From 5fb2c109dd7003dc7811c1e9b952b47b66f79b6b Mon Sep 17 00:00:00 2001 From: hided62 Date: Sun, 26 Jul 2026 05:27:36 +0000 Subject: [PATCH 16/19] Verify traffic geometry in Chromium --- app/game-frontend/e2e/inGameMenus.spec.ts | 61 +++++++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/app/game-frontend/e2e/inGameMenus.spec.ts b/app/game-frontend/e2e/inGameMenus.spec.ts index ca33b24..8d7a3f2 100644 --- a/app/game-frontend/e2e/inGameMenus.spec.ts +++ b/app/game-frontend/e2e/inGameMenus.spec.ts @@ -146,6 +146,19 @@ const install = async (page: Page, state: FixtureState) => { autorun_user: {}, }, }); + if (operation === 'public.getTraffic') + return response({ + history: [ + { year: 185, month: 1, date: '2026-01-01T00:00:00.000Z', refresh: 120, online: 8 }, + { year: 185, month: 2, date: '2026-01-01T00:10:00.000Z', refresh: 240, online: 12 }, + ], + maxRefresh: 240, + maxOnline: 12, + suspects: [ + { generalId: null, name: '합계', refresh: 360, refreshScoreTotal: 36 }, + { generalId: 7, name: '검증장수', refresh: 240, refreshScoreTotal: 24 }, + ], + }); if (operation === 'general.getMyLog') return response({ type: 'generalAction', logs: [{ id: 1, text: '기록' }] }); if (operation === 'general.setMySetting') { @@ -182,6 +195,54 @@ const install = async (page: Page, state: FixtureState) => { }); }; +test('접속량정보 keeps the legacy public 1016px chart geometry', async ({ page }) => { + const state: FixtureState = { permission: 'member', myset: 0, settingMutations: [] }; + await install(page, state); + await page.setViewportSize({ width: 1200, height: 900 }); + await page.goto('traffic'); + await expect(page.locator('.chart-title').first()).toHaveText('접 속 량'); + + const geometry = await page.locator('#traffic-container').evaluate((element) => { + const rect = element.getBoundingClientRect(); + const title = element.querySelector('.title-table')!.getBoundingClientRect(); + const charts = [...element.querySelectorAll('.chart-table')].map((chart) => + chart.getBoundingClientRect() + ); + const row = element.querySelector('.chart-row')!.getBoundingClientRect(); + const bar = element.querySelector('.big-bar')!.getBoundingClientRect(); + const suspect = element.querySelector('.suspect-table')!.getBoundingClientRect(); + return { + width: rect.width, + minWidth: getComputedStyle(element).minWidth, + fontSize: getComputedStyle(element).fontSize, + fontFamily: getComputedStyle(element).fontFamily, + titleWidth: title.width, + chartWidths: charts.map((chart) => chart.width), + chartGap: charts[1]!.x - charts[0]!.right, + rowHeight: row.height, + barHeight: bar.height, + suspectWidth: suspect.width, + }; + }); + expect(geometry.width).toBe(1016); + expect(geometry.minWidth).toBe('1016px'); + expect(geometry.fontSize).toBe('14px'); + expect(geometry.fontFamily).toContain('Pretendard'); + expect(geometry.titleWidth).toBe(1000); + expect(geometry.chartWidths).toEqual([483, 483]); + expect(geometry.chartGap).toBe(26); + expect(geometry.rowHeight).toBe(31); + expect(geometry.barHeight).toBe(30); + expect(geometry.suspectWidth).toBeGreaterThanOrEqual(994); + await persistParityArtifact(page, 'traffic-desktop', geometry); + + await page.setViewportSize({ width: 500, height: 900 }); + const mobileWidth = await page + .locator('#traffic-container') + .evaluate((element) => element.getBoundingClientRect().width); + expect(mobileWidth).toBe(1016); +}); + test('내 정보&설정 keeps the legacy 1000px/500px geometry and saves in place', async ({ page }) => { const state: FixtureState = { permission: 'head', myset: 3, settingMutations: [] }; await install(page, state); From 6ee4c4d7252e85ecba7ba44162c761a869c11623 Mon Sep 17 00:00:00 2001 From: hided62 Date: Sun, 26 Jul 2026 05:28:29 +0000 Subject: [PATCH 17/19] test: resolve parity images from main checkout --- tools/frontend-legacy-parity/visual-parity.spec.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tools/frontend-legacy-parity/visual-parity.spec.ts b/tools/frontend-legacy-parity/visual-parity.spec.ts index 899a70a..1ad90f5 100644 --- a/tools/frontend-legacy-parity/visual-parity.spec.ts +++ b/tools/frontend-legacy-parity/visual-parity.spec.ts @@ -6,7 +6,7 @@ import { fileURLToPath } from 'node:url'; import { canonicalFrontendFixture as fixture } from './fixtures/canonical'; const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../..'); -const imageRoot = resolve(repositoryRoot, '../../image'); +const imageRoots = [resolve(repositoryRoot, '../image'), resolve(repositoryRoot, '../../image')]; const artifactRoot = process.env.FRONTEND_PARITY_ARTIFACT_DIR; const response = (data: unknown) => ({ result: { data } }); @@ -28,11 +28,11 @@ const installImages = async (page: Page): Promise => { await page.route('**/image/**', async (route) => { const pathname = decodeURIComponent(new URL(route.request().url()).pathname); const relative = pathname.replace(/^\/image\//, ''); - const candidates = [ + const candidates = imageRoots.flatMap((imageRoot) => [ resolve(imageRoot, relative), resolve(imageRoot, 'game', relative), resolve(imageRoot, 'icons', '22.jpg'), - ]; + ]); for (const candidate of candidates) { try { const body = await readFile(candidate); From 61b540384cc8336dc30a1e229434d21f87aca24f Mon Sep 17 00:00:00 2001 From: hided62 Date: Sun, 26 Jul 2026 05:30:28 +0000 Subject: [PATCH 18/19] feat(battle-sim): isolate authenticated worker lifecycle --- .../src/battleSim/inMemoryTransport.ts | 14 +- app/game-api/src/battleSim/redisTransport.ts | 39 +-- app/game-api/src/battleSim/transport.ts | 4 +- app/game-api/src/battleSim/types.ts | 1 + app/game-api/src/battleSim/worker.ts | 66 +++-- app/game-api/src/router/battle/index.ts | 18 +- app/game-api/src/trpc.ts | 34 ++- app/game-api/test/battleSimRouter.test.ts | 251 ++++++++++++++++-- app/game-api/test/battleSimTransport.test.ts | 69 +++++ .../test/battleSimWorker.integration.test.ts | 94 +++++++ app/gateway-api/src/adminRouter.ts | 1 + .../src/lobby/profileStatusService.ts | 7 +- .../src/orchestrator/gatewayOrchestrator.ts | 40 ++- .../test/orchestratorOperations.test.ts | 13 +- app/gateway-api/test/orchestratorPlan.test.ts | 11 + .../e2e/server-operations.spec.ts | 1 + app/gateway-frontend/src/views/AdminView.vue | 4 +- .../src/views/ServerOperationsView.vue | 15 +- 18 files changed, 583 insertions(+), 99 deletions(-) create mode 100644 app/game-api/test/battleSimTransport.test.ts create mode 100644 app/game-api/test/battleSimWorker.integration.test.ts diff --git a/app/game-api/src/battleSim/inMemoryTransport.ts b/app/game-api/src/battleSim/inMemoryTransport.ts index 6ad068d..efec98c 100644 --- a/app/game-api/src/battleSim/inMemoryTransport.ts +++ b/app/game-api/src/battleSim/inMemoryTransport.ts @@ -4,16 +4,20 @@ import type { BattleSimJobPayload, BattleSimResultPayload, BattleSimTransportRes import { processBattleSimJob } from './processor.js'; export class InMemoryBattleSimTransport { - private readonly results = new Map(); + private readonly results = new Map(); - public async simulate(payload: BattleSimJobPayload): Promise { + public async simulate(payload: BattleSimJobPayload, requesterUserId: string): Promise { const jobId = crypto.randomUUID(); const result = processBattleSimJob(payload); - this.results.set(jobId, result); + this.results.set(jobId, { requesterUserId, payload: result }); return { status: 'completed', jobId, payload: result }; } - public async getSimulationResult(jobId: string): Promise { - return this.results.get(jobId) ?? null; + public async getSimulationResult(jobId: string, requesterUserId: string): Promise { + const result = this.results.get(jobId); + if (!result || result.requesterUserId !== requesterUserId) { + return null; + } + return result.payload; } } diff --git a/app/game-api/src/battleSim/redisTransport.ts b/app/game-api/src/battleSim/redisTransport.ts index db7436e..295fcd2 100644 --- a/app/game-api/src/battleSim/redisTransport.ts +++ b/app/game-api/src/battleSim/redisTransport.ts @@ -44,16 +44,16 @@ export class RedisBattleSimTransport { this.resultTtlSeconds = options.resultTtlSeconds; } - private buildResultKey(jobId: string): string { - return `${this.keys.resultKeyPrefix}${jobId}`; + private buildResultKey(jobId: string, requesterUserId: string): string { + return `${this.keys.resultKeyPrefix}${encodeURIComponent(requesterUserId)}:${jobId}`; } - private buildNotifyKey(jobId: string): string { - return `${this.keys.notifyKeyPrefix}${jobId}`; + private buildNotifyKey(jobId: string, requesterUserId: string): string { + return `${this.keys.notifyKeyPrefix}${encodeURIComponent(requesterUserId)}:${jobId}`; } - private async readResult(jobId: string): Promise { - const raw = await this.client.get(this.buildResultKey(jobId)); + private async readResult(jobId: string, requesterUserId: string): Promise { + const raw = await this.client.get(this.buildResultKey(jobId, requesterUserId)); if (!raw) { return null; } @@ -64,44 +64,49 @@ export class RedisBattleSimTransport { } } - private async waitForResult(jobId: string, timeoutMs: number): Promise { - const existing = await this.readResult(jobId); + private async waitForResult( + jobId: string, + requesterUserId: string, + timeoutMs: number + ): Promise { + const existing = await this.readResult(jobId, requesterUserId); if (existing) { return existing; } - const notifyKey = this.buildNotifyKey(jobId); + const notifyKey = this.buildNotifyKey(jobId, requesterUserId); const timeoutSec = toTimeoutSeconds(timeoutMs); const signal = await this.client.blPop(notifyKey, timeoutSec); if (!parseBlPopValue(signal)) { return null; } - return this.readResult(jobId); + return this.readResult(jobId, requesterUserId); } - public async simulate(payload: BattleSimJobPayload): Promise { + public async simulate(payload: BattleSimJobPayload, requesterUserId: string): Promise { const jobId = crypto.randomUUID(); const job = { jobId, + requesterUserId, requestedAt: new Date().toISOString(), payload, }; await this.client.rPush(this.keys.queueKey, JSON.stringify(job)); - const result = await this.waitForResult(jobId, this.requestTimeoutMs); + const result = await this.waitForResult(jobId, requesterUserId, this.requestTimeoutMs); if (result) { return { status: 'completed', jobId, payload: result }; } return { status: 'queued', jobId }; } - public async getSimulationResult(jobId: string): Promise { - return this.readResult(jobId); + public async getSimulationResult(jobId: string, requesterUserId: string): Promise { + return this.readResult(jobId, requesterUserId); } - public async pushResult(jobId: string, payload: BattleSimResultPayload): Promise { - const resultKey = this.buildResultKey(jobId); - const notifyKey = this.buildNotifyKey(jobId); + public async pushResult(jobId: string, requesterUserId: string, payload: BattleSimResultPayload): Promise { + const resultKey = this.buildResultKey(jobId, requesterUserId); + const notifyKey = this.buildNotifyKey(jobId, requesterUserId); await this.client.set(resultKey, JSON.stringify(payload), { EX: this.resultTtlSeconds, }); diff --git a/app/game-api/src/battleSim/transport.ts b/app/game-api/src/battleSim/transport.ts index cd6592a..0932b81 100644 --- a/app/game-api/src/battleSim/transport.ts +++ b/app/game-api/src/battleSim/transport.ts @@ -1,6 +1,6 @@ import type { BattleSimJobPayload, BattleSimResultPayload, BattleSimTransportResponse } from './types.js'; export interface BattleSimTransport { - simulate(payload: BattleSimJobPayload): Promise; - getSimulationResult(jobId: string): Promise; + simulate(payload: BattleSimJobPayload, requesterUserId: string): Promise; + getSimulationResult(jobId: string, requesterUserId: string): Promise; } diff --git a/app/game-api/src/battleSim/types.ts b/app/game-api/src/battleSim/types.ts index 6850b20..41d522b 100644 --- a/app/game-api/src/battleSim/types.ts +++ b/app/game-api/src/battleSim/types.ts @@ -134,6 +134,7 @@ export interface BattleSimResultPayload { export interface BattleSimJob { jobId: string; + requesterUserId: string; requestedAt: string; payload: BattleSimJobPayload; } diff --git a/app/game-api/src/battleSim/worker.ts b/app/game-api/src/battleSim/worker.ts index 6829051..6c5a2ba 100644 --- a/app/game-api/src/battleSim/worker.ts +++ b/app/game-api/src/battleSim/worker.ts @@ -18,7 +18,11 @@ const parseBlPopValue = (result: RedisBlPopResult): string | null => { return result.element ?? null; }; -export const runBattleSimWorker = async (): Promise => { +export interface BattleSimWorkerOptions { + signal?: AbortSignal; +} + +export const runBattleSimWorker = async (options: BattleSimWorkerOptions = {}): Promise => { const config = resolveGameApiConfigFromEnv(); const redis = createRedisConnector(resolveRedisConfigFromEnv()); await redis.connect(); @@ -30,35 +34,49 @@ export const runBattleSimWorker = async (): Promise => { resultTtlSeconds: config.battleSimResultTtlSeconds, }); - const handleExit = async () => { - await redis.disconnect(); + let stopped = options.signal?.aborted ?? false; + const handleExit = () => { + stopped = true; + }; + const handleAbort = () => { + stopped = true; }; process.on('SIGINT', handleExit); process.on('SIGTERM', handleExit); + options.signal?.addEventListener('abort', handleAbort, { once: true }); - while (true) { - const item = await redis.client.blPop(keys.queueKey, 0); - const raw = parseBlPopValue(item); - if (!raw) { - continue; - } + try { + while (!stopped) { + // A finite block lets SIGTERM and test AbortSignal stop the worker without + // leaving a Redis operation or a detached lifecycle process behind. + const item = await redis.client.blPop(keys.queueKey, 1); + const raw = parseBlPopValue(item); + if (!raw) { + continue; + } - let job: BattleSimJob | null = null; - try { - job = JSON.parse(raw) as BattleSimJob; - } catch { - continue; - } + let job: BattleSimJob | null = null; + try { + job = JSON.parse(raw) as BattleSimJob; + } catch { + continue; + } - try { - const result = processBattleSimJob(job.payload); - await transport.pushResult(job.jobId, result); - } catch (error) { - const reason = error instanceof Error ? error.message : '전투 시뮬레이션 오류'; - await transport.pushResult(job.jobId, { - result: false, - reason, - }); + try { + const result = processBattleSimJob(job.payload); + await transport.pushResult(job.jobId, job.requesterUserId, result); + } catch (error) { + const reason = error instanceof Error ? error.message : '전투 시뮬레이션 오류'; + await transport.pushResult(job.jobId, job.requesterUserId, { + result: false, + reason, + }); + } } + } finally { + process.off('SIGINT', handleExit); + process.off('SIGTERM', handleExit); + options.signal?.removeEventListener('abort', handleAbort); + await redis.disconnect(); } }; diff --git a/app/game-api/src/router/battle/index.ts b/app/game-api/src/router/battle/index.ts index 172e595..f77da37 100644 --- a/app/game-api/src/router/battle/index.ts +++ b/app/game-api/src/router/battle/index.ts @@ -4,7 +4,7 @@ import { z } from 'zod'; import { asRecord } from '@sammo-ts/common'; import { getDexLevel } from '@sammo-ts/logic'; -import { authedProcedure, procedure, router } from '../../trpc.js'; +import { authedProcedure, readOnlyAuthedProcedure, router } from '../../trpc.js'; import { buildBattleSimEnvironment, buildBattleSimJobPayload } from '../../battleSim/environment.js'; import { zBattleSimJobId, zBattleSimRequest } from '../../battleSim/schema.js'; import { @@ -30,6 +30,14 @@ const normalizeOptionalKey = (value: string | null): string | null => { return value; }; +const getAuthenticatedUserId = (auth: { user: { id: string } } | null): string => { + const userId = auth?.user.id; + if (!userId) { + throw new TRPCError({ code: 'UNAUTHORIZED', message: 'Unauthorized' }); + } + return userId; +}; + const resolveExpLevel = (meta: Record, experience: number): number => { const expLevel = meta.explevel ?? meta.expLevel; if (typeof expLevel === 'number' && Number.isFinite(expLevel)) { @@ -48,7 +56,7 @@ const resolveDexValue = (meta: Record, key: string): number => }; export const battleRouter = router({ - simulate: procedure.input(zBattleSimRequest).mutation(async ({ ctx, input }) => { + simulate: readOnlyAuthedProcedure.input(zBattleSimRequest).mutation(async ({ ctx, input }) => { const worldState = await ctx.db.worldState.findFirst(); if (!worldState) { throw new TRPCError({ @@ -58,10 +66,10 @@ export const battleRouter = router({ } const payload = await buildBattleSimJobPayload(worldState, input, ctx.profile.id); - return ctx.battleSim.simulate(payload); + return ctx.battleSim.simulate(payload, getAuthenticatedUserId(ctx.auth)); }), - getSimulation: procedure.input(zBattleSimJobId).query(async ({ ctx, input }) => { - const result = await ctx.battleSim.getSimulationResult(input.jobId); + getSimulation: readOnlyAuthedProcedure.input(zBattleSimJobId).query(async ({ ctx, input }) => { + const result = await ctx.battleSim.getSimulationResult(input.jobId, getAuthenticatedUserId(ctx.auth)); if (!result) { return { status: 'queued', jobId: input.jobId }; } diff --git a/app/game-api/src/trpc.ts b/app/game-api/src/trpc.ts index a8ce3e9..4a3e8dc 100644 --- a/app/game-api/src/trpc.ts +++ b/app/game-api/src/trpc.ts @@ -7,6 +7,21 @@ import { DuplicateInputEventError, executeInputEvent } from './inputEventBoundar const t = initTRPC.context().create(); +const requireAuthMiddleware = t.middleware(({ ctx, next }) => { + if (!ctx.auth) { + throw new TRPCError({ + code: 'UNAUTHORIZED', + message: 'Unauthorized', + }); + } + return next({ + ctx: { + ...ctx, + auth: ctx.auth, + }, + }); +}); + const inputEventMiddleware = t.middleware(async ({ ctx, type, path, next }) => { if (type !== 'mutation' || !ctx.db.$transaction) { return next(); @@ -46,17 +61,8 @@ const inputEventMiddleware = t.middleware(async ({ ctx, type, path, next }) => { export const router = t.router; export const procedure = t.procedure.use(inputEventMiddleware); -export const authedProcedure: typeof procedure = procedure.use(({ ctx, next }) => { - if (!ctx.auth) { - throw new TRPCError({ - code: 'UNAUTHORIZED', - message: 'Unauthorized', - }); - } - return next({ - ctx: { - ...ctx, - auth: ctx.auth, - }, - }); -}); +export const authedProcedure: typeof procedure = procedure.use(requireAuthMiddleware); + +// 시뮬레이터처럼 게임 상태를 변경하지 않는 계산은 input-event transaction과 +// 이벤트 원장을 만들지 않는다. 인증은 유지하되 lifecycle DB 경계 밖에서 실행한다. +export const readOnlyAuthedProcedure: typeof procedure = t.procedure.use(requireAuthMiddleware); diff --git a/app/game-api/test/battleSimRouter.test.ts b/app/game-api/test/battleSimRouter.test.ts index d0d0f49..3d7192d 100644 --- a/app/game-api/test/battleSimRouter.test.ts +++ b/app/game-api/test/battleSimRouter.test.ts @@ -19,19 +19,30 @@ const profile: GameProfile = { class QueuedBattleSimTransport implements BattleSimTransport { public simulateCalls = 0; public lastPayload: BattleSimJobPayload | null = null; + public lastRequesterUserId: string | null = null; + private readonly owners = new Map(); private readonly results = new Map(); - async simulate(payload: BattleSimJobPayload) { + async simulate(payload: BattleSimJobPayload, requesterUserId: string) { this.simulateCalls += 1; this.lastPayload = payload; - return { status: 'queued', jobId: 'job-1' } as const; + this.lastRequesterUserId = requesterUserId; + const jobId = `job-${this.simulateCalls}`; + this.owners.set(jobId, requesterUserId); + return { status: 'queued', jobId } as const; } - async getSimulationResult(jobId: string) { + async getSimulationResult(jobId: string, requesterUserId: string) { + if (this.owners.get(jobId) !== requesterUserId) { + return null; + } return this.results.get(jobId) ?? null; } - pushResult(jobId: string, payload: BattleSimResultPayload) { + pushResult(jobId: string, requesterUserId: string, payload: BattleSimResultPayload) { + if (this.owners.get(jobId) !== requesterUserId) { + throw new Error('requester mismatch'); + } this.results.set(jobId, payload); } } @@ -194,8 +205,13 @@ const buildBattleRequest = () => ({ }, }); -const buildContext = (options: { state: WorldStateRow; battleSim: BattleSimTransport }): GameApiContext => { - const db = { +const buildContext = (options: { + state: WorldStateRow; + battleSim: BattleSimTransport; + userId?: string | null; + db?: Partial; +}): GameApiContext => { + const db = options.db ?? { worldState: { findFirst: async () => options.state, }, @@ -207,20 +223,23 @@ const buildContext = (options: { state: WorldStateRow; battleSim: BattleSimTrans }, profile.name ); - const auth: GameSessionTokenPayload = { - version: 1, - profile: profile.name, - issuedAt: new Date('2026-01-01T00:00:00Z').toISOString(), - expiresAt: new Date('2026-01-02T00:00:00Z').toISOString(), - sessionId: 'session-1', - user: { - id: 'user-1', - username: 'tester', - displayName: 'Tester', - roles: [], - }, - sanctions: {}, - }; + const auth: GameSessionTokenPayload | null = + options.userId === null + ? null + : { + version: 1, + profile: profile.name, + issuedAt: new Date('2026-01-01T00:00:00Z').toISOString(), + expiresAt: new Date('2026-01-02T00:00:00Z').toISOString(), + sessionId: 'session-1', + user: { + id: options.userId ?? 'user-1', + username: 'tester', + displayName: 'Tester', + roles: [], + }, + sanctions: {}, + }; return { db: db as unknown as DatabaseClient, turnDaemon: new InMemoryTurnDaemonTransport(), @@ -255,14 +274,204 @@ describe('battle router orchestration', () => { const response = await caller.battle.simulate(buildBattleRequest()); expect(response.status).toBe('queued'); expect(battleSim.simulateCalls).toBe(1); + expect(battleSim.lastRequesterUserId).toBe('user-1'); const queued = await caller.battle.getSimulation({ jobId: response.jobId }); expect(queued.status).toBe('queued'); - battleSim.pushResult(response.jobId, { result: true, reason: 'success', avgWar: 1 }); + battleSim.pushResult(response.jobId, 'user-1', { result: true, reason: 'success', avgWar: 1 }); const completed = await caller.battle.getSimulation({ jobId: response.jobId }); expect(completed.status).toBe('completed'); expect(completed.payload?.result).toBe(true); }); + + it('requires login, allows a user without a general, and does not open an input-event transaction', async () => { + const battleSim = new QueuedBattleSimTransport(); + const state: WorldStateRow = { + id: 1, + scenarioCode: 'default', + currentYear: 200, + currentMonth: 1, + tickSeconds: 600, + config: {}, + meta: {}, + updatedAt: new Date('2026-01-01T00:00:00Z'), + }; + let transactionCalls = 0; + const db = { + worldState: { findFirst: async () => state }, + $transaction: async () => { + transactionCalls += 1; + throw new Error('simulation must not create an input event transaction'); + }, + } as unknown as DatabaseClient; + + const anonymous = appRouter.createCaller(buildContext({ state, battleSim, userId: null, db })); + await expect(anonymous.battle.simulate(buildBattleRequest())).rejects.toMatchObject({ + code: 'UNAUTHORIZED', + }); + + const noGeneralUser = appRouter.createCaller( + buildContext({ state, battleSim, userId: 'user-without-general', db }) + ); + await expect(noGeneralUser.battle.simulate(buildBattleRequest())).resolves.toMatchObject({ + status: 'queued', + }); + expect(transactionCalls).toBe(0); + expect(battleSim.lastRequesterUserId).toBe('user-without-general'); + }); + + it('does not expose queued results across authenticated users', async () => { + const battleSim = new QueuedBattleSimTransport(); + const state: WorldStateRow = { + id: 1, + scenarioCode: 'default', + currentYear: 200, + currentMonth: 1, + tickSeconds: 600, + config: {}, + meta: {}, + updatedAt: new Date('2026-01-01T00:00:00Z'), + }; + const owner = appRouter.createCaller(buildContext({ state, battleSim, userId: 'owner-user' })); + const other = appRouter.createCaller(buildContext({ state, battleSim, userId: 'other-user' })); + const response = await owner.battle.simulate(buildBattleRequest()); + battleSim.pushResult(response.jobId, 'owner-user', { result: true, reason: 'success', avgWar: 7 }); + + await expect(owner.battle.getSimulation({ jobId: response.jobId })).resolves.toMatchObject({ + status: 'completed', + payload: { avgWar: 7 }, + }); + await expect(other.battle.getSimulation({ jobId: response.jobId })).resolves.toEqual({ + status: 'queued', + jobId: response.jobId, + }); + }); +}); + +describe('battle simulator general import permissions', () => { + const state: WorldStateRow = { + id: 1, + scenarioCode: 'default', + currentYear: 200, + currentMonth: 1, + tickSeconds: 600, + config: {}, + meta: {}, + updatedAt: new Date('2026-01-01T00:00:00Z'), + }; + + const buildGeneral = (overrides: Record) => ({ + id: 1, + userId: 'same-nation-user', + name: '관전자', + npcState: 0, + nationId: 1, + leadership: 70, + strength: 71, + intel: 72, + officerLevel: 1, + injury: 0, + rice: 9000, + crew: 5000, + crewTypeId: 100, + atmos: 100, + train: 100, + experience: 400, + horseCode: null, + weaponCode: null, + bookCode: null, + itemCode: null, + personalCode: null, + special2Code: null, + meta: {}, + ...overrides, + }); + + const actor = buildGeneral({ id: 1, userId: 'same-nation-user', nationId: 1 }); + const ally = buildGeneral({ + id: 2, + userId: 'ally-user', + name: '아군 장수', + nationId: 1, + officerLevel: 4, + rice: 4321, + crew: 3210, + train: 97, + atmos: 96, + horseCode: 'che_적토마', + weaponCode: 'che_의천검', + bookCode: 'che_손자병법', + itemCode: 'che_옥새', + meta: { + dex1: 10000, + rank_warnum: 33, + rank_killnum: 22, + rank_killcrew: 1111, + }, + }); + const foreignActor = buildGeneral({ id: 3, userId: 'foreign-user', nationId: 2 }); + const generals = [actor, ally, foreignActor]; + const db = { + worldState: { findFirst: async () => state }, + general: { + findFirst: async ({ where }: { where: { userId: string } }) => + generals.find((general) => general.userId === where.userId) ?? null, + findUnique: async ({ where }: { where: { id: number } }) => + generals.find((general) => general.id === where.id) ?? null, + }, + } as unknown as DatabaseClient; + + it('returns full ally details to the same nation but redacts them for another nation', async () => { + const battleSim = new QueuedBattleSimTransport(); + const sameNation = appRouter.createCaller(buildContext({ state, battleSim, userId: 'same-nation-user', db })); + const foreign = appRouter.createCaller(buildContext({ state, battleSim, userId: 'foreign-user', db })); + + const visible = await sameNation.battle.getGeneralDetail({ generalId: ally.id }); + expect(visible.general).toMatchObject({ + name: '아군 장수', + officer_level: 4, + horse: 'che_적토마', + crew: 3210, + rice: 4321, + train: 97, + atmos: 96, + warnum: 33, + killnum: 22, + killcrew: 1111, + }); + + const redacted = await foreign.battle.getGeneralDetail({ generalId: ally.id }); + expect(redacted.general).toMatchObject({ + name: '아군 장수', + officer_level: 1, + horse: null, + weapon: null, + book: null, + item: null, + crew: 0, + rice: 10000, + dex1: 0, + warnum: 0, + killnum: 0, + killcrew: 0, + }); + }); + + it('requires a game general only for server-side general import', async () => { + const caller = appRouter.createCaller( + buildContext({ + state, + battleSim: new QueuedBattleSimTransport(), + userId: 'user-without-general', + db, + }) + ); + + await expect(caller.battle.getGeneralDetail({ generalId: ally.id })).rejects.toMatchObject({ + code: 'NOT_FOUND', + message: 'General not found', + }); + }); }); diff --git a/app/game-api/test/battleSimTransport.test.ts b/app/game-api/test/battleSimTransport.test.ts new file mode 100644 index 0000000..1f0eb54 --- /dev/null +++ b/app/game-api/test/battleSimTransport.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from 'vitest'; + +import { buildBattleSimQueueKeys } from '../src/battleSim/keys.js'; +import { RedisBattleSimTransport } from '../src/battleSim/redisTransport.js'; +import type { BattleSimJob, BattleSimJobPayload } from '../src/battleSim/types.js'; + +class FakeRedisClient { + readonly values = new Map(); + readonly lists = new Map(); + + async rPush(key: string, value: string): Promise { + const list = this.lists.get(key) ?? []; + list.push(value); + this.lists.set(key, list); + return list.length; + } + + async blPop(): Promise { + return null; + } + + async set(key: string, value: string): Promise<'OK'> { + this.values.set(key, value); + return 'OK'; + } + + async get(key: string): Promise { + return this.values.get(key) ?? null; + } + + async expire(): Promise { + return 1; + } +} + +describe('RedisBattleSimTransport requester isolation', () => { + it('records the requester on queued jobs and scopes completed results to that user', async () => { + const client = new FakeRedisClient(); + const keys = buildBattleSimQueueKeys('che:test'); + const transport = new RedisBattleSimTransport(client, { + keys, + requestTimeoutMs: 1, + resultTtlSeconds: 60, + }); + + const response = await transport.simulate({} as BattleSimJobPayload, 'user/one'); + expect(response.status).toBe('queued'); + + const queuedRaw = client.lists.get(keys.queueKey)?.[0]; + expect(queuedRaw).toBeTruthy(); + expect(JSON.parse(queuedRaw ?? '{}') as BattleSimJob).toMatchObject({ + jobId: response.jobId, + requesterUserId: 'user/one', + }); + + await transport.pushResult(response.jobId, 'user/one', { + result: true, + reason: 'success', + avgWar: 3, + }); + + await expect(transport.getSimulationResult(response.jobId, 'user/one')).resolves.toMatchObject({ + result: true, + avgWar: 3, + }); + await expect(transport.getSimulationResult(response.jobId, 'user/two')).resolves.toBeNull(); + expect(Array.from(client.values.keys()).some((key) => key.includes('user%2Fone'))).toBe(true); + }); +}); diff --git a/app/game-api/test/battleSimWorker.integration.test.ts b/app/game-api/test/battleSimWorker.integration.test.ts new file mode 100644 index 0000000..6a8ccf8 --- /dev/null +++ b/app/game-api/test/battleSimWorker.integration.test.ts @@ -0,0 +1,94 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { randomUUID } from 'node:crypto'; + +import { createRedisConnector, resolveRedisConfigFromEnv } from '@sammo-ts/infra'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { buildBattleSimEnvironment } from '../src/battleSim/environment.js'; +import { buildBattleSimQueueKeys } from '../src/battleSim/keys.js'; +import { RedisBattleSimTransport } from '../src/battleSim/redisTransport.js'; +import type { BattleSimRequestPayload } from '../src/battleSim/types.js'; +import { runBattleSimWorker } from '../src/battleSim/worker.js'; +import type { WorldStateRow } from '../src/context.js'; + +const liveDescribe = process.env.REDIS_URL ? describe : describe.skip; + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +liveDescribe('battle simulator worker with live Redis', () => { + it('consumes an isolated queue, produces a result, and stops cleanly', { timeout: 30_000 }, async () => { + const scenario = `battle-sim-e2e-${randomUUID()}`; + const profileName = `che:${scenario}`; + const requesterUserId = 'worker-e2e-user'; + vi.stubEnv('PROFILE', 'che'); + vi.stubEnv('SCENARIO', scenario); + vi.stubEnv('GAME_TOKEN_SECRET', 'battle-sim-test-only'); + + const fixturePath = path.resolve( + process.cwd(), + '../../tools/integration-tests/fixtures/battle/basic-infantry.json' + ); + const fixture = JSON.parse(await fs.readFile(fixturePath, 'utf8')) as BattleSimRequestPayload & { + startYear: number; + }; + const { startYear, ...request } = fixture; + const worldState: WorldStateRow = { + id: 1, + scenarioCode: 'default', + currentYear: request.year, + currentMonth: request.month, + tickSeconds: 600, + config: {}, + meta: { scenarioMeta: { startYear } }, + updatedAt: new Date(), + }; + const environment = await buildBattleSimEnvironment(worldState, 'che'); + const payload = { + ...request, + unitSet: environment.unitSet, + config: environment.config, + time: { year: request.year, month: request.month, startYear }, + }; + + const clientConnector = createRedisConnector(resolveRedisConfigFromEnv()); + await clientConnector.connect(); + const keys = buildBattleSimQueueKeys(profileName); + const transport = new RedisBattleSimTransport(clientConnector.client, { + keys, + requestTimeoutMs: 15_000, + resultTtlSeconds: 60, + }); + const abortController = new AbortController(); + const worker = runBattleSimWorker({ signal: abortController.signal }); + let jobId: string | null = null; + + try { + const result = await transport.simulate(payload, requesterUserId); + jobId = result.jobId; + expect(result.status).toBe('completed'); + if (result.status === 'completed') { + expect(result.payload).toMatchObject({ + result: true, + reason: 'success', + avgWar: 1, + }); + expect(result.payload.phase).toBeGreaterThan(0); + } + } finally { + abortController.abort(); + await worker; + if (jobId) { + const encodedRequester = encodeURIComponent(requesterUserId); + await clientConnector.client.del([ + keys.queueKey, + `${keys.resultKeyPrefix}${encodedRequester}:${jobId}`, + `${keys.notifyKeyPrefix}${encodedRequester}:${jobId}`, + ]); + } + await clientConnector.disconnect(); + } + }); +}); diff --git a/app/gateway-api/src/adminRouter.ts b/app/gateway-api/src/adminRouter.ts index e469052..4552131 100644 --- a/app/gateway-api/src/adminRouter.ts +++ b/app/gateway-api/src/adminRouter.ts @@ -817,6 +817,7 @@ export const adminRouter = router({ profileName: profile.profileName, apiRunning: false, daemonRunning: false, + battleSimRunning: false, tournamentRunning: false, }, })); diff --git a/app/gateway-api/src/lobby/profileStatusService.ts b/app/gateway-api/src/lobby/profileStatusService.ts index b65c2f5..9814831 100644 --- a/app/gateway-api/src/lobby/profileStatusService.ts +++ b/app/gateway-api/src/lobby/profileStatusService.ts @@ -26,6 +26,7 @@ export type LobbyProfileStatus = { runtime: { apiRunning: boolean; daemonRunning: boolean; + battleSimRunning: boolean; tournamentRunning: boolean; }; korName: string; @@ -69,7 +70,10 @@ export class RepositoryProfileStatusService implements GatewayProfileStatusServi private mapProfile( row: GatewayProfileRecord, - runtimeMap: Map + runtimeMap: Map< + string, + { apiRunning: boolean; daemonRunning: boolean; battleSimRunning: boolean; tournamentRunning: boolean } + > ): LobbyProfileStatus { const meta = row.meta; return { @@ -81,6 +85,7 @@ export class RepositoryProfileStatusService implements GatewayProfileStatusServi runtime: runtimeMap.get(row.profileName) ?? { apiRunning: false, daemonRunning: false, + battleSimRunning: false, tournamentRunning: false, }, korName: (meta.korName as string | undefined) ?? row.profile, diff --git a/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts b/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts index 273c083..804e411 100644 --- a/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts +++ b/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts @@ -39,6 +39,7 @@ export interface GatewayOrchestratorOptions { export interface ProfileRuntimeState { apiRunning: boolean; daemonRunning: boolean; + battleSimRunning: boolean; tournamentRunning: boolean; } @@ -66,13 +67,19 @@ export const planProfileReconcile = ( ): { shouldStart: boolean; shouldStop: boolean } => { if (status === 'RUNNING' || status === 'PREOPEN' || status === 'PAUSED' || status === 'COMPLETED') { return { - shouldStart: !(runtime.apiRunning && runtime.daemonRunning && runtime.tournamentRunning), + shouldStart: !( + runtime.apiRunning && + runtime.daemonRunning && + runtime.battleSimRunning && + runtime.tournamentRunning + ), shouldStop: false, }; } return { shouldStart: false, - shouldStop: runtime.apiRunning || runtime.daemonRunning || runtime.tournamentRunning, + shouldStop: + runtime.apiRunning || runtime.daemonRunning || runtime.battleSimRunning || runtime.tournamentRunning, }; }; @@ -273,8 +280,16 @@ const parseInstallOptions = ( }; }; -const buildProcessName = (profileName: string, role: 'api' | 'daemon' | 'tournament'): string => - `sammo:${profileName}:${role === 'api' ? 'game-api' : role === 'daemon' ? 'turn-daemon' : 'tournament-worker'}`; +const buildProcessName = (profileName: string, role: 'api' | 'daemon' | 'battle-sim' | 'tournament'): string => + `sammo:${profileName}:${ + role === 'api' + ? 'game-api' + : role === 'daemon' + ? 'turn-daemon' + : role === 'battle-sim' + ? 'battle-sim-worker' + : 'tournament-worker' + }`; const isMissingProcessError = (error: unknown): boolean => error instanceof Error && /process or namespace not found/i.test(error.message); @@ -285,11 +300,13 @@ export const buildProcessDefinitions = ( ): { api: { name: string; script: string; cwd: string; env: Record }; daemon: { name: string; script: string; cwd: string; env: Record }; + battleSim: { name: string; script: string; cwd: string; env: Record }; tournament: { name: string; script: string; cwd: string; env: Record }; } => { const baseEnv = { ...(config.baseEnv ?? {}) }; const apiName = buildProcessName(profile.profileName, 'api'); const daemonName = buildProcessName(profile.profileName, 'daemon'); + const battleSimName = buildProcessName(profile.profileName, 'battle-sim'); const tournamentName = buildProcessName(profile.profileName, 'tournament'); const runtimeWorkspace = profile.buildWorkspace ?? config.workspaceRoot; const apiCwd = path.join(runtimeWorkspace, 'app', 'game-api'); @@ -327,6 +344,15 @@ export const buildProcessDefinitions = ( cwd: daemonCwd, env: daemonEnv, }, + battleSim: { + name: battleSimName, + script: apiScript, + cwd: apiCwd, + env: { + ...apiEnv, + GAME_API_ROLE: 'battle-sim-worker', + }, + }, tournament: { name: tournamentName, script: apiScript, @@ -376,11 +402,13 @@ const mapRuntimeStates = (profileNames: string[], processNames: Map { const apiName = buildProcessName(profileName, 'api'); const daemonName = buildProcessName(profileName, 'daemon'); + const battleSimName = buildProcessName(profileName, 'battle-sim'); const tournamentName = buildProcessName(profileName, 'tournament'); return { profileName, apiRunning: processNames.get(apiName) ?? false, daemonRunning: processNames.get(daemonName) ?? false, + battleSimRunning: processNames.get(battleSimName) ?? false, tournamentRunning: processNames.get(tournamentName) ?? false, }; }); @@ -981,6 +1009,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle { try { await this.processManager.start(definitions.api); await this.processManager.start(definitions.daemon); + await this.processManager.start(definitions.battleSim); await this.processManager.start(definitions.tournament); await this.repository.updateLastError(profile.profileName, null); return true; @@ -996,10 +1025,11 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle { private async stopProfile(profile: GatewayProfileRecord): Promise { const apiName = buildProcessName(profile.profileName, 'api'); const daemonName = buildProcessName(profile.profileName, 'daemon'); + const battleSimName = buildProcessName(profile.profileName, 'battle-sim'); const tournamentName = buildProcessName(profile.profileName, 'tournament'); const existingNames = new Set((await this.processManager.list()).map((process) => process.name)); const failures: string[] = []; - for (const name of [apiName, daemonName, tournamentName]) { + for (const name of [apiName, daemonName, battleSimName, tournamentName]) { if (!existingNames.has(name)) { continue; } diff --git a/app/gateway-api/test/orchestratorOperations.test.ts b/app/gateway-api/test/orchestratorOperations.test.ts index 7a47e10..787d20a 100644 --- a/app/gateway-api/test/orchestratorOperations.test.ts +++ b/app/gateway-api/test/orchestratorOperations.test.ts @@ -88,6 +88,7 @@ const createHarness = ( ? [ { name: 'sammo:che:2:game-api', status: 'online' }, { name: 'sammo:che:2:turn-daemon', status: 'online' }, + { name: 'sammo:che:2:battle-sim-worker', status: 'online' }, { name: 'sammo:che:2:tournament-worker', status: 'online' }, ] : [], @@ -138,7 +139,7 @@ const createHarness = ( }; describe('GatewayOrchestrator first-class operations', () => { - it('starts both profile processes and records success', async () => { + it('starts every profile process and records success', async () => { const harness = createHarness(buildOperation('START')); await harness.orchestrator.runOperationsNow(); @@ -147,12 +148,13 @@ describe('GatewayOrchestrator first-class operations', () => { expect(harness.started.map((definition) => definition.name)).toEqual([ 'sammo:che:2:game-api', 'sammo:che:2:turn-daemon', + 'sammo:che:2:battle-sim-worker', 'sammo:che:2:tournament-worker', ]); expect(harness.completions).toEqual(['SUCCEEDED']); }); - it('stops both profile processes and records success', async () => { + it('stops every profile process and records success', async () => { const harness = createHarness(buildOperation('STOP')); await harness.orchestrator.runOperationsNow(); @@ -161,11 +163,13 @@ describe('GatewayOrchestrator first-class operations', () => { expect(harness.stopped).toEqual([ 'sammo:che:2:game-api', 'sammo:che:2:turn-daemon', + 'sammo:che:2:battle-sim-worker', 'sammo:che:2:tournament-worker', ]); expect(harness.deleted).toEqual([ 'sammo:che:2:game-api', 'sammo:che:2:turn-daemon', + 'sammo:che:2:battle-sim-worker', 'sammo:che:2:tournament-worker', ]); expect(harness.completions).toEqual(['SUCCEEDED']); @@ -190,6 +194,7 @@ describe('GatewayOrchestrator first-class operations', () => { expect(harness.deleted).toEqual([ 'sammo:che:2:game-api', 'sammo:che:2:turn-daemon', + 'sammo:che:2:battle-sim-worker', 'sammo:che:2:tournament-worker', ]); expect(harness.completions).toEqual(['SUCCEEDED']); @@ -203,7 +208,7 @@ describe('GatewayOrchestrator first-class operations', () => { expect(harness.completions).toEqual(['FAILED']); }); - it('attempts to stop both roles before reporting a partial PM2 failure', async () => { + it('attempts to stop every role before reporting a partial PM2 failure', async () => { const harness = createHarness(buildOperation('STOP'), false, true); await harness.orchestrator.runOperationsNow(); @@ -211,11 +216,13 @@ describe('GatewayOrchestrator first-class operations', () => { expect(harness.stopped).toEqual([ 'sammo:che:2:game-api', 'sammo:che:2:turn-daemon', + 'sammo:che:2:battle-sim-worker', 'sammo:che:2:tournament-worker', ]); expect(harness.deleted).toEqual([ 'sammo:che:2:game-api', 'sammo:che:2:turn-daemon', + 'sammo:che:2:battle-sim-worker', 'sammo:che:2:tournament-worker', ]); expect(harness.completions).toEqual(['FAILED']); diff --git a/app/gateway-api/test/orchestratorPlan.test.ts b/app/gateway-api/test/orchestratorPlan.test.ts index ae92d15..431a840 100644 --- a/app/gateway-api/test/orchestratorPlan.test.ts +++ b/app/gateway-api/test/orchestratorPlan.test.ts @@ -29,6 +29,7 @@ describe('planProfileReconcile', () => { planProfileReconcile('RUNNING', { apiRunning: true, daemonRunning: false, + battleSimRunning: true, tournamentRunning: true, }) ).toEqual({ shouldStart: true, shouldStop: false }); @@ -39,6 +40,7 @@ describe('planProfileReconcile', () => { planProfileReconcile('PREOPEN', { apiRunning: false, daemonRunning: false, + battleSimRunning: false, tournamentRunning: false, }) ).toEqual({ shouldStart: true, shouldStop: false }); @@ -49,6 +51,7 @@ describe('planProfileReconcile', () => { planProfileReconcile('RUNNING', { apiRunning: true, daemonRunning: true, + battleSimRunning: true, tournamentRunning: true, }) ).toEqual({ shouldStart: false, shouldStop: false }); @@ -59,6 +62,7 @@ describe('planProfileReconcile', () => { planProfileReconcile('STOPPED', { apiRunning: false, daemonRunning: true, + battleSimRunning: false, tournamentRunning: false, }) ).toEqual({ shouldStart: false, shouldStop: true }); @@ -69,6 +73,7 @@ describe('planProfileReconcile', () => { planProfileReconcile('RESERVED', { apiRunning: false, daemonRunning: false, + battleSimRunning: false, tournamentRunning: false, }) ).toEqual({ shouldStart: false, shouldStop: false }); @@ -95,6 +100,11 @@ describe('buildProcessDefinitions', () => { }); expect(definitions.daemon.cwd).toBe(path.join(buildWorkspace, 'app', 'game-engine')); expect(definitions.daemon.script).toBe(path.join(buildWorkspace, 'app', 'game-engine', 'dist', 'index.js')); + expect(definitions.battleSim).toMatchObject({ + cwd: path.join(buildWorkspace, 'app', 'game-api'), + script: path.join(buildWorkspace, 'app', 'game-api', 'dist', 'index.js'), + env: { GAME_API_ROLE: 'battle-sim-worker' }, + }); expect(definitions.tournament).toMatchObject({ cwd: path.join(buildWorkspace, 'app', 'game-api'), script: path.join(buildWorkspace, 'app', 'game-api', 'dist', 'index.js'), @@ -107,6 +117,7 @@ describe('buildProcessDefinitions', () => { expect(definitions.api.cwd).toBe(path.join(processConfig.workspaceRoot, 'app', 'game-api')); expect(definitions.daemon.cwd).toBe(path.join(processConfig.workspaceRoot, 'app', 'game-engine')); + expect(definitions.battleSim.cwd).toBe(path.join(processConfig.workspaceRoot, 'app', 'game-api')); expect(definitions.tournament.cwd).toBe(path.join(processConfig.workspaceRoot, 'app', 'game-api')); }); }); diff --git a/app/gateway-frontend/e2e/server-operations.spec.ts b/app/gateway-frontend/e2e/server-operations.spec.ts index ba61af7..65d02c9 100644 --- a/app/gateway-frontend/e2e/server-operations.spec.ts +++ b/app/gateway-frontend/e2e/server-operations.spec.ts @@ -38,6 +38,7 @@ const profile = (runtimeRunning: boolean) => ({ profileName: 'che:2', apiRunning: runtimeRunning, daemonRunning: runtimeRunning, + battleSimRunning: runtimeRunning, tournamentRunning: runtimeRunning, }, }); diff --git a/app/gateway-frontend/src/views/AdminView.vue b/app/gateway-frontend/src/views/AdminView.vue index 565317c..b2c9fa6 100644 --- a/app/gateway-frontend/src/views/AdminView.vue +++ b/app/gateway-frontend/src/views/AdminView.vue @@ -70,6 +70,7 @@ type AdminProfile = { runtime: { apiRunning: boolean; daemonRunning: boolean; + battleSimRunning: boolean; tournamentRunning: boolean; }; buildCommitSha?: string; @@ -1352,7 +1353,8 @@ onMounted(() => {
상태: {{ profile.status }} / API: {{ profile.runtime.apiRunning ? 'ON' : 'OFF' }} / - DAEMON: {{ profile.runtime.daemonRunning ? 'ON' : 'OFF' }} / TOURNAMENT: + DAEMON: {{ profile.runtime.daemonRunning ? 'ON' : 'OFF' }} / BATTLE SIM: + {{ profile.runtime.battleSimRunning ? 'ON' : 'OFF' }} / TOURNAMENT: {{ profile.runtime.tournamentRunning ? 'ON' : 'OFF' }}
diff --git a/app/gateway-frontend/src/views/ServerOperationsView.vue b/app/gateway-frontend/src/views/ServerOperationsView.vue index 9908d48..b39a048 100644 --- a/app/gateway-frontend/src/views/ServerOperationsView.vue +++ b/app/gateway-frontend/src/views/ServerOperationsView.vue @@ -16,7 +16,12 @@ type Profile = { buildWorkspace?: string; buildError?: string; lastError?: string; - runtime: { apiRunning: boolean; daemonRunning: boolean; tournamentRunning: boolean }; + runtime: { + apiRunning: boolean; + daemonRunning: boolean; + battleSimRunning: boolean; + tournamentRunning: boolean; + }; }; type Scenario = { @@ -377,6 +382,14 @@ onBeforeUnmount(() => { {{ selectedProfile.runtime.daemonRunning ? 'RUNNING' : 'STOPPED' }} +
+
Battle sim worker
+
+ {{ selectedProfile.runtime.battleSimRunning ? 'RUNNING' : 'STOPPED' }} +
+
Tournament worker
Date: Sun, 26 Jul 2026 05:30:35 +0000 Subject: [PATCH 19/19] feat(frontend): finish battle simulator workflows --- app/game-frontend/e2e/battleSimulator.spec.ts | 320 ++++++++++++++++++ .../e2e/battleSimulatorRef.spec.ts | 73 ++++ app/game-frontend/e2e/playwright.config.mjs | 2 + app/game-frontend/package.json | 1 + .../components/battle/BattleGeneralCard.vue | 55 ++- app/game-frontend/src/router/index.ts | 1 - .../src/views/BattleSimulatorView.vue | 239 ++++++++++--- app/game-frontend/src/views/JoinView.vue | 23 +- docs/frontend-legacy-parity.md | 18 +- 9 files changed, 636 insertions(+), 96 deletions(-) create mode 100644 app/game-frontend/e2e/battleSimulator.spec.ts create mode 100644 app/game-frontend/e2e/battleSimulatorRef.spec.ts diff --git a/app/game-frontend/e2e/battleSimulator.spec.ts b/app/game-frontend/e2e/battleSimulator.spec.ts new file mode 100644 index 0000000..bf0760f --- /dev/null +++ b/app/game-frontend/e2e/battleSimulator.spec.ts @@ -0,0 +1,320 @@ +import { expect, test, type Page, type Route } from '@playwright/test'; +import { readFile } from 'node:fs/promises'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../..'); +const imageRoots = [resolve(repositoryRoot, '../image'), resolve(repositoryRoot, '../../image')]; +const artifactRoot = process.env.BATTLE_SIM_ARTIFACT_DIR; + +const response = (data: unknown) => ({ result: { data } }); +const errorResponse = (path: string, message: string) => ({ + error: { + message, + code: -32000, + data: { code: 'BAD_REQUEST', httpStatus: 400, path }, + }, +}); + +const operationNames = (route: Route): string[] => { + const url = new URL(route.request().url()); + return decodeURIComponent(url.pathname.slice(url.pathname.lastIndexOf('/trpc/') + 6)).split(','); +}; + +const readImage = async (relative: string): Promise => { + for (const root of imageRoots) { + try { + return await readFile(resolve(root, relative)); + } catch { + // Main checkout and feature worktrees have different image-root parents. + } + } + throw new Error(`Reference image not found: ${relative}`); +}; + +const simulatorOptions = { + world: { startYear: 190, currentYear: 205, currentMonth: 8 }, + config: { + maxTrainByWar: 120, + maxAtmosByWar: 120, + maxTrainByCommand: 100, + maxAtmosByCommand: 100, + }, + unitSet: { + defaultCrewTypeId: 100, + crewTypes: [ + { id: 100, name: '보병', armType: 1 }, + { id: 200, name: '궁병', armType: 2 }, + ], + }, + nationTypes: [{ key: 'che_중립', name: '중립', info: '특별한 효과 없음' }], + warTraits: [{ key: 'che_필살', name: '필살', info: '필살 확률 증가' }], + personalities: [{ key: 'che_대담', name: '대담', info: '공격적인 성격' }], + items: { horse: [], weapon: [], book: [], item: [] }, + nationLevels: [ + { level: 0, name: '방랑군' }, + { level: 1, name: '소국' }, + ], + cityLevels: [ + { level: 1, name: '소도시' }, + { level: 5, name: '대도시' }, + ], + dexLevels: [ + { level: 0, label: 'F', value: 0 }, + { level: 1, label: 'E', value: 1000 }, + ], +}; + +const generalMe = { + general: { + id: 7, + name: '유비', + npcState: 0, + nationId: 1, + cityId: 1, + troopId: 0, + picture: '22.jpg', + imageServer: 0, + officerLevel: 12, + stats: { leadership: 85, strength: 72, intelligence: 78 }, + gold: 1000, + rice: 8765, + crew: 4321, + train: 99, + atmos: 98, + injury: 0, + experience: 900, + dedication: 100, + items: { horse: null, weapon: null, book: null, item: null }, + }, + city: { id: 1, level: 1, defence: 2222, wall: 3333 }, + nation: { id: 1, level: 1, tech: 4500, typeCode: 'che_중립', capitalCityId: 1 }, + settings: {}, + penalties: {}, +}; + +const importedGeneral = { + general: { + no: 7, + name: '유비', + officer_level: 12, + explevel: 30, + leadership: 85, + strength: 72, + intel: 78, + horse: null, + weapon: null, + book: null, + item: null, + injury: 0, + rice: 8765, + personal: 'che_대담', + special2: 'che_필살', + crew: 4321, + crewtype: 100, + atmos: 98, + train: 99, + dex1: 1000, + dex2: 0, + dex3: 0, + dex4: 0, + dex5: 0, + defence_train: 90, + warnum: 12, + killnum: 7, + killcrew: 3456, + }, +}; + +const simulationResult = { + result: true, + reason: 'success', + datetime: '205-08', + avgWar: 5, + phase: 13, + killed: 1234, + maxKilled: 1400, + minKilled: 1100, + dead: 432, + maxDead: 500, + minDead: 400, + attackerRice: 321, + defenderRice: 654, + attackerSkills: { 필살: 2 }, + defendersSkills: [{ 회피: 1 }], + lastWarLog: { + generalHistoryLog: '', + generalActionLog: '', + generalBattleResultLog: '유비가 모의전에서 승리했습니다.', + generalBattleDetailLog: '필살 발동, 피해 1,234', + nationalHistoryLog: '', + globalHistoryLog: '', + globalActionLog: '', + }, +}; + +type Fixture = { + hasGeneral: boolean; + failNextSimulation?: boolean; + queueFirst?: boolean; + pollingCount: number; + requests: string[]; +}; + +const installImages = async (page: Page) => { + for (const filename of ['back_walnut.jpg', 'back_green.jpg', 'back_blue.jpg']) { + await page.route(`**/image/game/${filename}`, async (route) => { + await route.fulfill({ + status: 200, + contentType: 'image/jpeg', + body: await readImage(`game/${filename}`), + }); + }); + } +}; + +const installApi = async (page: Page, fixture: Fixture) => { + await installImages(page); + await page.addInitScript(() => { + window.localStorage.setItem('sammo-game-token', 'ga_battle_sim_playwright'); + window.localStorage.setItem('sammo-game-profile', 'che:default'); + }); + await page.route('**/che/api/trpc/**', async (route) => { + const operations = operationNames(route); + const results = operations.map((operation) => { + fixture.requests.push(operation); + if (operation === 'lobby.info') { + return response({ + year: 205, + month: 8, + myGeneral: fixture.hasGeneral ? { name: '유비', picture: '22.jpg' } : null, + }); + } + if (operation === 'battle.getSimulatorContext') return response(simulatorOptions); + if (operation === 'general.me') return response(fixture.hasGeneral ? generalMe : null); + if (operation === 'battle.getGeneralList') { + return response({ + myNationId: 1, + myGeneralId: 7, + nations: [{ id: 1, name: '촉', color: '#8fbc8f' }], + generalsByNation: { 1: [{ id: 7, name: '유비', npcState: 0 }] }, + }); + } + if (operation === 'battle.getGeneralDetail') return response(importedGeneral); + if (operation === 'battle.simulate') { + if (fixture.failNextSimulation) { + fixture.failNextSimulation = false; + return errorResponse(operation, '시뮬레이터 입력 오류'); + } + if (fixture.queueFirst) { + return response({ status: 'queued', jobId: 'job-playwright' }); + } + return response({ status: 'completed', jobId: 'job-playwright', payload: simulationResult }); + } + if (operation === 'battle.getSimulation') { + fixture.pollingCount += 1; + if (fixture.pollingCount === 1) { + return response({ status: 'queued', jobId: 'job-playwright' }); + } + return response({ + status: 'completed', + jobId: 'job-playwright', + payload: simulationResult, + }); + } + return errorResponse(operation, `Unhandled battle simulator fixture operation: ${operation}`); + }); + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(results), + }); + }); +}; + +const gotoSimulator = async (page: Page) => { + await page.goto('battle-simulator'); + await expect(page.getByRole('heading', { name: '전투 시뮬레이터' })).toBeVisible(); + await expect(page.getByLabel('시뮬레이터 데이터 안내')).toBeVisible(); + await expect(page.getByText('출병자 설정')).toBeVisible(); +}; + +test('operates independent/game presets, imports my general, and renders battle logs', async ({ page }) => { + const fixture: Fixture = { hasGeneral: true, queueFirst: true, pollingCount: 0, requests: [] }; + await installApi(page, fixture); + await page.setViewportSize({ width: 1280, height: 900 }); + await gotoSimulator(page); + + const notice = page.getByLabel('시뮬레이터 데이터 안내'); + const noticeRect = await notice.boundingBox(); + expect(noticeRect?.width).toBeGreaterThan(900); + expect(await notice.evaluate((element) => getComputedStyle(element).display)).toBe('flex'); + + await page.getByRole('button', { name: '독립 기본값' }).click(); + await expect(page.getByLabel('연도', { exact: true })).toHaveValue('190'); + await expect(page.getByLabel('월')).toHaveValue('1'); + + await page.getByRole('button', { name: '현재 게임 환경 적용' }).click(); + await expect(page.getByLabel('연도', { exact: true })).toHaveValue('205'); + await expect(page.getByLabel('월')).toHaveValue('8'); + + await page.getByRole('button', { name: '내 장수를 출병자로' }).click(); + await expect(page.getByLabel('이름').first()).toHaveValue('유비'); + await expect(page.getByLabel('병사').first()).toHaveValue('4321'); + + const battleButton = page.getByRole('button', { name: '전투', exact: true }); + await battleButton.hover(); + expect(await battleButton.evaluate((element) => getComputedStyle(element).cursor)).toBe('pointer'); + await page.getByLabel('시드').fill('playwright-fixed-seed'); + await battleButton.click(); + + await expect(page.getByText('유비가 모의전에서 승리했습니다.')).toBeVisible(); + await expect(page.getByText('5', { exact: true })).toBeVisible(); + expect(fixture.pollingCount).toBe(2); + expect(fixture.requests).toContain('battle.getSimulation'); + + if (artifactRoot) { + await page.screenshot({ + path: resolve(artifactRoot, 'battle-simulator-core-desktop.png'), + fullPage: true, + animations: 'disabled', + }); + } +}); + +test('keeps simulation available without a game general and preserves input after an API error', async ({ page }) => { + const fixture: Fixture = { + hasGeneral: false, + failNextSimulation: true, + pollingCount: 0, + requests: [], + }; + await installApi(page, fixture); + await page.setViewportSize({ width: 500, height: 900 }); + await gotoSimulator(page); + + await expect(page).toHaveURL(/battle-simulator/); + await expect(page.getByRole('button', { name: '내 장수를 출병자로' })).toBeDisabled(); + await expect(page.getByRole('button', { name: '서버에서 가져오기' }).first()).toBeDisabled(); + + await page.getByLabel('시드').fill('keep-this-seed'); + await page.getByRole('button', { name: '전투', exact: true }).click(); + await expect(page.getByText('시뮬레이터 입력 오류')).toBeVisible(); + await expect(page.getByLabel('시드')).toHaveValue('keep-this-seed'); + + await page.getByRole('button', { name: '전투', exact: true }).click(); + await expect(page.getByText('유비가 모의전에서 승리했습니다.')).toBeVisible(); + await expect(page.getByText('시뮬레이터 입력 오류')).toHaveCount(0); + + const notice = page.getByLabel('시뮬레이터 데이터 안내'); + expect(await notice.evaluate((element) => getComputedStyle(element).flexDirection)).toBe('column'); + expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true); + + if (artifactRoot) { + await page.screenshot({ + path: resolve(artifactRoot, 'battle-simulator-core-mobile.png'), + fullPage: true, + animations: 'disabled', + }); + } +}); diff --git a/app/game-frontend/e2e/battleSimulatorRef.spec.ts b/app/game-frontend/e2e/battleSimulatorRef.spec.ts new file mode 100644 index 0000000..b11b48a --- /dev/null +++ b/app/game-frontend/e2e/battleSimulatorRef.spec.ts @@ -0,0 +1,73 @@ +import { createHash } from 'node:crypto'; +import { readFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; + +import { expect, test } from '@playwright/test'; + +const refBaseUrl = process.env.REF_BATTLE_SIM_URL; +const refPasswordFile = process.env.REF_USER_PASSWORD_FILE; +const refUsername = process.env.REF_USER_ID ?? 'refuser1'; +const artifactRoot = process.env.BATTLE_SIM_ARTIFACT_DIR; +const refTest = refBaseUrl && refPasswordFile ? test : test.skip; + +refTest('runs the legacy simulator in the same Chromium and captures its rendered contract', async ({ page }) => { + test.setTimeout(120_000); + if (!refBaseUrl || !refPasswordFile) { + throw new Error('REF_BATTLE_SIM_URL and REF_USER_PASSWORD_FILE are required'); + } + const password = (await readFile(refPasswordFile, 'utf8')).trim(); + await page.setViewportSize({ width: 1280, height: 900 }); + await page.goto(refBaseUrl, { waitUntil: 'networkidle' }); + await page.locator('#username').fill(refUsername); + await page.locator('#password').fill(password); + const globalSalt = await page.locator('#global_salt').inputValue(); + const passwordHash = createHash('sha512') + .update(globalSalt + password + globalSalt) + .digest('hex'); + const loginResponse = await page + .context() + .request.post(new URL('api.php?path=Login/LoginByID', refBaseUrl).toString(), { + data: { username: refUsername, password: passwordHash }, + }); + expect(loginResponse.status()).toBe(200); + await expect(loginResponse.json()).resolves.toMatchObject({ result: true }); + + await page.goto(new URL('hwe/battle_simulator.php', refBaseUrl).toString(), { + waitUntil: 'networkidle', + }); + const battleButton = page.locator('.btn-begin_battle'); + await expect(battleButton).toBeVisible(); + const container = page.locator('#container'); + const rect = await container.boundingBox(); + expect(rect?.width).toBeGreaterThanOrEqual(995); + expect(rect?.width).toBeLessThanOrEqual(1005); + + // A login with no game general leaves the legacy nation selects without a + // selected option. Choose the first legal independent value before running. + await page.locator('.form_nation_type').evaluateAll((elements) => { + for (const element of elements) { + const select = element as HTMLSelectElement; + select.selectedIndex = 0; + select.dispatchEvent(new Event('change', { bubbles: true })); + } + }); + await expect(page.locator('.form_nation_type').first()).not.toHaveValue(''); + + await battleButton.hover(); + expect(await battleButton.evaluate((element) => getComputedStyle(element).cursor)).toBe('pointer'); + const simulationResponse = page.waitForResponse( + (response) => response.url().includes('/j_simulate_battle.php') && response.status() === 200, + { timeout: 90_000 } + ); + await battleButton.click(); + await simulationResponse; + await expect(page.locator('#generalBattleResultLog')).not.toBeEmpty(); + + if (artifactRoot) { + await page.screenshot({ + path: resolve(artifactRoot, 'battle-simulator-ref-desktop.png'), + fullPage: true, + animations: 'disabled', + }); + } +}); diff --git a/app/game-frontend/e2e/playwright.config.mjs b/app/game-frontend/e2e/playwright.config.mjs index 94135ac..5ef6863 100644 --- a/app/game-frontend/e2e/playwright.config.mjs +++ b/app/game-frontend/e2e/playwright.config.mjs @@ -16,6 +16,8 @@ export default defineConfig({ 'nationOffices.spec.ts', 'nationGeneralSecret.spec.ts', 'npcPolicy.spec.ts', + 'battleSimulator.spec.ts', + 'battleSimulatorRef.spec.ts', ], fullyParallel: false, workers: 1, diff --git a/app/game-frontend/package.json b/app/game-frontend/package.json index a60d1d3..5d3a6bd 100644 --- a/app/game-frontend/package.json +++ b/app/game-frontend/package.json @@ -11,6 +11,7 @@ "test:e2e:nation-offices": "playwright test nationOffices.spec.ts --config e2e/playwright.config.mjs", "test:e2e:npc-policy": "playwright test npcPolicy.spec.ts --config e2e/playwright.config.mjs", "test:e2e:board": "playwright test board.spec.ts --config e2e/playwright.config.mjs", + "test:e2e:battle-simulator": "playwright test battleSimulator.spec.ts --config e2e/playwright.config.mjs", "lint": "eslint .", "lint:fix": "eslint . --fix", "test": "node -e \"console.log('test not configured')\"", diff --git a/app/game-frontend/src/components/battle/BattleGeneralCard.vue b/app/game-frontend/src/components/battle/BattleGeneralCard.vue index 8f60d54..7a84f23 100644 --- a/app/game-frontend/src/components/battle/BattleGeneralCard.vue +++ b/app/game-frontend/src/components/battle/BattleGeneralCard.vue @@ -7,6 +7,7 @@ interface Props { options: BattleSimOptions; mode: 'attacker' | 'defender'; title: string; + canImportServer: boolean; } const props = defineProps(); @@ -59,7 +60,19 @@ const officerLevelOptions = [
No {{ general.no }}
- + @@ -187,21 +200,11 @@ const officerLevelOptions = [
@@ -391,6 +379,11 @@ const officerLevelOptions = [ color: #f0b6b6; } +.action:disabled { + cursor: not-allowed; + opacity: 0.45; +} + .form-block { display: flex; flex-direction: column; diff --git a/app/game-frontend/src/router/index.ts b/app/game-frontend/src/router/index.ts index 63d0a16..c4dd81d 100644 --- a/app/game-frontend/src/router/index.ts +++ b/app/game-frontend/src/router/index.ts @@ -197,7 +197,6 @@ const routes = [ component: BattleSimulatorView, meta: { requiresAuth: true, - requiresGeneral: true, }, }, { diff --git a/app/game-frontend/src/views/BattleSimulatorView.vue b/app/game-frontend/src/views/BattleSimulatorView.vue index 9b6dec7..e8ced0d 100644 --- a/app/game-frontend/src/views/BattleSimulatorView.vue +++ b/app/game-frontend/src/views/BattleSimulatorView.vue @@ -26,6 +26,7 @@ type BattleExport = { type ExportedInfo = { objType: 'general'; data: GeneralExport } | { objType: 'battle'; data: BattleExport }; type GeneralListResponse = Awaited>; +type GeneralMeResponse = Awaited>; const loading = ref(true); const error = ref(null); @@ -72,6 +73,7 @@ const importTarget = ref(null); const generalList = ref(null); const generalListLoading = ref(false); const selectedGeneralId = ref(null); +const gameDefaults = ref(null); let generalIdSeed = 0; @@ -250,6 +252,7 @@ const initializeDefaults = async () => { try { const [context, me] = await Promise.all([trpc.battle.getSimulatorContext.query(), trpc.general.me.query()]); options.value = context; + gameDefaults.value = me; year.value = context.world.currentYear; month.value = context.world.currentMonth; repeatCnt.value = 1; @@ -283,6 +286,47 @@ const initializeDefaults = async () => { } }; +const hasGameGeneral = computed(() => !!gameDefaults.value?.general?.id); + +const applyGameEnvironment = () => { + if (!options.value) { + return; + } + const me = gameDefaults.value; + const nationTypeDefault = options.value.nationTypes[0]?.key ?? 'che_중립'; + year.value = options.value.world.currentYear; + month.value = options.value.world.currentMonth; + attackerNation.type = me?.nation?.typeCode ?? nationTypeDefault; + defenderNation.type = attackerNation.type; + attackerNation.level = me?.nation?.level ?? 0; + defenderNation.level = attackerNation.level; + attackerNation.tech = me?.nation?.tech ? Math.floor(me.nation.tech / 1000) : 1; + defenderNation.tech = attackerNation.tech; + attackerCity.level = me?.city?.level ?? 5; + defenderCity.level = attackerCity.level; + defenderCity.def = me?.city?.defence ?? 1000; + defenderCity.wall = me?.city?.wall ?? 1000; + attackerNation.isCapital = !!me?.city && me.nation?.capitalCityId === me.city.id; + defenderNation.isCapital = attackerNation.isCapital; + error.value = null; +}; + +const applyIndependentEnvironment = () => { + if (!options.value) { + return; + } + const nationTypeDefault = options.value.nationTypes[0]?.key ?? 'che_중립'; + year.value = options.value.world.startYear; + month.value = 1; + seed.value = ''; + repeatCnt.value = 1; + Object.assign(attackerNation, { type: nationTypeDefault, tech: 1, level: 0, isCapital: false }); + Object.assign(defenderNation, { type: nationTypeDefault, tech: 1, level: 0, isCapital: false }); + attackerCity.level = 5; + Object.assign(defenderCity, { level: 5, def: 1000, wall: 1000 }); + error.value = null; +}; + onMounted(() => { void initializeDefaults(); }); @@ -536,13 +580,19 @@ const runSimulation = async (action: BattleSimRequestPayload['action']) => { } isSimulating.value = true; + error.value = null; + if (action === 'battle') { + battleResult.value = null; + } statusMessage.value = action === 'battle' ? '전투를 진행 중입니다.' : '수비자 순서를 계산 중입니다.'; try { const payload = buildBattlePayload(action); const response = await trpc.battle.simulate.mutate(payload); const result = - 'payload' in response && response.payload ? response.payload : await waitForSimulationResult(response.jobId); + 'payload' in response && response.payload + ? response.payload + : await waitForSimulationResult(response.jobId); if (!result.result) { error.value = result.reason || 'battle_failed'; @@ -786,6 +836,10 @@ const loadGeneralList = async () => { }; const openImportModal = async (target: GeneralDraft) => { + if (!hasGameGeneral.value) { + error.value = '게임 장수를 보유한 사용자만 서버 장수 정보를 가져올 수 있습니다.'; + return; + } importTarget.value = target; importOpen.value = true; if (!generalList.value) { @@ -801,47 +855,62 @@ const closeImportModal = () => { importTarget.value = null; }; +const applyServerGeneral = async (target: GeneralDraft, generalId: number) => { + const response = await trpc.battle.getGeneralDetail.query({ generalId }); + applyGeneralExport(target, { + no: response.general.no, + name: response.general.name, + officerLevel: response.general.officer_level, + expLevel: response.general.explevel, + leadership: response.general.leadership, + strength: response.general.strength, + intel: response.general.intel, + horse: response.general.horse, + weapon: response.general.weapon, + book: response.general.book, + item: response.general.item, + injury: response.general.injury, + rice: response.general.rice, + personal: response.general.personal, + special2: response.general.special2, + crew: response.general.crew, + crewtype: response.general.crewtype, + atmos: response.general.atmos, + train: response.general.train, + dex1: response.general.dex1, + dex2: response.general.dex2, + dex3: response.general.dex3, + dex4: response.general.dex4, + dex5: response.general.dex5, + defenceTrain: response.general.defence_train, + warnum: response.general.warnum, + killnum: response.general.killnum, + killcrew: response.general.killcrew, + inheritBuff: createInheritBuff(), + }); + target.no = target === attackerGeneral.value ? 1 : resolveGeneralNo(response.general.no, target.id); +}; + +const applyMyGeneralToAttacker = async () => { + const generalId = gameDefaults.value?.general?.id; + if (!attackerGeneral.value || !generalId) { + error.value = '불러올 내 장수가 없습니다.'; + return; + } + try { + error.value = null; + await applyServerGeneral(attackerGeneral.value, generalId); + } catch (err) { + error.value = resolveErrorMessage(err); + } +}; + const confirmImport = async () => { if (!importTarget.value || !selectedGeneralId.value) { return; } try { - const response = await trpc.battle.getGeneralDetail.query({ generalId: selectedGeneralId.value }); - applyGeneralExport(importTarget.value, { - no: response.general.no, - name: response.general.name, - officerLevel: response.general.officer_level, - expLevel: response.general.explevel, - leadership: response.general.leadership, - strength: response.general.strength, - intel: response.general.intel, - horse: response.general.horse, - weapon: response.general.weapon, - book: response.general.book, - item: response.general.item, - injury: response.general.injury, - rice: response.general.rice, - personal: response.general.personal, - special2: response.general.special2, - crew: response.general.crew, - crewtype: response.general.crewtype, - atmos: response.general.atmos, - train: response.general.train, - dex1: response.general.dex1, - dex2: response.general.dex2, - dex3: response.general.dex3, - dex4: response.general.dex4, - dex5: response.general.dex5, - defenceTrain: response.general.defence_train, - warnum: response.general.warnum, - killnum: response.general.killnum, - killcrew: response.general.killcrew, - inheritBuff: createInheritBuff(), - }); - importTarget.value.no = - importTarget.value === attackerGeneral.value - ? 1 - : resolveGeneralNo(response.general.no, importTarget.value.id); + await applyServerGeneral(importTarget.value, selectedGeneralId.value); } catch (err) { error.value = resolveErrorMessage(err); } finally { @@ -882,19 +951,21 @@ const summaryRows = computed(() => { { label: '전투 페이즈', value: formatNumber(battleResult.value.phase) }, { label: '준 피해', - value: battleResult.value.minKilled !== battleResult.value.maxKilled - ? `${formatNumber(battleResult.value.killed)} (${formatNumber(battleResult.value.minKilled)} ~ ${formatNumber( - battleResult.value.maxKilled - )})` - : formatNumber(battleResult.value.killed), + value: + battleResult.value.minKilled !== battleResult.value.maxKilled + ? `${formatNumber(battleResult.value.killed)} (${formatNumber(battleResult.value.minKilled)} ~ ${formatNumber( + battleResult.value.maxKilled + )})` + : formatNumber(battleResult.value.killed), }, { label: '받은 피해', - value: battleResult.value.minDead !== battleResult.value.maxDead - ? `${formatNumber(battleResult.value.dead)} (${formatNumber(battleResult.value.minDead)} ~ ${formatNumber( - battleResult.value.maxDead - )})` - : formatNumber(battleResult.value.dead), + value: + battleResult.value.minDead !== battleResult.value.maxDead + ? `${formatNumber(battleResult.value.dead)} (${formatNumber(battleResult.value.minDead)} ~ ${formatNumber( + battleResult.value.maxDead + )})` + : formatNumber(battleResult.value.dead), }, { label: '출병자 군량 소모', value: formatNumber(battleResult.value.attackerRice) }, { label: '수비자 군량 소모', value: formatNumber(battleResult.value.defenderRice) }, @@ -937,6 +1008,32 @@ const shouldShowUI = computed(() => !loading.value && !!options.value);
+
+
+ 게임 상태와 분리된 모의 계산 +

+ 현재 연도·국가·도시는 시작값으로만 읽으며, 아래 편집과 전투 결과는 턴·DB·장수 상태를 변경하지 + 않습니다. +

+
+
+ + + +
+
+
{{ error }}
{{ statusMessage }}
@@ -1032,6 +1129,7 @@ const shouldShowUI = computed(() => !loading.value && !!options.value); :options="options!" mode="attacker" title="출병자 설정" + :can-import-server="hasGameGeneral" @import="openImportModal(attackerGeneral!)" @save="saveGeneral(attackerGeneral!)" @load="(payload) => handleGeneralLoad({ target: attackerGeneral!, file: payload.file })" @@ -1099,6 +1197,7 @@ const shouldShowUI = computed(() => !loading.value && !!options.value); :options="options!" mode="defender" :title="`수비자 설정 ${index + 1}`" + :can-import-server="hasGameGeneral" @import="openImportModal(defender)" @save="saveGeneral(defender)" @load="(payload) => handleGeneralLoad({ target: defender, file: payload.file })" @@ -1146,11 +1245,7 @@ const shouldShowUI = computed(() => !loading.value && !!options.value);