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; }