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/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/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/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 7d216cdf..77e9863a 100644 --- a/app/game-api/src/turns/reservedTurns.ts +++ b/app/game-api/src/turns/reservedTurns.ts @@ -398,6 +398,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, @@ -409,6 +435,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 1534c2df..1ca8cf02 100644 --- a/app/game-api/test/reservedTurns.test.ts +++ b/app/game-api/test/reservedTurns.test.ts @@ -10,7 +10,9 @@ import { setGeneralTurn, setGeneralTurns, setNationTurn, + setNationTurnAtCurrentPosition, setNationTurns, + setNationTurnsAtCurrentPositions, shiftGeneralTurns, shiftNationTurns, ReservedTurnRevisionConflictError, @@ -195,7 +197,7 @@ const buildDb = (autorunLimit: number | null = null) => { }, } as unknown as DatabaseClient; - return { db }; + return { db, nationTurns, nationRevisions }; }; describe('reservedTurns', () => { @@ -343,6 +345,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 () => ({})); @@ -371,4 +428,35 @@ describe('reservedTurns', () => { expect(deleteMany).not.toHaveBeenCalled(); expect(createMany).not.toHaveBeenCalled(); }); + + it('does not rebase a current-position nation write while the daemon lease holds the same revision', async () => { + const deleteMany = vi.fn(async () => ({})); + const createMany = vi.fn(async () => ({})); + const db = { + nationTurnRevision: { + updateMany: vi.fn(async () => ({ count: 0 })), + createMany: vi.fn(async () => ({ count: 0 })), + findUnique: vi.fn(async () => ({ + nationId: 6, + officerLevel: 12, + revision: 4, + leaseOwner: 'daemon-1', + leaseExpiresAt: new Date(Date.now() + 60_000), + updatedAt: new Date(), + })), + }, + nationTurn: { + findMany: vi.fn(async () => []), + deleteMany, + createMany, + }, + } as unknown as DatabaseClient; + + await expect(setNationTurnAtCurrentPosition(db, 6, 12, 2, 'che_증축', {}, 4)).rejects.toMatchObject({ + expectedRevision: 4, + currentRevision: 4, + }); + expect(deleteMany).not.toHaveBeenCalled(); + expect(createMany).not.toHaveBeenCalled(); + }); }); diff --git a/app/game-api/test/router.test.ts b/app/game-api/test/router.test.ts index 5f92b9ff..7de59fe7 100644 --- a/app/game-api/test/router.test.ts +++ b/app/game-api/test/router.test.ts @@ -1021,7 +1021,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-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/commandArguments.spec.ts b/app/game-frontend/e2e/commandArguments.spec.ts index b217dd01..be6a88dc 100644 --- a/app/game-frontend/e2e/commandArguments.spec.ts +++ b/app/game-frontend/e2e/commandArguments.spec.ts @@ -53,6 +53,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: '도적', description: '금 수입 증가, 쌀 수입 감소' }], @@ -389,8 +401,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), @@ -801,6 +813,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 }); @@ -891,7 +989,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(); @@ -905,13 +1009,13 @@ test('enters general and nation command arguments and sends exact values', async }); await chiefPicker.getByRole('button', { name: '입력', exact: true }).click(); await expect(page.locator('[data-command-scope="nation"] .action-column > div').first()).toHaveText( - '【관우】 쌀 300 포상' + '【여포NPC】 쌀 300 포상' ); 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/e2e/inGameInfo.spec.ts b/app/game-frontend/e2e/inGameInfo.spec.ts index 31171634..958fefbe 100644 --- a/app/game-frontend/e2e/inGameInfo.spec.ts +++ b/app/game-frontend/e2e/inGameInfo.spec.ts @@ -875,6 +875,18 @@ test('current-city wraps dense general names and only shrinks reserved turns', a expect(pageStyle.fontFamily).toContain('Pretendard'); expect(pageStyle.fontSize).toBe('14px'); + const controlFonts = await page + .locator('.city-page') + .evaluate(() => + ['#citySelector', '.back-link'].map( + (selector) => getComputedStyle(document.querySelector(selector)!).fontFamily + ) + ); + expect(controlFonts).toHaveLength(2); + for (const fontFamily of controlFonts) { + expect(fontFamily).toContain('Pretendard'); + } + const names = page.locator('.general-names'); await expect(names).toHaveCSS('white-space', 'normal'); const nameLineCount = await names.locator('span').evaluateAll((elements) => { diff --git a/app/game-frontend/e2e/inGameMenus.spec.ts b/app/game-frontend/e2e/inGameMenus.spec.ts index a7c616b8..c2a6b351 100644 --- a/app/game-frontend/e2e/inGameMenus.spec.ts +++ b/app/game-frontend/e2e/inGameMenus.spec.ts @@ -791,6 +791,97 @@ test('메인 장수 동향과 개인 전투 기록은 모두 21px 행 간격을 await persistParityArtifact(page, 'core-main-personal-battle-log-inline-mobile', mobileGeometry); }); +test('메인 개인 기록의 공격·수비 시각은 Ref와 같은 90% 글자 크기로 표시한다', async ({ page }) => { + const state: FixtureState = { + permission: 'head', + myset: 3, + settingMutations: [], + accessPages: [], + recentRecords: { + global: [], + general: [ + { + id: 18703, + text: '●10월:천귀병으로 ⓝ염행의 보병을 수비합니다.', + createdAt: '2026-01-01T03:54:00.000Z', + }, + { + id: 18702, + text: '●10월:천귀병으로 ⓝ염행의 보병을 공격합니다.', + createdAt: '2026-01-01T03:55:00.000Z', + }, + { + id: 18701, + text: '●10월:이미 기록된 시각 <1>12:34', + createdAt: '2026-01-01T03:56:00.000Z', + }, + ], + history: [], + }, + }; + await install(page, state); + await page.setViewportSize({ width: 1200, height: 900 }); + await page.goto(''); + + const inspect = async (selector: string) => { + const lines = page.locator(selector); + await expect(lines).toHaveCount(3); + await expect(lines.nth(0)).toHaveText('●10월:천귀병으로 ⓝ염행의 보병을 수비합니다. 12:54'); + await expect(lines.nth(1)).toHaveText('●10월:천귀병으로 ⓝ염행의 보병을 공격합니다. 12:55'); + await expect(lines.nth(2)).toHaveText('●10월:이미 기록된 시각 12:34'); + + return lines.evaluateAll((elements) => + elements.map((element) => { + const spans = [...element.querySelectorAll('span')]; + const time = spans.find((span) => /^\d{2}:\d{2}$/u.test(span.textContent ?? '')); + const name = spans.find((span) => span.textContent === 'ⓝ염행'); + const action = spans.find((span) => span.textContent === '수비' || span.textContent === '공격'); + if (!time) throw new Error('개인 기록 시각 span을 찾지 못했습니다.'); + const rect = element.getBoundingClientRect(); + return { + text: element.textContent, + row: { + width: rect.width, + height: rect.height, + clientWidth: element.clientWidth, + scrollWidth: element.scrollWidth, + fontSize: getComputedStyle(element).fontSize, + lineHeight: getComputedStyle(element).lineHeight, + }, + time: { + fontSize: getComputedStyle(time).fontSize, + lineHeight: getComputedStyle(time).lineHeight, + }, + nameFontSize: name ? getComputedStyle(name).fontSize : null, + actionFontSize: action ? getComputedStyle(action).fontSize : null, + timeSpanCount: spans.filter((span) => /^\d{2}:\d{2}$/u.test(span.textContent ?? '')).length, + }; + }) + ); + }; + const assertFontContract = (measurements: Awaited>) => { + expect(measurements.map((entry) => entry.row.fontSize)).toEqual(['14px', '14px', '14px']); + expect(measurements.map((entry) => entry.row.lineHeight)).toEqual(['21px', '21px', '21px']); + expect(measurements.map((entry) => entry.row.height)).toEqual([21, 21, 21]); + expect(measurements.map((entry) => entry.time.fontSize)).toEqual(['12.6px', '12.6px', '12.6px']); + expect(measurements.map((entry) => entry.timeSpanCount)).toEqual([1, 1, 1]); + expect(measurements[0]?.nameFontSize).toBe('14px'); + expect(measurements[0]?.actionFontSize).toBe('14px'); + expect(measurements[1]?.nameFontSize).toBe('14px'); + expect(measurements[1]?.actionFontSize).toBe('14px'); + expect(measurements.every((entry) => entry.row.scrollWidth <= entry.row.clientWidth)).toBe(true); + }; + + const desktop = await inspect('.record-zone [data-record-bucket="general"] .record-line'); + assertFontContract(desktop); + await persistParityArtifact(page, 'core-main-personal-war-log-time-font-desktop', desktop); + + await page.setViewportSize({ width: 500, height: 900 }); + const mobile = await inspect('.record-zone-mobile [data-record-bucket="general"] .record-line'); + assertFontContract(mobile); + await persistParityArtifact(page, 'core-main-personal-war-log-time-font-mobile', mobile); +}); + test('접속량정보 keeps the legacy public 1016px chart geometry', async ({ page }) => { const state: FixtureState = { permission: 'member', myset: 0, settingMutations: [], accessPages: [] }; await install(page, state); 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/assets/main.css b/app/game-frontend/src/assets/main.css index 38e3b1c3..52aaefe7 100644 --- a/app/game-frontend/src/assets/main.css +++ b/app/game-frontend/src/assets/main.css @@ -1,3 +1,4 @@ +@import url('https://cdn.jsdelivr.net/gh/orioncactus/pretendard/dist/web/static/pretendard.css'); @import 'tailwindcss'; @import './styles/tokens.css'; @import './styles/legacy-controls.css'; diff --git a/app/game-frontend/src/components/command/ReservedCommandEditor.vue b/app/game-frontend/src/components/command/ReservedCommandEditor.vue index 52b42b6b..9b43cd7b 100644 --- a/app/game-frontend/src/components/command/ReservedCommandEditor.vue +++ b/app/game-frontend/src/components/command/ReservedCommandEditor.vue @@ -685,7 +685,9 @@ const clickOutsideMenu = (event: Event) => { class="command-picker" :class="{ 'recruitment-picker': isRecruitmentCommand }" data-testid="command-picker" - :style="isRecruitmentCommand || 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=" @@ -1146,12 +1148,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; @@ -1159,6 +1164,8 @@ const clickOutsideMenu = (event: Event) => { } .mobile.compact .queue-area { order: initial; + grid-column: 2; + grid-row: 1; padding-top: 10px; } .mobile.compact .queue-grid { @@ -1189,8 +1196,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/reservedCommandBrief.ts b/app/game-frontend/src/components/command/reservedCommandBrief.ts index c815f5e9..0e113877 100644 --- a/app/game-frontend/src/components/command/reservedCommandBrief.ts +++ b/app/game-frontend/src/components/command/reservedCommandBrief.ts @@ -75,7 +75,10 @@ export const formatReservedCommandBrief = ( const commandName = commandNames.get(action) ?? defaultCommandName(action); const cityName = optionLabel(input?.cities ?? [], firstValue(args, ['destCityId', 'destCityID'])); const nationName = optionLabel(input?.nations ?? [], firstValue(args, ['destNationId', 'destNationID'])); - const generalName = optionLabel(input?.generals ?? [], firstValue(args, ['destGeneralId', 'destGeneralID'])); + const generalName = optionLabel( + input?.generalTargets?.[action] ?? input?.generals ?? [], + firstValue(args, ['destGeneralId', 'destGeneralID']) + ); if (CITY_TO_COMMANDS.has(action) && cityName) { return `${wrappedWithParticle(cityName, '으로')} ${commandName}`; 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/components/main/RecentLogList.vue b/app/game-frontend/src/components/main/RecentLogList.vue index 85792bc6..14cad890 100644 --- a/app/game-frontend/src/components/main/RecentLogList.vue +++ b/app/game-frontend/src/components/main/RecentLogList.vue @@ -41,7 +41,7 @@ const formattedLogs = computed(() => .recent-log-list { min-width: 0; color: #fff; - font-family: 'Times New Roman', serif; + font-family: var(--sammo-font-sans); font-size: 14px; line-height: 1.35; } 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(() => { -
-
+
- +
+
+ {{ 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; } diff --git a/app/game-frontend/src/views/CurrentCityView.vue b/app/game-frontend/src/views/CurrentCityView.vue index e75b1d28..c8e92d0b 100644 --- a/app/game-frontend/src/views/CurrentCityView.vue +++ b/app/game-frontend/src/views/CurrentCityView.vue @@ -366,7 +366,7 @@ const generalImage = (general: General): string => resolveGeneralIconUrl(general border: 1px solid #767676; background: #6b6b6b; color: #fff; - font-family: Arial, sans-serif; + font-family: var(--sammo-font-sans); font-size: 13.3333px; } .selector { @@ -470,7 +470,7 @@ const generalImage = (general: General): string => resolveGeneralIconUrl(general background: #6c757d; color: #fff; padding: 0.25rem 0.5rem; - font-family: Arial, sans-serif; + font-family: var(--sammo-font-sans); font-size: 14px; line-height: 1; text-decoration: none; diff --git a/app/game-frontend/src/views/DynastyDetailView.vue b/app/game-frontend/src/views/DynastyDetailView.vue index 15107fd4..50acff83 100644 --- a/app/game-frontend/src/views/DynastyDetailView.vue +++ b/app/game-frontend/src/views/DynastyDetailView.vue @@ -305,7 +305,7 @@ onMounted(loadDetail); width: 1000px; margin: 8px auto 0; color: #fff; - font-family: 'Times New Roman', serif; + font-family: var(--sammo-font-sans); font-size: 16px; line-height: normal; } @@ -384,7 +384,7 @@ onMounted(loadDetail); padding: 1px 6px; background: buttonface; color: buttontext; - font-family: Arial; + font-family: var(--sammo-font-sans); font-size: 13.3333px; font-weight: 400; line-height: normal; diff --git a/app/game-frontend/src/views/DynastyListView.vue b/app/game-frontend/src/views/DynastyListView.vue index 767e7cf9..ff4464ef 100644 --- a/app/game-frontend/src/views/DynastyListView.vue +++ b/app/game-frontend/src/views/DynastyListView.vue @@ -231,7 +231,7 @@ onMounted(loadDynasty); padding: 1px 6px; background: buttonface; color: buttontext; - font-family: Arial; + font-family: var(--sammo-font-sans); font-size: 13.3333px; font-weight: 400; line-height: normal; diff --git a/app/game-frontend/src/views/InheritView.vue b/app/game-frontend/src/views/InheritView.vue index e22ae871..9684a5da 100644 --- a/app/game-frontend/src/views/InheritView.vue +++ b/app/game-frontend/src/views/InheritView.vue @@ -778,12 +778,7 @@ onMounted(() => { padding: 0 7px; color: #fff; height: 1597px; - font: - 14px/21px Pretendard, - 'Apple SD Gothic Neo', - 'Noto Sans KR', - 'Malgun Gothic', - sans-serif; + font: 14px/21px var(--sammo-font-sans); } .inherit-page.legacy-bg0 { diff --git a/app/game-frontend/src/views/MainView.vue b/app/game-frontend/src/views/MainView.vue index a1afda8d..1e4daab3 100644 --- a/app/game-frontend/src/views/MainView.vue +++ b/app/game-frontend/src/views/MainView.vue @@ -66,11 +66,12 @@ const nationAccess = computed(() => ({ })); const nationColor = computed(() => nation.value?.color ?? '#000000'); const voteActive = computed(() => Boolean(frontStatus.value?.latestVote)); +const recordTimeSuffixPattern = /\d{2}:\d{2}(?:<\/>)?\s*$/u; const formatRecord = (entry: { text: string; createdAt?: string | Date }, appendTime = false): string => { - if (!appendTime || /\d{2}:\d{2}\s*$/u.test(entry.text)) return formatLog(entry.text); + if (!appendTime || recordTimeSuffixPattern.test(entry.text)) return formatLog(entry.text); const time = formatServerDateTime(entry.createdAt, { format: 'hourMinute', fallback: '' }); if (!time) return formatLog(entry.text); - return formatLog(`${entry.text} ${time}`); + return formatLog(`${entry.text} <1>${time}`); }; let surveyNoticeTimer: ReturnType | null = null; diff --git a/app/game-frontend/src/views/NationBettingView.vue b/app/game-frontend/src/views/NationBettingView.vue index c9be8118..a9af935c 100644 --- a/app/game-frontend/src/views/NationBettingView.vue +++ b/app/game-frontend/src/views/NationBettingView.vue @@ -451,7 +451,7 @@ onMounted(() => { width: 500px; margin: 0 auto; color: #fff; - font-family: Pretendard, 'Apple SD Gothic Neo', 'Noto Sans KR', 'Malgun Gothic'; + font-family: var(--sammo-font-sans); font-size: 14px; line-height: 1.5; } diff --git a/app/game-frontend/src/views/NationPersonnelView.vue b/app/game-frontend/src/views/NationPersonnelView.vue index 9389d5a4..4fd68a0a 100644 --- a/app/game-frontend/src/views/NationPersonnelView.vue +++ b/app/game-frontend/src/views/NationPersonnelView.vue @@ -522,12 +522,7 @@ onMounted(() => void loadPersonnel()); min-height: 100vh; margin: 0 auto; color: #fff; - font: - 14px/1.3 Pretendard, - 'Apple SD Gothic Neo', - 'Noto Sans KR', - 'Malgun Gothic', - sans-serif; + font: 14px/1.3 var(--sammo-font-sans); } .legacy-table { width: 1000px; diff --git a/app/game-frontend/src/views/NationSecretView.vue b/app/game-frontend/src/views/NationSecretView.vue index 1c5d1f9d..081d0816 100644 --- a/app/game-frontend/src/views/NationSecretView.vue +++ b/app/game-frontend/src/views/NationSecretView.vue @@ -174,12 +174,7 @@ onMounted(load); .secret-page { width: auto; margin: 0; - font: - 14px Pretendard, - 'Apple SD Gothic Neo', - 'Noto Sans KR', - 'Malgun Gothic', - sans-serif; + font: 14px var(--sammo-font-sans); color: #fff; } .layout { diff --git a/app/game-frontend/src/views/NationStratFinanView.vue b/app/game-frontend/src/views/NationStratFinanView.vue index 4ea402e6..ba21659b 100644 --- a/app/game-frontend/src/views/NationStratFinanView.vue +++ b/app/game-frontend/src/views/NationStratFinanView.vue @@ -437,12 +437,7 @@ onMounted(() => void loadStratFinan()); margin: 0 auto; color: #fff; background: var(--sammo-texture-walnut); - font: - 14px/1.3 Pretendard, - 'Apple SD Gothic Neo', - 'Noto Sans KR', - 'Malgun Gothic', - sans-serif; + font: 14px/1.3 var(--sammo-font-sans); } .tiptap-compat-controls { display: none; diff --git a/app/game-frontend/src/views/NpcListView.vue b/app/game-frontend/src/views/NpcListView.vue index 1447ee92..0c884ed6 100644 --- a/app/game-frontend/src/views/NpcListView.vue +++ b/app/game-frontend/src/views/NpcListView.vue @@ -165,7 +165,7 @@ onMounted(() => { min-height: 100vh; margin: 0 auto; color: #fff; - font-family: Pretendard, 'Apple SD Gothic Neo', 'Noto Sans KR', 'Malgun Gothic'; + font-family: var(--sammo-font-sans); font-size: 14px; line-height: 1.3; } diff --git a/app/game-frontend/src/views/SelectGeneralView.vue b/app/game-frontend/src/views/SelectGeneralView.vue index f3fc7abb..ce654da9 100644 --- a/app/game-frontend/src/views/SelectGeneralView.vue +++ b/app/game-frontend/src/views/SelectGeneralView.vue @@ -515,8 +515,6 @@ onBeforeUnmount(() => { ` + + `
` + + `
개인 기록
${lines}
`, + { waitUntil: 'networkidle' } + ); + await page.evaluate(() => document.fonts.ready); + + for (const viewport of [ + { name: 'desktop', width: 1200, height: 900 }, + { name: 'mobile', width: 500, height: 900 }, + ]) { + await page.setViewportSize(viewport); + const measurement = await page.locator('.fixture-line').evaluateAll((elements) => + elements.map((element) => { + const spans = [...element.querySelectorAll('span')]; + const time = spans.find((span) => /^\d{2}:\d{2}$/u.test(span.textContent ?? '')); + const name = spans.find((span) => span.textContent === 'ⓝ염행'); + const action = spans.find((span) => span.textContent === '수비' || span.textContent === '공격'); + if ( + !(time instanceof HTMLElement) || + !(name instanceof HTMLElement) || + !(action instanceof HTMLElement) + ) { + throw new Error('Ref 개인 공격·수비 기록의 비교 span을 찾지 못했습니다.'); + } + const rect = element.getBoundingClientRect(); + return { + text: element.textContent, + row: { + width: rect.width, + height: rect.height, + fontSize: getComputedStyle(element).fontSize, + lineHeight: getComputedStyle(element).lineHeight, + }, + timeFontSize: getComputedStyle(time).fontSize, + nameFontSize: getComputedStyle(name).fontSize, + actionFontSize: getComputedStyle(action).fontSize, + }; + }) + ); + const output = { viewport, measurement }; + console.log(JSON.stringify(output)); + if (artifactDir) { + await mkdir(artifactDir, { recursive: true }); + await Promise.all([ + page.screenshot({ path: resolve(artifactDir, `ref-personal-war-log-font-${viewport.name}.png`) }), + writeFile( + resolve(artifactDir, `ref-personal-war-log-font-${viewport.name}.json`), + `${JSON.stringify(output, null, 2)}\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 fb571756..3f2c16e5 100644 --- a/tools/frontend-legacy-parity/visual-parity.spec.ts +++ b/tools/frontend-legacy-parity/visual-parity.spec.ts @@ -955,6 +955,63 @@ test.describe('yearbook legacy parity', () => { }); } + for (const viewport of [ + { name: 'desktop', width: 1365, height: 768, minimumWorldHeight: 128 }, + { name: 'mobile', width: 390, height: 844, minimumWorldHeight: 149 }, + ]) { + test(`grows record blocks with long content on ${viewport.name}`, async ({ page }) => { + const longWorldHistory = Array.from( + { length: 12 }, + (_, index) => `● 중원 정세 긴 기록 ${index + 1}번째 줄입니다.` + ); + const longGlobalAction = Array.from( + { length: 8 }, + (_, index) => `● 장수 동향 긴 기록 ${index + 1}번째 줄입니다.` + ); + await page.route('**/che/api/trpc/**', async (route) => { + if (!operationNames(route).includes('yearbook.getHistory')) { + await route.fallback(); + return; + } + await fulfillOperations(route, () => ({ + ...fixture.game.yearbook, + data: { + ...fixture.game.yearbook.data, + globalHistory: longWorldHistory, + globalAction: longGlobalAction, + }, + })); + }); + + await page.setViewportSize(viewport); + await page.goto(gameUrl('/yearbook')); + await expect(page.getByText('중원 정세 긴 기록 12번째 줄입니다.')).toBeVisible(); + await expect(page.getByText('장수 동향 긴 기록 8번째 줄입니다.')).toBeVisible(); + if (artifactRoot) { + await page.screenshot({ + path: resolve(artifactRoot, `yearbook-long-content-${viewport.name}.png`), + fullPage: true, + animations: 'disabled', + }); + } + + const geometry = await page.locator('.history-log').evaluateAll((blocks) => + blocks.map((block) => { + const element = block as HTMLElement; + return { + height: element.getBoundingClientRect().height, + clientHeight: element.clientHeight, + scrollHeight: element.scrollHeight, + }; + }) + ); + expect(geometry[0]?.height).toBeGreaterThan(viewport.minimumWorldHeight); + expect(geometry[1]?.height).toBeGreaterThan(65); + expect(geometry[0]?.clientHeight).toBe(geometry[0]?.scrollHeight); + expect(geometry[1]?.clientHeight).toBe(geometry[1]?.scrollHeight); + }); + } + test('shows the history API error while keeping month navigation available', async ({ page }) => { await page.route('**/che/api/trpc/**', async (route) => { if (operationNames(route).includes('yearbook.getHistory')) {