diff --git a/app/game-api/src/router/general/index.ts b/app/game-api/src/router/general/index.ts index 6b0984a9..baf15c48 100644 --- a/app/game-api/src/router/general/index.ts +++ b/app/game-api/src/router/general/index.ts @@ -333,6 +333,7 @@ export const getGeneralContext = async (ctx: GameApiContext) => { troopLeaderFirstTurn, accessLog, rankRows, + gameTime, ] = await Promise.all([ general.cityId > 0 ? ctx.db.city.findUnique({ @@ -413,6 +414,7 @@ export const getGeneralContext = async (ctx: GameApiContext) => { where: { generalId: general.id, type: { in: [...PERSONAL_RECORD_TYPES] } }, select: { type: true, value: true }, }), + loadCurrentGameTime(ctx.db), ]); const nation = queriedNation ?? NEUTRAL_NATION_CONTEXT; @@ -588,8 +590,7 @@ export const getGeneralContext = async (ctx: GameApiContext) => { killTurn: readNumber(metaRecord.killturn ?? metaRecord.killTurn, 0), remainingMinutes: resolveRemainingMinutes( general.turnTime, - parsedLastExecuted, - worldState?.tickSeconds ?? 0 + gameTime.now ), crewTypeId: general.crewTypeId, crewTypeName: crewTypeDetails.get(general.crewTypeId)?.name ?? '-', diff --git a/app/game-api/src/router/troop/index.ts b/app/game-api/src/router/troop/index.ts index aa91b68b..9340d3bc 100644 --- a/app/game-api/src/router/troop/index.ts +++ b/app/game-api/src/router/troop/index.ts @@ -23,6 +23,7 @@ import { resolveRefreshScoreText, resolveRemainingMinutes, } from '../../services/generalBasicCardProjection.js'; +import { loadCurrentGameTime } from '../../services/gameClock.js'; import { loadTraitNames } from '../nation/shared.js'; import { getAuthenticatedUserId, getMyGeneral } from '../shared/general.js'; import { throwIfCommandRejected } from '../shared/turnDaemon.js'; @@ -72,7 +73,7 @@ export const troopRouter = router({ throw new TRPCError({ code: 'PRECONDITION_FAILED', message: '국가에 소속되어 있지 않습니다.' }); } - const [nation, troops, generals, cities, worldState] = await Promise.all([ + const [nation, troops, generals, cities, worldState, gameTime] = await Promise.all([ ctx.db.nation.findUnique({ where: { id: me.nationId }, select: { id: true, name: true, color: true, level: true, meta: true }, @@ -121,6 +122,7 @@ export const troopRouter = router({ select: { id: true, name: true }, }), ctx.db.worldState.findFirst({ select: { tickSeconds: true, config: true, meta: true } }), + loadCurrentGameTime(ctx.db), ]); if (!nation) { throw new TRPCError({ code: 'NOT_FOUND', message: '국가 정보를 찾을 수 없습니다.' }); @@ -214,15 +216,6 @@ export const troopRouter = router({ const traitName = (code: string, names: Map): string => names.get(code)?.name ?? sanitizeInternalDisplayCode(code); const itemName = (code: string): string => itemNames.get(code) ?? sanitizeInternalDisplayCode(code); - const worldMeta = asRecord(worldState?.meta); - const rawLastExecuted = worldMeta.lastTurnTime ?? worldMeta.turntime; - const lastExecuted = - rawLastExecuted instanceof Date - ? rawLastExecuted - : typeof rawLastExecuted === 'string' - ? new Date(rawLastExecuted) - : null; - const mappedTroops = troops .map((troop) => { const leader = generalMap.get(troop.troopLeaderId); @@ -348,8 +341,7 @@ export const troopRouter = router({ killTurn: readNumber(meta.killturn ?? meta.killTurn), remainingMinutes: resolveRemainingMinutes( general.turnTime, - lastExecuted, - worldState?.tickSeconds ?? 0 + gameTime.now ), troopId: general.troopId, troop: { diff --git a/app/game-api/src/services/generalBasicCardProjection.ts b/app/game-api/src/services/generalBasicCardProjection.ts index 3f389efb..3a062835 100644 --- a/app/game-api/src/services/generalBasicCardProjection.ts +++ b/app/game-api/src/services/generalBasicCardProjection.ts @@ -45,15 +45,10 @@ export const resolveRefreshScoreText = (score: number): string => { export const resolveRemainingMinutes = ( turnTime: Date, - lastExecuted: Date | null, - turnTermSeconds: number + currentGameTime: Date | null ): number | null => { - if (!lastExecuted || !Number.isFinite(lastExecuted.getTime()) || turnTermSeconds <= 0) return null; - let nextTurnMillis = turnTime.getTime(); - if (nextTurnMillis < lastExecuted.getTime()) { - nextTurnMillis += turnTermSeconds * 1_000; - } - return Math.floor(Math.min(999, Math.max(0, (nextTurnMillis - lastExecuted.getTime()) / 60_000))); + if (!currentGameTime || !Number.isFinite(currentGameTime.getTime())) return null; + return Math.floor(Math.max(0, (turnTime.getTime() - currentGameTime.getTime()) / 60_000)); }; export interface NextTurnMonthOffsetInput { diff --git a/app/game-api/test/generalBasicCardProjection.test.ts b/app/game-api/test/generalBasicCardProjection.test.ts index 250661f4..67dc08d5 100644 --- a/app/game-api/test/generalBasicCardProjection.test.ts +++ b/app/game-api/test/generalBasicCardProjection.test.ts @@ -36,11 +36,12 @@ describe('general basic card Ref projection', () => { expect(resolveRefreshScoreText(12_800)).toBe('헐...'); }); - it('matches the Ref remaining-minute calculation and one-turn rollover', () => { - const lastExecuted = new Date('2026-08-13T00:00:00.000Z'); - expect(resolveRemainingMinutes(new Date('2026-08-13T00:07:06.000Z'), lastExecuted, 3_600)).toBe(7); - expect(resolveRemainingMinutes(new Date('2026-08-12T23:59:00.000Z'), lastExecuted, 3_600)).toBe(59); - expect(resolveRemainingMinutes(new Date('2026-08-13T00:07:06.000Z'), null, 3_600)).toBeNull(); + it('matches the Ref remaining-minute calculation against the current game clock', () => { + const currentGameTime = new Date('2026-08-13T00:20:00.000Z'); + expect(resolveRemainingMinutes(new Date('2026-08-13T01:20:00.000Z'), currentGameTime)).toBe(60); + expect(resolveRemainingMinutes(new Date('2026-08-13T01:08:59.000Z'), currentGameTime)).toBe(48); + expect(resolveRemainingMinutes(new Date('2026-08-13T00:19:00.000Z'), currentGameTime)).toBe(0); + expect(resolveRemainingMinutes(new Date('2026-08-13T00:20:00.000Z'), null)).toBeNull(); }); it('moves the first reserved month only after the general turn bucket has passed', () => { diff --git a/app/game-api/test/inGameMenuPermissions.test.ts b/app/game-api/test/inGameMenuPermissions.test.ts index d51d9a05..de714871 100644 --- a/app/game-api/test/inGameMenuPermissions.test.ts +++ b/app/game-api/test/inGameMenuPermissions.test.ts @@ -85,6 +85,7 @@ const createContext = (options: { troopLeaderAction?: string | null; refreshScore?: number; refreshScoreTotal?: number; + gameClockNow?: Date; rankRows?: Array<{ generalId?: number; type: string; value: number }>; requestId?: string; transaction?: ReturnType; @@ -164,6 +165,10 @@ const createContext = (options: { currentMonth: 1, tickSeconds: 600, config: { const: { upgradeLimit: 20 } }, + clockBaseTime: options.gameClockNow ?? null, + clockTick: options.gameClockNow ? 0n : null, + clockMode: 'manual', + clockWallAnchor: options.gameClockNow ?? null, })), }, logEntry: { @@ -485,6 +490,7 @@ describe('in-game my information ownership', () => { troopLeaderAction: '휴식', refreshScore: 3, refreshScoreTotal: 1_141, + gameClockNow: new Date('2026-01-01T00:00:00.000Z'), }); await expect(appRouter.createCaller(fixture.context).general.me()).resolves.toMatchObject({ @@ -495,7 +501,7 @@ describe('in-game my information ownership', () => { retirementYear: 70, defenceTrain: 80, killTurn: 6, - remainingMinutes: null, + remainingMinutes: 7, troop: { name: '정밀검증부대', status: 'inactive', leaderCityName: '업' }, refreshScore: { current: 3, total: 1_141, text: '열심' }, }, diff --git a/app/game-frontend/e2e/commandArguments.spec.ts b/app/game-frontend/e2e/commandArguments.spec.ts index 4058bb39..70dbcb07 100644 --- a/app/game-frontend/e2e/commandArguments.spec.ts +++ b/app/game-frontend/e2e/commandArguments.spec.ts @@ -946,7 +946,7 @@ const install = async ( return requests; }; -test('offers the Ref repeat range for general turns while keeping the chief range', async ({ page }) => { +test('offers 12 repeat turns and six shift turns for general turns while keeping the chief range', async ({ page }) => { await install(page); await page.setViewportSize({ width: 1200, height: 900 }); await page.goto('/'); @@ -957,6 +957,11 @@ test('offers the Ref repeat range for general turns while keeping the chief rang await expect(generalRepeat.locator('.menu-items > button')).toHaveText( Array.from({ length: 12 }, (_, index) => `${index + 1}턴`) ); + const generalPull = generalEditor.locator('.bottom-shift-menu').first(); + await generalPull.locator('summary').click(); + await expect(generalPull.locator('.menu-items > button')).toHaveText( + Array.from({ length: 6 }, (_, index) => `${index + 1}턴`) + ); await page.setViewportSize({ width: 500, height: 900 }); const mobileGeneralEditor = page.locator('[data-command-scope="general"]:visible'); diff --git a/app/game-frontend/e2e/mainNavigation.spec.ts b/app/game-frontend/e2e/mainNavigation.spec.ts index 0852890a..a266b5df 100644 --- a/app/game-frontend/e2e/mainNavigation.spec.ts +++ b/app/game-frontend/e2e/mainNavigation.spec.ts @@ -1840,7 +1840,11 @@ test('tournament split main action follows recruitment, betting, finals, and tou const main = nationMenu.locator('[data-navigation-id="tournament"]'); await expect(main).toHaveText(lifecycle.label); await expect(main).toHaveAttribute('href', `${basePath}${lifecycle.route}`); - await expect(main).toHaveClass(/highlight/); + if (lifecycle.stage === 1 || lifecycle.stage === 6) { + await expect(main).toHaveClass(/highlight/); + } else { + await expect(main).not.toHaveClass(/highlight/); + } const toggle = nationMenu.locator('[data-menu-id="tournament-betting"]'); await toggle.click(); @@ -1851,9 +1855,12 @@ test('tournament split main action follows recruitment, betting, finals, and tou if (lifecycle.stage === 6) { await expect(tournamentItem).not.toHaveClass(/highlight/); await expect(bettingItem).toHaveClass(/highlight/); - } else { + } else if (lifecycle.stage === 1) { await expect(tournamentItem).toHaveClass(/highlight/); await expect(bettingItem).not.toHaveClass(/highlight/); + } else { + await expect(tournamentItem).not.toHaveClass(/highlight/); + await expect(bettingItem).not.toHaveClass(/highlight/); } await page.keyboard.press('Escape'); @@ -1865,9 +1872,12 @@ test('tournament split main action follows recruitment, betting, finals, and tou if (lifecycle.stage === 6) { await expect(mobileTournament).not.toHaveClass(/highlight/); await expect(mobileBetting).toHaveClass(/highlight/); - } else { + } else if (lifecycle.stage === 1) { await expect(mobileTournament).toHaveClass(/highlight/); await expect(mobileBetting).not.toHaveClass(/highlight/); + } else { + await expect(mobileTournament).not.toHaveClass(/highlight/); + await expect(mobileBetting).not.toHaveClass(/highlight/); } await page.keyboard.press('Escape'); } @@ -2323,6 +2333,12 @@ test('main general card uses local turn time and command clock tracks corrected await expect(generalCard).toContainText('7분 남음'); await expect(generalCard).toContainText('백마대'); await expect(generalCard).toContainText('보통 120점(3)'); + const leadershipProgress = generalCard.locator('[data-rich-tooltip="stat-leadership"]'); + await leadershipProgress.hover(); + const statTooltip = page.locator('.tippy-box[data-theme~="sammo-rich"]'); + await expect(statTooltip).toBeVisible(); + await expect(statTooltip).toContainText('통솔 성장'); + await expect(statTooltip).toContainText('5 / 20'); const desktopGeometry = await title.evaluate((element) => { const rect = element.getBoundingClientRect(); @@ -4239,6 +4255,18 @@ test('all main Lumen button families share the rounded pressed geometry', async await page.mouse.up(); } + const generalPullMenu = page.locator('[data-main-target="commands"] .bottom-shift-menu').first(); + await generalPullMenu.locator('summary').click(); + await expect(generalPullMenu.locator('.menu-items > button')).toHaveText([ + '1턴', + '2턴', + '3턴', + '4턴', + '5턴', + '6턴', + ]); + await generalPullMenu.locator('summary').click(); + state.permission = 0; await page.locator('.main-turn-controls').getByRole('button', { name: '갱 신' }).click(); const disabledSecret = page.locator('.layout-desktop [data-navigation-id="secret-board"]'); diff --git a/app/game-frontend/src/components/command/ReservedCommandEditor.vue b/app/game-frontend/src/components/command/ReservedCommandEditor.vue index 0e2beed8..2ccd0f22 100644 --- a/app/game-frontend/src/components/command/ReservedCommandEditor.vue +++ b/app/game-frontend/src/components/command/ReservedCommandEditor.vue @@ -32,6 +32,7 @@ const props = withDefaults( storageKey: string; editModeStorageKey?: string; maxPushTurn?: number; + maxShiftTurn?: number; compact?: boolean; mobile?: boolean; title?: string; @@ -44,6 +45,7 @@ const props = withDefaults( { editModeStorageKey: undefined, maxPushTurn: 6, + maxShiftTurn: 6, compact: false, mobile: false, title: '', @@ -498,7 +500,7 @@ const clickOutsideMenu = (event: Event) => { 당기기