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 43ec023c..77e9863a 100644 --- a/app/game-api/src/turns/reservedTurns.ts +++ b/app/game-api/src/turns/reservedTurns.ts @@ -165,20 +165,22 @@ const loadGeneralTurns = async (db: DatabaseClient, generalId: number): Promise< return buildTurnListFromRows(rows, MAX_GENERAL_TURNS); }; +const loadGeneralAutorunLimit = async (db: DatabaseClient, generalId: number): Promise => { + const general = await db.general.findUnique({ where: { id: generalId }, select: { meta: true } }); + const rawAutorunLimit = isRecord(general?.meta) ? general.meta.autorun_limit : undefined; + return typeof rawAutorunLimit === 'number' && Number.isFinite(rawAutorunLimit) ? Math.trunc(rawAutorunLimit) : null; +}; + export const getGeneralTurnSnapshot = async (db: DatabaseClient, generalId: number): Promise => { - const [turns, revisionRow, general] = await Promise.all([ + const [turns, revisionRow, autorunLimit] = await Promise.all([ loadGeneralTurns(db, generalId), db.generalTurnRevision.findUnique({ where: { generalId } }), - db.general.findUnique({ where: { id: generalId }, select: { meta: true } }), + loadGeneralAutorunLimit(db, generalId), ]); - const rawAutorunLimit = isRecord(general?.meta) ? general.meta.autorun_limit : undefined; return { revision: revisionRow?.revision ?? 0, turns: serializeTurnList(turns), - autorunLimit: - typeof rawAutorunLimit === 'number' && Number.isFinite(rawAutorunLimit) - ? Math.trunc(rawAutorunLimit) - : null, + autorunLimit, }; }; @@ -331,7 +333,7 @@ export const setGeneralTurns = async ( } } await persistGeneralTurns(db, generalId, turns); - return { revision, turns: serializeTurnList(turns) }; + return { revision, turns: serializeTurnList(turns), autorunLimit: await loadGeneralAutorunLimit(db, generalId) }; }; export const setGeneralTurn = async ( @@ -357,7 +359,7 @@ export const shiftGeneralTurns = async ( const turns = await loadGeneralTurns(db, generalId); const shifted = applyShift(turns, amount); await persistGeneralTurns(db, generalId, shifted); - return { revision, turns: serializeTurnList(shifted) }; + return { revision, turns: serializeTurnList(shifted), autorunLimit: await loadGeneralAutorunLimit(db, generalId) }; }; export const repeatGeneralTurns = async ( @@ -372,7 +374,7 @@ export const repeatGeneralTurns = async ( const revision = await claimGeneralRevision(db, generalId, expectedRevision); const turns = applyRepeat(await loadGeneralTurns(db, generalId), amount); await persistGeneralTurns(db, generalId, turns); - return { revision, turns: serializeTurnList(turns) }; + return { revision, turns: serializeTurnList(turns), autorunLimit: await loadGeneralAutorunLimit(db, generalId) }; }; export const setNationTurns = async ( @@ -396,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, @@ -407,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 cce2678a..1ca8cf02 100644 --- a/app/game-api/test/reservedTurns.test.ts +++ b/app/game-api/test/reservedTurns.test.ts @@ -10,13 +10,15 @@ import { setGeneralTurn, setGeneralTurns, setNationTurn, + setNationTurnAtCurrentPosition, setNationTurns, + setNationTurnsAtCurrentPositions, shiftGeneralTurns, shiftNationTurns, ReservedTurnRevisionConflictError, } from '../src/turns/reservedTurns.js'; -const buildDb = () => { +const buildDb = (autorunLimit: number | null = null) => { const generalTurns = new Map(); const nationTurns = new Map(); const generalRevisions = new Map(); @@ -45,7 +47,7 @@ const buildDb = () => { findFirst: async () => null, }, general: { - findUnique: async () => null, + findUnique: async () => ({ meta: autorunLimit === null ? {} : { autorun_limit: autorunLimit } }), }, city: { findUnique: async () => null, @@ -195,27 +197,30 @@ const buildDb = () => { }, } as unknown as DatabaseClient; - return { db }; + return { db, nationTurns, nationRevisions }; }; describe('reservedTurns', () => { it('sets and shifts general turns', async () => { - const { db } = buildDb(); + const { db } = buildDb(2408); const initial = await setGeneralTurn(db, 1, 0, 'che_화계', { destCityId: 10 }, 0); expect(initial.revision).toBe(1); expect(initial.turns).toHaveLength(MAX_GENERAL_TURNS); expect(initial.turns[0]?.action).toBe('che_화계'); + expect(initial.autorunLimit).toBe(2408); const pushed = await shiftGeneralTurns(db, 1, 1, initial.revision); expect(pushed.revision).toBe(2); expect(pushed.turns[0]?.action).toBe('휴식'); expect(pushed.turns[1]?.action).toBe('che_화계'); + expect(pushed.autorunLimit).toBe(2408); const pulled = await shiftGeneralTurns(db, 1, -1, pushed.revision); expect(pulled.turns[0]?.action).toBe('che_화계'); expect(pulled.turns[MAX_GENERAL_TURNS - 1]?.action).toBe('휴식'); + expect(pulled.autorunLimit).toBe(2408); await expect(setGeneralTurn(db, 1, 2, 'che_훈련', {}, 1)).rejects.toBeInstanceOf( ReservedTurnRevisionConflictError @@ -285,7 +290,7 @@ describe('reservedTurns', () => { }); it('repeats the leading general pattern at the legacy interval', async () => { - const { db } = buildDb(); + const { db } = buildDb(2408); const seeded = await setGeneralTurns( db, 4, @@ -309,6 +314,7 @@ describe('reservedTurns', () => { 'che_사기진작', 'che_징병', ]); + expect(repeated.autorunLimit).toBe(2408); }); it('supports nation bulk/repeat and preserves the legacy amount-12 no-op', async () => { @@ -339,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 () => ({})); @@ -367,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 f99f2ab4..7de59fe7 100644 --- a/app/game-api/test/router.test.ts +++ b/app/game-api/test/router.test.ts @@ -869,7 +869,7 @@ describe('appRouter', () => { }); it('validates and persists general command arguments from the authenticated owner', async () => { - const general = buildGeneralRow({ id: 13 }); + const general = buildGeneralRow({ id: 13, meta: { autorun_limit: 2408 } }); const writes: unknown[] = []; const changeJournal = new ChangeJournal(); const caller = appRouter.createCaller( @@ -885,6 +885,7 @@ describe('appRouter', () => { }); expect(response.turns[0]).toMatchObject({ action: 'che_화계', args: { destCityId: 7 } }); + expect(response.autorunLimit).toBe(2408); expect(writes).toHaveLength(1); const written = writes[0] as { data: unknown[] }; expect(written.data).toHaveLength(30); @@ -1020,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 c2dc3319..be6a88dc 100644 --- a/app/game-frontend/e2e/commandArguments.spec.ts +++ b/app/game-frontend/e2e/commandArguments.spec.ts @@ -43,6 +43,7 @@ const inputOptions = { cities: [ { value: 1, label: '업 (아국)' }, { value: 2, label: '허창 (적국)', description: '적국 · 예주 · 대도시' }, + { value: 3, label: '단양 (오)' }, ], nations: [ { value: 1, label: '아국', color: '#008000' }, @@ -52,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: '금 수입 증가, 쌀 수입 감소' }], @@ -182,6 +195,27 @@ const commandTable = { }, ], }, + { + category: '군사', + values: [ + { + key: 'che_출병', + name: '출병', + reqArg: true, + possible: true, + status: 'needsInput', + inputFields: [ + { + key: 'destCityId', + label: '대상 도시', + kind: 'select', + required: true, + optionSource: 'cities', + }, + ], + }, + ], + }, ], nation: [ { @@ -312,6 +346,7 @@ const generalContext = { experience: 0, dedication: 0, items: { horse: 'None', weapon: 'None', book: 'None', item: 'None' }, + turnTime: '2026-08-17T12:00:00.000Z', }, city: { id: 1, @@ -366,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), @@ -483,7 +518,7 @@ const install = async (page: Page, rejectGeneral = false, commandTableResponse: if (name === 'turns.getCommandTable') return response(commandTableResponse); if (name === 'nation.getChiefCenter') return response(chiefCenter); if (name === 'turns.reserved.getGeneral') - return response({ turns: generalTurns, revision: generalRevision }); + return response({ turns: generalTurns, revision: generalRevision, autorunLimit: 2403 }); if (name === 'turns.reserved.getNation') return response({ turns: nationTurns, revision: nationRevision }); if (name === 'general.getRecentRecords') return response({ global: [], general: [], history: [] }); if (name === 'general.getFrontStatus') @@ -520,7 +555,7 @@ const install = async (page: Page, rejectGeneral = false, commandTableResponse: generalTurns[index] = { index, action: entry.action, args: entry.args ?? {} }; } generalRevision += 1; - return response({ ok: true, revision: generalRevision, turns: generalTurns }); + return response({ ok: true, revision: generalRevision, turns: generalTurns, autorunLimit: 2403 }); } if (name === 'turns.reserved.setNationBulk') { requests.push(body); @@ -561,7 +596,7 @@ test('renders and accepts every Ref strategy command at mobile width', async ({ await button.click(); const form = picker.getByTestId('command-argument-form'); await expect(form.getByTestId('command-argument-guidance')).toContainText(strategy.guidance); - await expect(form.locator('select option')).toHaveCount(2); + await expect(form.locator('select option')).toHaveCount(3); await picker.getByRole('button', { name: '명령 다시 선택', exact: true }).click(); } await picker.screenshot({ path: test.info().outputPath('all-strategy-commands-mobile.png') }); @@ -709,7 +744,7 @@ test('reserves force move, retirement, and resignation from the user command pic await expect(forceMoveForm.getByTestId('command-argument-guidance')).toContainText('선택한 도시로 강행합니다.'); await forceMoveForm.locator('select').selectOption('2'); await picker.getByRole('button', { name: '입력', exact: true }).click(); - await expect(editor.locator('.action-column > div').nth(2)).toHaveText('강행'); + await expect(editor.locator('.action-column > div').nth(2)).toHaveText('【허창】으로 강행'); const serialized = JSON.stringify(requests); expect(serialized).toContain('"action":"che_은퇴","args":{}'); @@ -778,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 }); @@ -856,7 +977,9 @@ test('enters general and nation command arguments and sends exact values', async return { width: rect.width, height: rect.height }; }); await page.getByTestId('command-picker').getByRole('button', { name: '입력', exact: true }).click(); - await expect(page.locator('[data-command-scope="general"] .action-column > div').first()).toHaveText('화계'); + await expect(page.locator('[data-command-scope="general"] .action-column > div').first()).toHaveText( + '【허창】에 화계실행' + ); await page.goto('/che/chief-center'); await page.getByRole('button', { name: '1턴 명령 입력', exact: true }).click(); @@ -866,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(); @@ -879,12 +1008,14 @@ 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('포상'); + await expect(page.locator('[data-command-scope="nation"] .action-column > div').first()).toHaveText( + '【여포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); @@ -961,7 +1092,9 @@ test('uses a Ref-style full recruitment page without horizontal overflow on desk await infantry.getByRole('button', { name: '절반', exact: true }).click(); await page.screenshot({ path: testInfo.outputPath('recruitment-desktop.png') }); await picker.getByRole('button', { name: '입력', exact: true }).click(); - await expect(page.locator('[data-command-scope="general"] .action-column > div').first()).toHaveText('징병'); + await expect(page.locator('[data-command-scope="general"] .action-column > div').first()).toHaveText( + '【보병】 3500명 징병' + ); expect(JSON.stringify(requests)).toContain('"crewType":1100'); expect(JSON.stringify(requests)).toContain('"amount":3500'); @@ -1167,6 +1300,53 @@ test('keeps the entered command visible and reports a server validation error', await expect(page.getByTestId('command-argument-form').locator('select')).toHaveValue('2'); }); +test('keeps Ref command briefs and autonomous-action state after a turn mutation', async ({ page }) => { + await install(page); + await page.setViewportSize({ width: 1200, height: 900 }); + await page.goto('/'); + + const editor = page.locator('[data-command-scope="general"]'); + const status = editor.locator('[data-command-autorun-status]'); + const firstRow = editor.locator('.action-column > div').first(); + await expect(status).toHaveText(/자율 행동: 200年 3月 · \d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}까지/u); + await expect(firstRow).toContainText('휴식(자율 행동)'); + expect(await firstRow.evaluate((element) => getComputedStyle(element).color)).toBe('rgb(170, 255, 255)'); + + await page.getByRole('button', { name: '1턴 명령 입력', exact: true }).click(); + const picker = page.getByTestId('command-picker'); + await picker.getByRole('button', { name: '군사', exact: true }).click(); + await picker.getByRole('button', { name: '출병', exact: true }).click(); + await picker.getByTestId('command-argument-form').locator('select').selectOption('3'); + await picker.getByRole('button', { name: '입력', exact: true }).click(); + + await expect(firstRow).toHaveText('【단양】으로 출병'); + await expect(firstRow).toHaveAttribute('title', /자율 행동/u); + expect(await firstRow.evaluate((element) => getComputedStyle(element).color)).toBe('rgb(170, 255, 255)'); + await expect(status).toHaveText(/자율 행동: 200年 3月 · .*까지/u); + + const desktopGeometry = await editor.evaluate((element) => { + const statusElement = element.querySelector('[data-command-autorun-status]'); + const row = element.querySelector('.action-column > div'); + if (!statusElement || !row) throw new Error('autonomous command geometry is missing'); + return { + horizontalOverflow: element.scrollWidth - element.clientWidth, + statusWidth: statusElement.getBoundingClientRect().width, + editorWidth: element.getBoundingClientRect().width, + rowHeight: row.getBoundingClientRect().height, + }; + }); + expect(desktopGeometry.horizontalOverflow).toBeLessThanOrEqual(0); + expect(desktopGeometry.statusWidth).toBeLessThanOrEqual(desktopGeometry.editorWidth); + expect(desktopGeometry.rowHeight).toBeGreaterThanOrEqual(20); + await page.screenshot({ path: test.info().outputPath('command-brief-autorun-desktop-1200.png'), fullPage: true }); + + await page.setViewportSize({ width: 500, height: 900 }); + await expect(firstRow).toHaveText('【단양】으로 출병'); + await expect(status).toBeVisible(); + expect(await editor.evaluate((element) => element.scrollWidth - element.clientWidth)).toBeLessThanOrEqual(0); + await page.screenshot({ path: test.info().outputPath('command-brief-autorun-mobile-500.png'), fullPage: true }); +}); + test('uses drag selection, clipboard paste, and a stored template in advanced mode', async ({ page }) => { const requests = await install(page); await page.goto('/'); @@ -1194,7 +1374,7 @@ test('uses drag selection, clipboard paste, and a stored template in advanced mo await blockedFire.click(); await picker.getByTestId('command-argument-form').locator('select').selectOption('2'); await picker.getByRole('button', { name: '입력', exact: true }).click(); - await expect(editor.locator('.action-column > div').nth(2)).toHaveText('화계'); + await expect(editor.locator('.action-column > div').nth(2)).toHaveText('【허창】에 화계실행'); await drag(0, 2); await editor.locator('details.selected-menu > summary').click(); @@ -1205,7 +1385,7 @@ test('uses drag selection, clipboard paste, and a stored template in advanced mo await expect(editor.locator('.index-column > button.selected')).toHaveCount(15); await editor.locator('details.selected-menu > summary').click(); await editor.getByRole('button', { name: '붙여넣기', exact: true }).click(); - await expect(editor.locator('.action-column > div').nth(5)).toHaveText('화계'); + await expect(editor.locator('.action-column > div').nth(5)).toHaveText('【허창】에 화계실행'); await drag(0, 2); page.once('dialog', (dialog) => dialog.accept('화계 세트')); @@ -1284,7 +1464,9 @@ test('keeps the shared main and chief shell geometry and interaction states', as await chiefArgumentForm.locator('input[type=number]').fill('300'); await chiefArgumentForm.locator('select').selectOption('2'); await page.getByTestId('command-picker').getByRole('button', { name: '입력', exact: true }).click(); - await expect(page.locator('[data-command-scope="nation"] .action-column > div').first()).toHaveText('포상'); + await expect(page.locator('[data-command-scope="nation"] .action-column > div').first()).toHaveText( + '【관우】 쌀 300 포상' + ); expect(JSON.stringify(requests)).toContain('"action":"che_포상"'); expect(JSON.stringify(requests)).toContain('"destGeneralId":2'); const chiefDesktop = await page.locator('.chief-page').evaluate((element) => ({ 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/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 10e9a11d..9b43cd7b 100644 --- a/app/game-frontend/src/components/command/ReservedCommandEditor.vue +++ b/app/game-frontend/src/components/command/ReservedCommandEditor.vue @@ -21,6 +21,7 @@ import type { CommandTable, ReservedCommandRow, } from './types'; +import { formatReservedCommandBrief } from './reservedCommandBrief'; const props = withDefaults( defineProps<{ @@ -37,6 +38,7 @@ const props = withDefaults( currentTime?: string; mapData?: CommandMapData | null; mapLayout?: CommandMapLayout | null; + autonomousUntil?: string | null; }>(), { maxPushTurn: 6, @@ -47,6 +49,7 @@ const props = withDefaults( currentTime: '--:--:--', mapData: null, mapLayout: null, + autonomousUntil: null, } ); @@ -70,6 +73,7 @@ const commandArgsValid = ref(false); const expanded = ref(false); const menuRevision = ref(0); const pendingReservation = ref(null); +const editorElement = ref(null); const pickerElement = ref(null); const collapsedRowCount = 15; @@ -133,12 +137,16 @@ const labelMap = computed(() => { const displayRows = computed(() => props.rows.slice(0, expanded.value || props.compact ? props.rows.length : collapsedRowCount) ); -const quickPickerTop = computed(() => `${70 + (quickTarget.value ?? 0) * 34.4}px`); +const quickPickerTop = ref('38px'); const isRecruitmentCommand = computed( () => selectedCommand.value?.key === 'che_징병' || selectedCommand.value?.key === 'che_모병' ); const isRecruitmentOverlayOpen = computed(() => pickerOpen.value && isRecruitmentCommand.value); -const rowLabel = (row: ReservedCommandRow): string => row.label ?? labelMap.value.get(row.action) ?? row.action; +const rowLabel = (row: ReservedCommandRow): string => + formatReservedCommandBrief(props.scope, row.action, row.args, props.commandTable) || + row.label || + labelMap.value.get(row.action) || + row.action; const selectedIndices = () => normalizedSelection(selected.value, previousSelected.value, props.rows.length); const pattern = () => extractPattern(props.rows, selectedIndices()); const touchMenus = () => (menuRevision.value += 1); @@ -163,6 +171,19 @@ const finishDrag = (next: Set) => { }; const openPicker = (turnIndex?: number) => { + if (!props.compact && editorElement.value) { + const editorRect = editorElement.value.getBoundingClientRect(); + const anchor = + turnIndex === undefined + ? editorElement.value.querySelector('.control-pad') + : editorElement.value.querySelector(`[data-turn-index="${turnIndex}"]`); + if (anchor) { + const anchorRect = anchor.getBoundingClientRect(); + quickPickerTop.value = `${ + turnIndex === undefined ? anchorRect.bottom - editorRect.top : anchorRect.top - editorRect.top + 30 + }px`; + } + } quickTarget.value = turnIndex ?? null; pickerOpen.value = true; selectedCommand.value = null; @@ -281,7 +302,7 @@ const rearrange = (direction: 'pull' | 'push') => { const textCopy = async () => { const lines = selectedIndices().map((index) => { const row = props.rows[index]; - return `${index + 1}턴 ${row?.label ?? labelMap.value.get(row?.action ?? '') ?? row?.action ?? ''}`; + return `${index + 1}턴 ${row ? rowLabel(row) : ''}`; }); await navigator.clipboard.writeText(lines.join('\n')); releaseSelection(); @@ -310,6 +331,7 @@ const clickOutsideMenu = (event: Event) => {