fix(game-ui): 메인 턴과 장수 상태 표시를 보완

This commit is contained in:
2026-08-29 03:30:50 +00:00
parent fa9fe71539
commit 26952fcdc7
11 changed files with 120 additions and 49 deletions
+3 -2
View File
@@ -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 ?? '-',
+4 -12
View File
@@ -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, { name: string }>): 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: {
@@ -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 {
@@ -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', () => {
@@ -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<typeof vi.fn>;
@@ -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: '열심' },
},
@@ -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');
+31 -3
View File
@@ -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"]');
@@ -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) => {
<summary>당기기</summary>
<div class="menu-items">
<button
v-for="amount in props.maxPushTurn"
v-for="amount in props.maxShiftTurn"
:key="amount"
@click="
emit('shift', -amount);
@@ -513,7 +515,7 @@ const clickOutsideMenu = (event: Event) => {
<summary>미루기</summary>
<div class="menu-items">
<button
v-for="amount in props.maxPushTurn"
v-for="amount in props.maxShiftTurn"
:key="amount"
@click="
emit('shift', amount);
@@ -695,12 +697,38 @@ const clickOutsideMenu = (event: Event) => {
</div>
<div v-if="!props.compact" class="bottom-actions">
<button class="legacy-button legacy-button--secondary" type="button" @click="emit('shift', -1)">
당기기
</button>
<button class="legacy-button legacy-button--secondary" type="button" @click="emit('shift', 1)">
미루기
</button>
<details class="legacy-menu bottom-shift-menu">
<summary class="legacy-button legacy-button--secondary" role="button">당기기</summary>
<div class="menu-items">
<button
v-for="amount in props.maxShiftTurn"
:key="amount"
type="button"
@click="
emit('shift', -amount);
clickOutsideMenu($event);
"
>
{{ amount }}
</button>
</div>
</details>
<details class="legacy-menu bottom-shift-menu">
<summary class="legacy-button legacy-button--secondary" role="button">미루기</summary>
<div class="menu-items">
<button
v-for="amount in props.maxShiftTurn"
:key="amount"
type="button"
@click="
emit('shift', amount);
clickOutsideMenu($event);
"
>
{{ amount }}
</button>
</div>
</details>
<button class="legacy-button legacy-button--secondary" type="button" @click="expanded = !expanded">
{{ expanded ? '접기' : '펼치기' }}
</button>
@@ -831,7 +859,8 @@ const clickOutsideMenu = (event: Event) => {
}
.control-pad > button,
.clock,
.legacy-menu > summary {
.control-pad > .legacy-menu > summary,
.advanced-actions .legacy-menu > summary {
box-sizing: border-box;
min-height: 34px;
border: 0;
@@ -272,11 +272,19 @@ const specialText = computed(() => {
<strong class="stat-value" :style="{ color: injuryInfo.color }">
<span>{{ stat.value }}</span>
<span v-if="stat.bonus > 0" class="leadership-bonus">+{{ stat.bonus }}</span>
<span class="bar-cell" :data-stat-progress="stat.key">
<LegacyProgressBar
:percent="stat.percent"
:label="`${stat.label} 성장 ${stat.accumulated} / ${stat.limit}`"
/>
<span class="bar-cell">
<RichTooltip
:title="`${stat.label} 성장`"
:description="`${stat.accumulated} / ${stat.limit}`"
:test-id="`stat-${stat.key}`"
>
<span :data-stat-progress="stat.key">
<LegacyProgressBar
:percent="stat.percent"
:label="`${stat.label} 성장 ${stat.accumulated} / ${stat.limit}`"
/>
</span>
</RichTooltip>
</span>
</strong>
</template>
@@ -539,6 +547,12 @@ const specialText = computed(() => {
grid-column: 3;
}
.bar-cell :deep(.rich-tooltip-trigger),
.bar-cell [data-stat-progress] {
display: block;
width: 100%;
}
.bar-cell,
.experience-bar {
display: grid;
@@ -17,7 +17,7 @@ export const resolveTournamentMainPresentation = (
tournamentStage: number,
tournamentType: number | null
): TournamentMainPresentation => {
const active = tournamentStage > 0 || tournamentType !== null;
const active = tournamentStage === 1 || tournamentStage === 6;
const bettingActive = tournamentStage === 6;
const tournamentLabel = tournamentType === null ? undefined : tournamentLabels[tournamentType];
return {
@@ -3,14 +3,14 @@ import test from 'node:test';
import { resolveTournamentMainPresentation } from '../src/utils/tournamentNavigation.ts';
void test('routes every active tournament stage except betting to its type-specific tournament button', () => {
void test('routes every non-betting stage to its type-specific tournament button and highlights only registration', () => {
const expectedLabels = ['전력전', '통솔전', '일기토', '설전'];
for (const [type, expectedLabel] of expectedLabels.entries()) {
for (const stage of [1, 2, 3, 4, 5, 7, 8, 9, 10, 0]) {
const presentation = resolveTournamentMainPresentation(stage, type);
assert.equal(presentation.compactLabel, expectedLabel);
assert.equal(presentation.to, '/tournament');
assert.equal(presentation.active, true);
assert.equal(presentation.active, stage === 1);
}
}
});