From 3202509051a7decd103c75eb7c7f6b408a1a3f7f Mon Sep 17 00:00:00 2001 From: hided62 Date: Mon, 17 Aug 2026 10:48:52 +0000 Subject: [PATCH 1/5] =?UTF-8?q?fix:=20=EC=82=AC=EB=A0=B9=EB=B6=80=20?= =?UTF-8?q?=EB=AA=A8=EB=B0=94=EC=9D=BC=20=ED=98=84=EC=9E=AC=20=ED=84=B4?= =?UTF-8?q?=EA=B3=BC=20NPC=20=EB=8C=80=EC=83=81=EC=9D=84=20=ED=98=B8?= =?UTF-8?q?=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit daemon 진행 뒤 국가 명령을 현재 슬롯에 재기준화하고 Ref 명령별 장수 대상 필터를 적용한다. 모바일 고급 편집기 12턴과 500px 8수뇌 행렬을 실제 Chromium 기준으로 복원한다. --- app/game-api/src/router/turns/index.ts | 43 ++++--- app/game-api/src/turns/commandInput.ts | 1 + app/game-api/src/turns/commandTable.ts | 1 + app/game-api/src/turns/commandTargets.ts | 55 +++++++++ app/game-api/src/turns/reservedTurns.ts | 43 +++++++ app/game-api/test/commandTargets.test.ts | 51 ++++++++ app/game-api/test/reservedTurns.test.ts | 58 ++++++++- app/game-api/test/router.test.ts | 17 ++- .../e2e/commandArguments.spec.ts | 112 +++++++++++++++++- .../command/ReservedCommandEditor.vue | 18 ++- .../src/components/command/types.ts | 1 + .../components/main/CommandArgumentForm.vue | 3 + .../src/views/ChiefCenterView.vue | 88 ++++++++++---- 13 files changed, 440 insertions(+), 51 deletions(-) create mode 100644 app/game-api/src/turns/commandTargets.ts create mode 100644 app/game-api/test/commandTargets.test.ts diff --git a/app/game-api/src/router/turns/index.ts b/app/game-api/src/router/turns/index.ts index b43a18b8..9da9d0ab 100644 --- a/app/game-api/src/router/turns/index.ts +++ b/app/game-api/src/router/turns/index.ts @@ -28,13 +28,14 @@ import { repeatNationTurns, setGeneralTurn, setGeneralTurns, - setNationTurn, - setNationTurns, + setNationTurnAtCurrentPosition, + setNationTurnsAtCurrentPositions, shiftGeneralTurns, shiftNationTurns, } from '../../turns/reservedTurns.js'; import { getOwnedGeneral } from '../shared/general.js'; import type { GameApiContext, GeneralRow, WorldStateRow } from '../../context.js'; +import { buildRefGeneralTargetOptions } from '../../turns/commandTargets.js'; const zPushAmount = z .number() @@ -181,9 +182,15 @@ export const getTurnCommandTable = async (ctx: GameApiContext, generalId: number orderBy: { id: 'asc' }, }), ctx.db.general.findMany({ - where: { npcState: { lt: 2 } }, - select: { id: true, name: true, nationId: true, cityId: true }, - orderBy: { id: 'asc' }, + select: { + id: true, + name: true, + nationId: true, + cityId: true, + npcState: true, + officerLevel: true, + }, + orderBy: [{ npcState: 'asc' }, { name: 'asc' }, { id: 'asc' }], }), environmentPromise, loadBattleSimTraitOptions(), @@ -192,7 +199,13 @@ export const getTurnCommandTable = async (ctx: GameApiContext, generalId: number ]); const nationById = new Map(nations.map((entry) => [entry.id, entry])); - const cityById = new Map(cities.map((entry) => [entry.id, entry])); + const generalTargetOptions = buildRefGeneralTargetOptions({ + actorId: general.id, + actorNationId: general.nationId, + generals, + nationNames: new Map(nations.map((entry) => [entry.id, entry.name])), + cityNames: new Map(cities.map((entry) => [entry.id, entry.name])), + }); const items: TurnCommandInputOptions['items'] = { horse: [{ value: 'None', label: '판매/해제' }], weapon: [{ value: 'None', label: '판매/해제' }], @@ -226,12 +239,8 @@ export const getTurnCommandTable = async (ctx: GameApiContext, generalId: number label: entry.name, color: entry.color, })), - generals: generals.map((entry) => ({ - value: entry.id, - label: `${entry.name} (${nationById.get(entry.nationId)?.name ?? '무소속'} · ${ - cityById.get(entry.cityId)?.name ?? '재야' - })`, - })), + generals: generalTargetOptions.generals, + generalTargets: generalTargetOptions.generalTargets, crewTypes: (environment.unitSet.crewTypes ?? []) .filter((entry) => !entry.requirements.some((requirement) => requirement.type === 'Impossible')) .map((entry) => ({ value: entry.id, label: entry.name })), @@ -446,7 +455,7 @@ export const turnsRouter = router({ await assertReservedTurnPermission(worldState, general, 'nation', input.action, args); const snapshot = await mutateReservedTurns(() => - setNationTurn( + setNationTurnAtCurrentPosition( ctx.db, general.nationId, general.officerLevel, @@ -559,7 +568,13 @@ export const turnsRouter = router({ await assertReservedTurnPermission(worldState, general, 'nation', update.action, update.args); } const snapshot = await mutateReservedTurns(() => - setNationTurns(ctx.db, general.nationId, general.officerLevel, updates, input.expectedRevision) + setNationTurnsAtCurrentPositions( + ctx.db, + general.nationId, + general.officerLevel, + updates, + input.expectedRevision + ) ); return { ok: true, ...snapshot }; }), diff --git a/app/game-api/src/turns/commandInput.ts b/app/game-api/src/turns/commandInput.ts index 6aa2ea19..401c2852 100644 --- a/app/game-api/src/turns/commandInput.ts +++ b/app/game-api/src/turns/commandInput.ts @@ -71,6 +71,7 @@ export interface TurnCommandInputOptions { cities: TurnCommandOption[]; nations: TurnCommandOption[]; generals: TurnCommandOption[]; + generalTargets?: Record; crewTypes: TurnCommandOption[]; armTypes: TurnCommandOption[]; nationTypes: TurnCommandOption[]; diff --git a/app/game-api/src/turns/commandTable.ts b/app/game-api/src/turns/commandTable.ts index 5e5c32ef..63fed4c8 100644 --- a/app/game-api/src/turns/commandTable.ts +++ b/app/game-api/src/turns/commandTable.ts @@ -771,6 +771,7 @@ export const buildTurnCommandTable = async (options: { cities: [], nations: [], generals: [], + generalTargets: {}, crewTypes: [], armTypes: [], nationTypes: [], diff --git a/app/game-api/src/turns/commandTargets.ts b/app/game-api/src/turns/commandTargets.ts new file mode 100644 index 00000000..33c71434 --- /dev/null +++ b/app/game-api/src/turns/commandTargets.ts @@ -0,0 +1,55 @@ +import type { TurnCommandOption } from './commandInput.js'; + +export interface GeneralTargetSource { + id: number; + name: string; + nationId: number; + cityId: number; + npcState: number; + officerLevel: number; +} + +export interface RefGeneralTargetOptions { + generals: TurnCommandOption[]; + generalTargets: Record; +} + +const SAME_NATION_GENERAL_COMMANDS = ['che_증여'] as const; +const SAME_NATION_NATION_COMMANDS = ['che_발령', 'che_포상', 'che_몰수', 'che_부대탈퇴지시'] as const; + +/** Ref 각 처리 화면의 SELECT 조건을 공통 command table의 명령별 option으로 투영한다. */ +export const buildRefGeneralTargetOptions = (options: { + actorId: number; + actorNationId: number; + generals: readonly GeneralTargetSource[]; + nationNames: ReadonlyMap; + cityNames: ReadonlyMap; +}): RefGeneralTargetOptions => { + const toOption = (entry: GeneralTargetSource): TurnCommandOption => ({ + value: entry.id, + label: `${entry.name} (${options.nationNames.get(entry.nationId) ?? '무소속'} · ${ + options.cityNames.get(entry.cityId) ?? '재야' + })`, + }); + const project = (predicate: (entry: GeneralTargetSource) => boolean): TurnCommandOption[] => + options.generals.filter(predicate).map(toOption); + + const sameNation = project((entry) => entry.nationId === options.actorNationId); + const generalTargets: Record = {}; + for (const action of SAME_NATION_GENERAL_COMMANDS) generalTargets[action] = sameNation; + for (const action of SAME_NATION_NATION_COMMANDS) generalTargets[action] = sameNation; + + generalTargets.che_선양 = project( + (entry) => entry.nationId !== 0 && entry.nationId === options.actorNationId && entry.id !== options.actorId + ); + generalTargets.che_등용 = project( + (entry) => entry.npcState < 2 && entry.officerLevel !== 12 && entry.id !== options.actorId + ); + generalTargets.che_장수대상임관 = project((entry) => entry.id !== options.actorId); + + return { + // 기존 profile의 공통 fallback은 유저장 목록을 유지한다. + generals: project((entry) => entry.npcState < 2), + generalTargets, + }; +}; diff --git a/app/game-api/src/turns/reservedTurns.ts b/app/game-api/src/turns/reservedTurns.ts index 43ec023c..e3944732 100644 --- a/app/game-api/src/turns/reservedTurns.ts +++ b/app/game-api/src/turns/reservedTurns.ts @@ -396,6 +396,32 @@ export const setNationTurns = async ( return { revision, turns: serializeTurnList(turns) }; }; +/** + * 국가 턴 입력은 화면을 연 뒤 daemon이 선두 턴을 소비했더라도 사용자가 고른 + * 슬롯 번호를 현재 큐에 적용한다. 큐 lease가 실제로 잡혀 있는 충돌은 그대로 + * 거절하고, revision이 앞으로 진행한 경우에만 새 revision으로 재기준화한다. + */ +export const setNationTurnsAtCurrentPositions = async ( + db: DatabaseClient, + nationId: number, + officerLevel: number, + updates: readonly ReservedTurnUpdate[], + expectedRevision: number +): Promise => { + let revision = expectedRevision; + for (let attempt = 0; attempt < 8; attempt += 1) { + try { + return await setNationTurns(db, nationId, officerLevel, updates, revision); + } catch (error) { + if (!(error instanceof ReservedTurnRevisionConflictError) || error.currentRevision === revision) { + throw error; + } + revision = error.currentRevision; + } + } + throw new ReservedTurnRevisionConflictError(revision, revision); +}; + export const setNationTurn = async ( db: DatabaseClient, nationId: number, @@ -407,6 +433,23 @@ export const setNationTurn = async ( ): Promise => setNationTurns(db, nationId, officerLevel, [{ turnIndices: [turnIndex], action, args }], expectedRevision); +export const setNationTurnAtCurrentPosition = async ( + db: DatabaseClient, + nationId: number, + officerLevel: number, + turnIndex: number, + action: string, + args: unknown, + expectedRevision: number +): Promise => + setNationTurnsAtCurrentPositions( + db, + nationId, + officerLevel, + [{ turnIndices: [turnIndex], action, args }], + expectedRevision + ); + export const shiftNationTurns = async ( db: DatabaseClient, nationId: number, diff --git a/app/game-api/test/commandTargets.test.ts b/app/game-api/test/commandTargets.test.ts new file mode 100644 index 00000000..ed7104fb --- /dev/null +++ b/app/game-api/test/commandTargets.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest'; + +import { buildRefGeneralTargetOptions, type GeneralTargetSource } from '../src/turns/commandTargets.js'; + +const general = (overrides: Partial): GeneralTargetSource => ({ + id: 1, + name: '본인', + nationId: 1, + cityId: 10, + npcState: 0, + officerLevel: 5, + ...overrides, +}); + +describe('Ref command general targets', () => { + const sources = [ + general({}), + general({ id: 2, name: '아국유저', officerLevel: 12 }), + general({ id: 3, name: '아국NPC', npcState: 2, officerLevel: 0 }), + general({ id: 4, name: '타국유저', nationId: 2, cityId: 20, officerLevel: 0 }), + general({ id: 5, name: '타국NPC', nationId: 2, cityId: 20, npcState: 3, officerLevel: 0 }), + ]; + const result = buildRefGeneralTargetOptions({ + actorId: 1, + actorNationId: 1, + generals: sources, + nationNames: new Map([ + [1, '아국'], + [2, '타국'], + ]), + cityNames: new Map([ + [10, '업'], + [20, '허창'], + ]), + }); + const ids = (action: string) => result.generalTargets[action]?.map((entry) => entry.value); + + it('includes user and NPC generals of the same nation for every Ref nation personnel command', () => { + for (const action of ['che_발령', 'che_포상', 'che_몰수', 'che_부대탈퇴지시']) { + expect(ids(action)).toEqual([1, 2, 3]); + } + }); + + it('preserves the distinct Ref filters for gift, abdication, recruitment, and target-based joining', () => { + expect(ids('che_증여')).toEqual([1, 2, 3]); + expect(ids('che_선양')).toEqual([2, 3]); + expect(ids('che_등용')).toEqual([4]); + expect(ids('che_장수대상임관')).toEqual([2, 3, 4, 5]); + expect(result.generals.map((entry) => entry.value)).toEqual([1, 2, 4]); + }); +}); diff --git a/app/game-api/test/reservedTurns.test.ts b/app/game-api/test/reservedTurns.test.ts index cce2678a..c5066ddc 100644 --- a/app/game-api/test/reservedTurns.test.ts +++ b/app/game-api/test/reservedTurns.test.ts @@ -11,6 +11,7 @@ import { setGeneralTurns, setNationTurn, setNationTurns, + setNationTurnsAtCurrentPositions, shiftGeneralTurns, shiftNationTurns, ReservedTurnRevisionConflictError, @@ -195,7 +196,7 @@ const buildDb = () => { }, } as unknown as DatabaseClient; - return { db }; + return { db, nationTurns, nationRevisions }; }; describe('reservedTurns', () => { @@ -339,6 +340,61 @@ describe('reservedTurns', () => { expect(noOpPush).toEqual(repeated); }); + it('rebases stale nation slot input onto the current queue after a turn advances', async () => { + const { db, nationTurns, nationRevisions } = buildDb(); + const seeded = await setNationTurns( + db, + 6, + 12, + [ + { turnIndices: [0], action: 'che_증축', args: {} }, + { turnIndices: [1], action: 'che_감축', args: {} }, + { turnIndices: [2], action: 'che_천도', args: { destCityId: 3 } }, + ], + 0 + ); + expect(seeded.revision).toBe(1); + + // daemon이 한 턴을 소비한 뒤의 현재 큐를 모사한다. + nationRevisions.set('6:12', 2); + nationTurns.set('6:12', [ + { + id: 1, + nationId: 6, + officerLevel: 12, + turnIdx: 0, + actionCode: 'che_감축', + arg: {}, + createdAt: new Date(), + }, + { + id: 2, + nationId: 6, + officerLevel: 12, + turnIdx: 1, + actionCode: 'che_천도', + arg: { destCityId: 3 }, + createdAt: new Date(), + }, + ]); + + const result = await setNationTurnsAtCurrentPositions( + db, + 6, + 12, + [{ turnIndices: [2], action: 'che_포상', args: { destGeneralId: 77, amount: 100, isGold: true } }], + 1 + ); + + expect(result.revision).toBe(3); + expect(result.turns[0]?.action).toBe('che_감축'); + expect(result.turns[1]?.action).toBe('che_천도'); + expect(result.turns[2]).toMatchObject({ + action: 'che_포상', + args: { destGeneralId: 77, amount: 100, isGold: true }, + }); + }); + it('rejects an API writer while the daemon holds the queue lease without touching turns', async () => { const deleteMany = vi.fn(async () => ({})); const createMany = vi.fn(async () => ({})); diff --git a/app/game-api/test/router.test.ts b/app/game-api/test/router.test.ts index f99f2ab4..bf5b8617 100644 --- a/app/game-api/test/router.test.ts +++ b/app/game-api/test/router.test.ts @@ -1020,7 +1020,22 @@ describe('appRouter', () => { expect(response.turns[0]?.args).toEqual({ isGold: true, amount: 1, destGeneralId: 7 }); expect(response.turns[2]?.args).toEqual({ isGold: false, amount: 2, destGeneralId: 8 }); - expect(nationWrites).toHaveLength(1); + + const rebased = await caller.turns.reserved.setNationBulk({ + generalId: general.id, + entries: [ + { + turnList: [2], + action: 'che_포상', + args: { isGold: true, amount: 3, destGeneralId: 9 }, + }, + ], + // 첫 요청 뒤 턴이 진행한 화면의 stale revision을 그대로 보낸 상황입니다. + expectedRevision: 0, + }); + expect(rebased.revision).toBe(2); + expect(rebased.turns[2]?.args).toEqual({ isGold: true, amount: 3, destGeneralId: 9 }); + expect(nationWrites).toHaveLength(2); }); it('enforces only legacy reservation permissions without applying full execution constraints', async () => { diff --git a/app/game-frontend/e2e/commandArguments.spec.ts b/app/game-frontend/e2e/commandArguments.spec.ts index dde3ebb1..656dfc91 100644 --- a/app/game-frontend/e2e/commandArguments.spec.ts +++ b/app/game-frontend/e2e/commandArguments.spec.ts @@ -52,6 +52,18 @@ const inputOptions = { { value: 1, label: '장수 (아국 · 업)' }, { value: 2, label: '관우 (아국 · 업)' }, ], + generalTargets: { + che_포상: [ + { value: 1, label: '장수 (아국 · 업)' }, + { value: 2, label: '관우 (아국 · 업)' }, + { value: 3, label: '여포NPC (아국 · 업)' }, + ], + che_몰수: [ + { value: 1, label: '장수 (아국 · 업)' }, + { value: 2, label: '관우 (아국 · 업)' }, + { value: 3, label: '여포NPC (아국 · 업)' }, + ], + }, crewTypes: [{ value: 1100, label: '보병' }], armTypes: [{ value: 1, label: '보병' }], nationTypes: [{ value: 'che_중립', label: '중립' }], @@ -366,8 +378,8 @@ const chiefCenter = { maxTurns: 12, chiefs: [12, 10, 8, 6, 11, 9, 7, 5].map((officerLevel) => ({ officerLevel, - name: officerLevel === 5 ? '장수' : null, - npcState: officerLevel === 5 ? 0 : null, + name: officerLevel === 5 ? '장수' : `수뇌${officerLevel}`, + npcState: officerLevel === 8 ? 2 : 0, turnTime: null, revision: 0, turns: turns(12), @@ -723,6 +735,92 @@ test('shows every Ref chief command in the exact category and command order', as await mobilePicker.screenshot({ path: test.info().outputPath('ref-chief-command-list-mobile-500.png') }); }); +test('shows all 12 advanced chief turns before the actions and uses the full mobile chief matrix', async ({ page }) => { + await install(page); + await page.setViewportSize({ width: 500, height: 900 }); + await page.goto('/che/chief-center'); + + const editor = page.locator('[data-command-scope="nation"]:visible'); + await editor.getByRole('button', { name: '고급 모드', exact: true }).click(); + await expect(editor.locator('.index-column > button')).toHaveCount(12); + await expect(editor.locator('.index-column > button').last()).toHaveText('12'); + await expect(editor.locator('.advanced-actions')).toContainText('선택한 턴을'); + await expect(editor.locator('.advanced-actions')).toContainText('명령 선택'); + + const frame = page.locator('.chief-overview-frame'); + await expect(frame.locator('.chief-overview-row')).toHaveCount(2); + await expect(frame.locator('.overview-turn-index')).toHaveCount(4); + for (const gutter of await frame.locator('.overview-turn-index').all()) { + await expect(gutter.locator('span').filter({ hasText: /\d+/u })).toHaveText( + Array.from({ length: 12 }, (_, index) => String(index + 1)) + ); + } + await expect(frame.locator('.compact-name')).toHaveCount(8); + + const geometry = await page.locator('.chief-page').evaluate((element) => { + const editorElement = element.querySelector('[data-command-scope="nation"]')!; + const queue = editorElement.querySelector('.queue-grid')!; + const lastTurn = editorElement.querySelectorAll('.action-column > div')[11]!; + const actions = editorElement.querySelector('.advanced-actions')!; + const overviewFrame = element.querySelector('.chief-overview-frame')!; + const firstOverviewRow = element.querySelector('.chief-overview-row')!; + const overviewRows = [...element.querySelectorAll('.chief-overview-row')]; + const gutters = [...firstOverviewRow.querySelectorAll('.overview-turn-index')]; + const cards = [...firstOverviewRow.querySelectorAll('.chief-card')]; + const names = [...overviewFrame.querySelectorAll('.compact-name')]; + const frameRect = overviewFrame.getBoundingClientRect(); + const editorRect = editorElement.getBoundingClientRect(); + const actionsRect = actions.getBoundingClientRect(); + return { + editorBottom: editorRect.bottom, + editorHeight: editorRect.height, + queueBottom: queue.getBoundingClientRect().bottom, + lastTurnBottom: lastTurn.getBoundingClientRect().bottom, + actionsTop: actionsRect.top, + actionsBottom: actionsRect.bottom, + frameTop: frameRect.top, + frameWidth: frameRect.width, + rowWidth: firstOverviewRow.getBoundingClientRect().width, + rowEdges: overviewRows.map((item) => ({ + top: item.getBoundingClientRect().top - frameRect.top, + bottom: item.getBoundingClientRect().bottom - frameRect.top, + })), + gutterWidths: gutters.map((item) => item.getBoundingClientRect().width), + gutterEdges: gutters.map((item) => ({ + left: item.getBoundingClientRect().left - frameRect.left, + right: item.getBoundingClientRect().right - frameRect.left, + })), + cardWidths: cards.map((item) => item.getBoundingClientRect().width), + namesInsideFrame: names.every((item) => { + const rect = item.getBoundingClientRect(); + return rect.top >= frameRect.top && rect.bottom <= frameRect.bottom && rect.height > 0; + }), + documentOverflow: document.documentElement.scrollWidth - document.documentElement.clientWidth, + }; + }); + + expect(geometry.lastTurnBottom).toBeLessThanOrEqual(geometry.actionsTop); + expect(geometry.queueBottom).toBeLessThanOrEqual(geometry.actionsTop); + expect(geometry.actionsBottom).toBeLessThanOrEqual(geometry.editorBottom); + expect(geometry.frameTop).toBeGreaterThanOrEqual(geometry.editorBottom); + expect(geometry.editorHeight).toBeGreaterThanOrEqual(404); + expect(geometry.frameWidth).toBe(500); + expect(geometry.rowWidth).toBe(500); + expect(geometry.rowEdges).toEqual([ + { top: 0, bottom: 155 }, + { top: 155, bottom: 310 }, + ]); + expect(geometry.gutterWidths).toEqual([12, 12]); + expect(geometry.gutterEdges).toEqual([ + { left: 0, right: 12 }, + { left: 488, right: 500 }, + ]); + expect(geometry.cardWidths).toEqual([119, 119, 119, 119]); + expect(geometry.namesInsideFrame).toBe(true); + expect(geometry.documentOverflow).toBeLessThanOrEqual(0); + await page.screenshot({ path: test.info().outputPath('chief-advanced-mobile-500.png'), fullPage: true }); +}); + test('enters general and nation command arguments and sends exact values', async ({ page }) => { const requests = await install(page); await page.setViewportSize({ width: 1200, height: 900 }); @@ -811,7 +909,13 @@ test('enters general and nation command arguments and sends exact values', async const chiefForm = chiefPicker.getByTestId('command-argument-form'); await chiefForm.getByRole('button', { name: '쌀' }).click(); await chiefForm.locator('input[type=number]').fill('300'); - await chiefForm.locator('select').selectOption('2'); + const chiefTarget = chiefForm.locator('select'); + await expect(chiefTarget.locator('option')).toHaveText([ + '장수 (아국 · 업)', + '관우 (아국 · 업)', + '여포NPC (아국 · 업)', + ]); + await chiefTarget.selectOption('3'); const geometry = await chiefForm.evaluate((element) => { const row = element.querySelector('.argument-row'); const rect = element.getBoundingClientRect(); @@ -829,7 +933,7 @@ test('enters general and nation command arguments and sends exact values', async expect(JSON.stringify(requests)).toContain('"destCityId":2'); expect(JSON.stringify(requests)).toContain('"isGold":false'); expect(JSON.stringify(requests)).toContain('"amount":300'); - expect(JSON.stringify(requests)).toContain('"destGeneralId":2'); + expect(JSON.stringify(requests)).toContain('"destGeneralId":3'); expect(mapGeometry.width).toBeGreaterThan(650); expect(mapGeometry.height / mapGeometry.width).toBeCloseTo(5 / 7, 2); diff --git a/app/game-frontend/src/components/command/ReservedCommandEditor.vue b/app/game-frontend/src/components/command/ReservedCommandEditor.vue index aa42c194..6b3c2fec 100644 --- a/app/game-frontend/src/components/command/ReservedCommandEditor.vue +++ b/app/game-frontend/src/components/command/ReservedCommandEditor.vue @@ -649,7 +649,9 @@ const clickOutsideMenu = (event: Event) => { class="command-picker" :class="{ 'recruitment-picker': isRecruitmentCommand }" data-testid="command-picker" - :style="isRecruitmentCommand || quickTarget === null || props.compact ? undefined : { top: quickPickerTop }" + :style=" + isRecruitmentCommand || quickTarget === null || props.compact ? undefined : { top: quickPickerTop } + " :role="isRecruitmentCommand ? 'dialog' : undefined" :aria-modal="isRecruitmentCommand ? 'true' : undefined" :aria-label=" @@ -1102,12 +1104,15 @@ const clickOutsideMenu = (event: Event) => { } .mobile.compact .editor-layout { - height: 360px; + min-height: 370px; display: grid; grid-template-columns: 109px 391px; + grid-template-rows: auto auto; } .mobile.compact .control-pad { order: initial; + grid-column: 1; + grid-row: 1 / -1; min-height: 0; padding: 0; grid-template-columns: 1fr; @@ -1115,6 +1120,8 @@ const clickOutsideMenu = (event: Event) => { } .mobile.compact .queue-area { order: initial; + grid-column: 2; + grid-row: 1; padding-top: 10px; } .mobile.compact .queue-grid { @@ -1145,9 +1152,8 @@ const clickOutsideMenu = (event: Event) => { overflow: visible; } .mobile.compact .advanced-actions { - right: 0; - bottom: 0; - left: 109px; + position: static; + grid-column: 2; + grid-row: 2; } - diff --git a/app/game-frontend/src/components/command/types.ts b/app/game-frontend/src/components/command/types.ts index 9705ad1d..57521b84 100644 --- a/app/game-frontend/src/components/command/types.ts +++ b/app/game-frontend/src/components/command/types.ts @@ -95,6 +95,7 @@ export type CommandTable = { cities: CommandOption[]; nations: CommandOption[]; generals: CommandOption[]; + generalTargets?: Record; crewTypes: CommandOption[]; armTypes: CommandOption[]; nationTypes: CommandOption[]; diff --git a/app/game-frontend/src/components/main/CommandArgumentForm.vue b/app/game-frontend/src/components/main/CommandArgumentForm.vue index 8a6c78f8..f2b0688f 100644 --- a/app/game-frontend/src/components/main/CommandArgumentForm.vue +++ b/app/game-frontend/src/components/main/CommandArgumentForm.vue @@ -33,6 +33,9 @@ const visibleFields = computed(() => props.fields.filter((entry) => entry.kind ! const optionsFor = (field: CommandInputField): CommandOption[] => { if (field.options) return field.options; if (!field.optionSource) return []; + if (field.optionSource === 'generals') { + return props.options.generalTargets?.[props.commandKey] ?? props.options.generals; + } if (field.optionSource === 'items') { return props.options.items[String(values.itemType ?? '')] ?? []; } diff --git a/app/game-frontend/src/views/ChiefCenterView.vue b/app/game-frontend/src/views/ChiefCenterView.vue index bd16e264..7d2e6680 100644 --- a/app/game-frontend/src/views/ChiefCenterView.vue +++ b/app/game-frontend/src/views/ChiefCenterView.vue @@ -352,22 +352,34 @@ const repeatTurns = async (amount: number) => {
- +
+
+ {{ idx }} +
+ +
+ {{ idx }} +
+
@@ -750,20 +762,25 @@ const repeatTurns = async (amount: number) => { .chief-overview-frame { width: 500px; height: 310px; - margin-top: -3px; + margin-top: 0; margin-bottom: 11px; overflow: hidden; } .chief-overview { - width: 445px; + width: 500px; height: 310px; margin-top: 0; + display: flex; + flex-direction: column; +} +.chief-overview-row { + width: 500px; + height: 155px; display: grid; - grid-template-columns: repeat(4, 111.25px); - grid-auto-rows: 155px; + grid-template-columns: 12px repeat(4, 119px) 12px; } .chief-overview :deep(.chief-card) { - width: 111.25px; + width: 119px; height: 155px; border: 0; border-left: 1px solid #fff; @@ -787,13 +804,34 @@ const repeatTurns = async (amount: number) => { box-sizing: border-box; height: 20px !important; min-height: 20px !important; - grid-template-rows: none; + grid-template-columns: 1fr; + grid-template-rows: 10px 10px; + line-height: 10px; +} +.chief-overview :deep(.compact-name), +.chief-overview :deep(.compact-meta) { + height: 10px; + line-height: 10px; } .chief-overview :deep(.row-time), .chief-overview :deep(.row-action) { display: grid; place-items: center; } +.overview-turn-index { + display: grid; + grid-template-rows: 20px repeat(12, 11.25px); + width: 12px; + height: 155px; + color: #fff; + font-size: 0.55rem; + line-height: 11.25px; + text-align: center; +} +.overview-turn-index span { + display: grid; + place-items: center; +} .mobile-readonly { width: 404px; height: 420px; @@ -879,7 +917,7 @@ const repeatTurns = async (amount: number) => { @media (max-width: 1024px) { .chief-overview { - grid-template-columns: repeat(4, 111.25px); + width: 500px; } } @@ -891,8 +929,8 @@ const repeatTurns = async (amount: number) => { .chief-grid-row > .empty-chief-slot { height: 384px; } -.chief-overview > .empty-chief-slot { - width: 111.25px; +.chief-overview-row > .empty-chief-slot { + width: 119px; height: 155px; } From 2fdc81369f380e4bff7bfe2ab469bf3d1ad6e393 Mon Sep 17 00:00:00 2001 From: hided62 Date: Mon, 17 Aug 2026 10:48:52 +0000 Subject: [PATCH 2/5] =?UTF-8?q?fix:=20=ED=86=A0=EB=84=88=EB=A8=BC=ED=8A=B8?= =?UTF-8?q?=20=EC=B0=B8=EA=B0=80=20=EC=A6=89=EC=8B=9C=20=EC=A1=B0=ED=8E=B8?= =?UTF-8?q?=EC=84=B1=EA=B3=BC=20=ED=99=94=EB=A9=B4=20=EC=97=AC=EB=B0=B1?= =?UTF-8?q?=EC=9D=84=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 수동 참가자를 비포화 예선 조에 즉시 배치하고 자동 참가 설정을 건드리던 잘못된 부작용을 제거한다. 자동 참가 장수와 NPC의 8x8 조 편입을 회귀 테스트로 고정한다. 장수 아이콘 아래 배당이 잘리지 않도록 대진 높이를 늘리고 갱신·참가·닫기 버튼의 클릭 영역을 확대한다. --- app/game-api/src/router/tournament/index.ts | 60 ++++-------- app/game-api/src/tournament/workerHelpers.ts | 54 +++++++++++ app/game-api/test/tournamentRouter.test.ts | 14 +++ app/game-api/test/tournamentWorker.test.ts | 54 ++++++++++- .../e2e/tournamentBracket.spec.ts | 94 ++++++++++++++++++- .../tournament/TournamentBracket.vue | 13 +-- app/game-frontend/src/views/BettingView.vue | 16 +++- .../src/views/TournamentView.vue | 16 +++- 8 files changed, 261 insertions(+), 60 deletions(-) diff --git a/app/game-api/src/router/tournament/index.ts b/app/game-api/src/router/tournament/index.ts index 7c8dba4c..181e2cfc 100644 --- a/app/game-api/src/router/tournament/index.ts +++ b/app/game-api/src/router/tournament/index.ts @@ -7,6 +7,7 @@ import type { TournamentState } from '../../tournament/types.js'; import { TournamentStore } from '../../tournament/store.js'; import { buildTournamentKeys } from '../../tournament/keys.js'; +import { assignManualApplicantGroup } from '../../tournament/workerHelpers.js'; import { accessAuthedProcedure, authedProcedure, router } from '../../trpc.js'; import { getMyGeneral } from '../shared/general.js'; import { loadCurrentGameTime } from '../../services/gameClock.js'; @@ -412,52 +413,31 @@ export const tournamentRouter = router({ }); } - const settingResult = await ctx.turnDaemon.requestCommand({ - type: 'setMySetting', - generalId: general.id, - settings: { tnmt: 1 }, + const meta = asRecord(general.meta); + const level = typeof meta.explevel === 'number' ? meta.explevel : 0; + const applicant = assignManualApplicantGroup({ + state, + baseSeed: String(asRecord(worldState?.meta).hiddenSeed ?? 'tournament'), + current: participants, + applicant: { + id: general.id, + name: general.name, + leadership: general.leadership, + strength: general.strength, + intel: general.intel, + level, + }, }); - if (!settingResult || settingResult.type !== 'setMySetting' || !settingResult.ok) { + const next = participants.concat(applicant); + + try { + await store.setParticipants(next); + } catch (error) { await ctx.turnDaemon.requestCommand({ type: 'adjustGeneralResources', reason: 'tournamentJoinRollback', adjustments: [{ generalId: general.id, goldDelta: develCost }], }); - throw new TRPCError({ - code: 'BAD_REQUEST', - message: - settingResult && settingResult.type === 'setMySetting' - ? (settingResult.reason ?? '요청에 실패했습니다.') - : 'Unexpected response', - }); - } - - const meta = asRecord(general.meta); - const level = typeof meta.explevel === 'number' ? meta.explevel : 0; - const next = participants.concat({ - id: general.id, - name: general.name, - leadership: general.leadership, - strength: general.strength, - intel: general.intel, - level, - }); - - try { - await store.setParticipants(next); - } catch (error) { - await Promise.all([ - ctx.turnDaemon.requestCommand({ - type: 'adjustGeneralResources', - reason: 'tournamentJoinRollback', - adjustments: [{ generalId: general.id, goldDelta: develCost }], - }), - ctx.turnDaemon.requestCommand({ - type: 'setMySetting', - generalId: general.id, - settings: { tnmt: 0 }, - }), - ]); throw error; } return { ok: true, count: next.length }; diff --git a/app/game-api/src/tournament/workerHelpers.ts b/app/game-api/src/tournament/workerHelpers.ts index ecac5fa7..a10400cc 100644 --- a/app/game-api/src/tournament/workerHelpers.ts +++ b/app/game-api/src/tournament/workerHelpers.ts @@ -155,6 +155,60 @@ export const assignGroupSlots = ( }); }; +/** + * Ref assigns a manual applicant to one uniformly selected non-full preliminary + * group as part of the join request. Keeping that assignment in the persisted + * participant projection lets the applicant see the group immediately while + * the later participant-fill pass can still balance automatic applicants. + */ +export const assignManualApplicantGroup = (options: { + state: TournamentState; + baseSeed: string; + current: TournamentParticipantEntry[]; + applicant: TournamentParticipantEntry; + groupCount?: number; + groupSize?: number; +}): TournamentParticipantEntry => { + const groupCount = options.groupCount ?? 8; + const groupSize = options.groupSize ?? 8; + const groupCounts = Array.from({ length: groupCount }, () => 0); + + for (const participant of options.current) { + const groupId = participant.groupId; + if (groupId !== undefined && groupId >= 0 && groupId < groupCount) { + groupCounts[groupId] = (groupCounts[groupId] ?? 0) + 1; + } + } + + const openGroupIds = groupCounts.flatMap((count, groupId) => (count < groupSize ? [groupId] : [])); + if (openGroupIds.length === 0) { + throw new Error('참가 인원이 가득 찼습니다.'); + } + + const rng = createTournamentRng(options.baseSeed, { + openYear: options.state.openYear, + openMonth: options.state.openMonth, + stage: 1, + phase: options.state.phase, + matchIndex: options.applicant.id, + participantIndex: options.current.length, + extraSeed: `manual-group:${options.current.map((entry) => entry.id).join('-')}:${openGroupIds.join('-')}`, + }); + const groupId = rng.choice(openGroupIds); + + return { + ...options.applicant, + groupId, + groupNo: groupCounts[groupId] ?? 0, + win: 0, + draw: 0, + lose: 0, + gl: 0, + seedRank: 0, + finalRank: 0, + }; +}; + const selectWeighted = (rng: ReturnType, pool: Array<{ item: T; weight: number }>): T => rng.choiceUsingWeightPair(pool.map((entry) => [entry.item, entry.weight])); diff --git a/app/game-api/test/tournamentRouter.test.ts b/app/game-api/test/tournamentRouter.test.ts index 1e84de66..3b7a8148 100644 --- a/app/game-api/test/tournamentRouter.test.ts +++ b/app/game-api/test/tournamentRouter.test.ts @@ -204,6 +204,20 @@ describe('tournament router permissions and mutations', () => { expect(transport.gold.get(general.id)).toBe(1_800); expect(transport.commands.filter((command) => command.type === 'adjustGeneralResources')).toHaveLength(1); + expect(transport.commands.filter((command) => command.type === 'setMySetting')).toHaveLength(0); + const snapshot = await caller.tournament.getSnapshot(); + expect(snapshot.participants).toHaveLength(1); + expect(snapshot.participants[0]).toMatchObject({ + id: general.id, + groupId: expect.any(Number), + groupNo: 0, + win: 0, + draw: 0, + lose: 0, + gl: 0, + }); + expect(snapshot.participants[0]!.groupId).toBeGreaterThanOrEqual(0); + expect(snapshot.participants[0]!.groupId).toBeLessThan(8); }); it('serializes concurrent bets and enforces the legacy per-user 1000 limit', async () => { diff --git a/app/game-api/test/tournamentWorker.test.ts b/app/game-api/test/tournamentWorker.test.ts index 052ac944..a0ce15b5 100644 --- a/app/game-api/test/tournamentWorker.test.ts +++ b/app/game-api/test/tournamentWorker.test.ts @@ -12,7 +12,12 @@ import type { TournamentState, } from '../src/tournament/types.js'; import { applyBattle, applyPreBattleStage, settleTournamentOutcome } from '../src/tournament/worker.js'; -import { buildBettingPayouts, resolveBettingCloseAt, resolveNextAt } from '../src/tournament/workerHelpers.js'; +import { + assignManualApplicantGroup, + buildBettingPayouts, + resolveBettingCloseAt, + resolveNextAt, +} from '../src/tournament/workerHelpers.js'; import type { TurnDaemonTransport } from '../src/daemon/transport.js'; class MemoryRedis { @@ -226,6 +231,45 @@ const runTournamentToCompletion = async (options: { const delayTick = async (): Promise => new Promise((resolve) => setTimeout(resolve, 0)); describe('tournament worker schedule compatibility', () => { + it('수동 참가자를 즉시 남은 예선 조의 다음 슬롯에 배치한다', () => { + const current = Array.from({ length: 63 }, (_, index): TournamentParticipantEntry => { + const groupId = index < 47 ? index % 8 : (index + 1) % 8; + const groupNo = Math.floor(index / 8); + return { + id: index + 1, + name: `참가자${index + 1}`, + leadership: 70, + strength: 70, + intel: 70, + level: 10, + groupId, + groupNo, + }; + }); + const groupCounts = Array.from({ length: 8 }, (_, groupId) => + current.filter((entry) => entry.groupId === groupId).length + ); + const openGroupId = groupCounts.findIndex((count) => count === 7); + expect(openGroupId).toBeGreaterThanOrEqual(0); + expect(groupCounts.filter((count) => count === 7)).toHaveLength(1); + + const applicant = assignManualApplicantGroup({ + state: createTournamentState(), + baseSeed: 'manual-join-seed', + current, + applicant: { + id: 100, + name: '즉시배치', + leadership: 80, + strength: 81, + intel: 82, + level: 20, + }, + }); + + expect(applicant).toMatchObject({ groupId: openGroupId, groupNo: 7, win: 0, draw: 0, lose: 0, gl: 0 }); + }); + it('catches up from the stored schedule instead of discarding elapsed legacy phases', () => { const state = createTournamentState({ termSeconds: 600, @@ -600,6 +644,14 @@ describe('tournament worker (in-memory)', () => { expect(participants.some((entry) => entry.id === 99)).toBe(false); expect(participants.some((entry) => entry.id === 1001)).toBe(true); expect(participants.some((entry) => entry.id < 0)).toBe(true); + expect(participants.every((entry) => entry.groupId !== undefined && entry.groupNo !== undefined)).toBe(true); + expect(participants.find((entry) => entry.id === 1)).toMatchObject({ groupId: expect.any(Number) }); + expect(participants.find((entry) => entry.id === 1001)).toMatchObject({ groupId: expect.any(Number) }); + expect( + Array.from({ length: 8 }, (_, groupId) => + participants.filter((entry) => entry.groupId === groupId).length + ) + ).toEqual(Array.from({ length: 8 }, () => 8)); await store.setState(afterJoin); const finalState = await runTournamentToCompletion({ store, prisma, baseSeed: 'seed' }); diff --git a/app/game-frontend/e2e/tournamentBracket.spec.ts b/app/game-frontend/e2e/tournamentBracket.spec.ts index d0358b0a..3a9e7452 100644 --- a/app/game-frontend/e2e/tournamentBracket.spec.ts +++ b/app/game-frontend/e2e/tournamentBracket.spec.ts @@ -118,7 +118,8 @@ const persistScreenshot = async (page: Page, name: string, fallbackPath: string) await page.screenshot({ path: resolve(responsiveArtifactDir, `${name}.webp`), fullPage: true }); }; -const installFixture = async (page: Page) => { +const installFixture = async (page: Page, options: { applicationOpen?: boolean } = {}) => { + let joined = false; await page.addInitScript((profile) => { window.localStorage.setItem('sammo-game-token', 'ga_tournament_bracket_playwright'); window.localStorage.setItem('sammo-game-profile', profile); @@ -141,7 +142,7 @@ const installFixture = async (page: Page) => { if (operation === 'tournament.getSnapshot') { return response({ state: { - stage: 0, + stage: options.applicationOpen ? 1 : 0, phase: 0, type: 0, auto: false, @@ -151,11 +152,32 @@ const installFixture = async (page: Page) => { nextAt: '2026-08-02T00:00:00.000Z', winnerId: 1, }, - participants, + participants: + options.applicationOpen && !joined + ? [] + : options.applicationOpen + ? [ + { + ...participants[0], + groupId: 0, + groupNo: 0, + win: 0, + draw: 0, + lose: 0, + gl: 0, + seedRank: 0, + finalRank: 0, + }, + ] + : participants, matches, betCount: 16, }); } + if (operation === 'tournament.join') { + joined = true; + return response({ ok: true, count: 1 }); + } if (operation === 'tournament.getBettingSummary') { return response({ totals: Object.fromEntries( @@ -245,9 +267,67 @@ test('desktop bracket connects every real general slot to the next round', async expect(geometry.horizontalIdentities).toBe(true); expect(Math.abs(geometry.firstParentY - geometry.firstPairAverageY)).toBeLessThan(1); + const controls = await page.locator('#tournament-container').evaluate((container) => { + const bounds = (selector: string) => container.querySelector(selector)!.getBoundingClientRect(); + const refresh = bounds('.toolbar button:first-child'); + const join = bounds('.join-button'); + const close = bounds('.close-button'); + return { + refresh: { width: refresh.width, height: refresh.height }, + join: { width: join.width, height: join.height }, + close: { width: close.width, height: close.height }, + }; + }); + expect(controls.refresh).toEqual({ width: 72, height: 44 }); + expect(controls.join).toEqual({ width: 72, height: 44 }); + expect(controls.close).toEqual({ width: 88, height: 44 }); + + const firstSlot = page.locator('.desktop-bracket-name').first(); + const oddsContainment = await firstSlot.evaluate((slot) => { + const card = slot.getBoundingClientRect(); + const odds = slot.querySelector('.bracket-odds')!.getBoundingClientRect(); + return { + cardTop: card.top, + cardBottom: card.bottom, + oddsTop: odds.top, + oddsBottom: odds.bottom, + cardHeight: card.height, + }; + }); + expect(oddsContainment.cardHeight).toBeGreaterThanOrEqual(82); + expect(oddsContainment.oddsTop).toBeGreaterThanOrEqual(oddsContainment.cardTop); + expect(oddsContainment.oddsBottom).toBeLessThanOrEqual(oddsContainment.cardBottom); + await persistScreenshot(page, 'tournament-desktop', testInfo.outputPath('tournament-bracket-desktop.webp')); }); +test('join refresh shows the assigned preliminary group immediately with accessible controls', async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }); + await installFixture(page, { applicationOpen: true }); + await page.goto('tournament'); + + const refresh = page.getByRole('button', { name: '갱신' }); + const join = page.getByRole('button', { name: '참가' }); + const close = page.getByRole('button', { name: '창 닫기' }).first(); + await expect(join).toBeEnabled(); + await join.click(); + + await expect(page.getByRole('status')).toHaveText('참가 신청이 반영되었습니다.'); + await expect(join).toBeDisabled(); + await expect(page.locator('.preliminary-grid .general-identity', { hasText: names[0] })).toBeVisible(); + + for (const control of [refresh, join, close]) { + const box = await control.boundingBox(); + expect(box?.height).toBe(44); + expect(box?.width).toBeGreaterThanOrEqual(72); + } + await refresh.focus(); + await expect(refresh).toBeFocused(); + await refresh.hover(); + await expect(refresh).toHaveCSS('filter', 'brightness(1.25)'); + expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(390); +}); + test('mobile bracket exposes every round through tabs with standard horizontal identities', async ({ page, }, testInfo) => { @@ -325,6 +405,14 @@ test('mobile bracket exposes every round through tabs with standard horizontal i expect(identity.nameLeft).toBeGreaterThanOrEqual(identity.iconRight - 1); expect(identity.nameTop).toBeLessThan(identity.iconBottom); expect(identity.nameBottom).toBeGreaterThan(identity.iconTop); + const firstMobileSlot = bracket.locator('.mobile-bracket-name').first(); + const mobileOddsContainment = await firstMobileSlot.evaluate((slot) => { + const card = slot.getBoundingClientRect(); + const odds = slot.querySelector('.bracket-odds')!.getBoundingClientRect(); + return { cardBottom: card.bottom, oddsBottom: odds.bottom, cardHeight: card.height }; + }); + expect(mobileOddsContainment.cardHeight).toBeGreaterThanOrEqual(82); + expect(mobileOddsContainment.oddsBottom).toBeLessThanOrEqual(mobileOddsContainment.cardBottom); await expect(page.getByRole('tablist', { name: '본선 조 선택' })).toBeVisible(); await page.getByRole('tab', { name: '二조' }).first().click(); await expect(page.getByRole('tab', { name: '二조' }).first()).toHaveAttribute('aria-selected', 'true'); diff --git a/app/game-frontend/src/components/tournament/TournamentBracket.vue b/app/game-frontend/src/components/tournament/TournamentBracket.vue index eb1da364..6606b178 100644 --- a/app/game-frontend/src/components/tournament/TournamentBracket.vue +++ b/app/game-frontend/src/components/tournament/TournamentBracket.vue @@ -28,8 +28,10 @@ const roundColumns = computed(() => [ ]); const desktopX = [110, 355, 600, 845, 1090]; const cardWidth = 190; +const desktopSlotHeight = 88; +const desktopCanvasHeight = desktopSlotHeight * 16; const slotY = (columnIndex: number, slotIndex: number) => { - const slotHeight = 72 * 2 ** columnIndex; + const slotHeight = desktopSlotHeight * 2 ** columnIndex; return slotHeight / 2 + slotIndex * slotHeight; }; const connections = computed(() => @@ -77,8 +79,8 @@ const mobilePairs = computed(() => { -
-
+
+