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, troopLeaderFirstTurn,
accessLog, accessLog,
rankRows, rankRows,
gameTime,
] = await Promise.all([ ] = await Promise.all([
general.cityId > 0 general.cityId > 0
? ctx.db.city.findUnique({ ? ctx.db.city.findUnique({
@@ -413,6 +414,7 @@ export const getGeneralContext = async (ctx: GameApiContext) => {
where: { generalId: general.id, type: { in: [...PERSONAL_RECORD_TYPES] } }, where: { generalId: general.id, type: { in: [...PERSONAL_RECORD_TYPES] } },
select: { type: true, value: true }, select: { type: true, value: true },
}), }),
loadCurrentGameTime(ctx.db),
]); ]);
const nation = queriedNation ?? NEUTRAL_NATION_CONTEXT; const nation = queriedNation ?? NEUTRAL_NATION_CONTEXT;
@@ -588,8 +590,7 @@ export const getGeneralContext = async (ctx: GameApiContext) => {
killTurn: readNumber(metaRecord.killturn ?? metaRecord.killTurn, 0), killTurn: readNumber(metaRecord.killturn ?? metaRecord.killTurn, 0),
remainingMinutes: resolveRemainingMinutes( remainingMinutes: resolveRemainingMinutes(
general.turnTime, general.turnTime,
parsedLastExecuted, gameTime.now
worldState?.tickSeconds ?? 0
), ),
crewTypeId: general.crewTypeId, crewTypeId: general.crewTypeId,
crewTypeName: crewTypeDetails.get(general.crewTypeId)?.name ?? '-', crewTypeName: crewTypeDetails.get(general.crewTypeId)?.name ?? '-',
+4 -12
View File
@@ -23,6 +23,7 @@ import {
resolveRefreshScoreText, resolveRefreshScoreText,
resolveRemainingMinutes, resolveRemainingMinutes,
} from '../../services/generalBasicCardProjection.js'; } from '../../services/generalBasicCardProjection.js';
import { loadCurrentGameTime } from '../../services/gameClock.js';
import { loadTraitNames } from '../nation/shared.js'; import { loadTraitNames } from '../nation/shared.js';
import { getAuthenticatedUserId, getMyGeneral } from '../shared/general.js'; import { getAuthenticatedUserId, getMyGeneral } from '../shared/general.js';
import { throwIfCommandRejected } from '../shared/turnDaemon.js'; import { throwIfCommandRejected } from '../shared/turnDaemon.js';
@@ -72,7 +73,7 @@ export const troopRouter = router({
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: '국가에 소속되어 있지 않습니다.' }); 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({ ctx.db.nation.findUnique({
where: { id: me.nationId }, where: { id: me.nationId },
select: { id: true, name: true, color: true, level: true, meta: true }, select: { id: true, name: true, color: true, level: true, meta: true },
@@ -121,6 +122,7 @@ export const troopRouter = router({
select: { id: true, name: true }, select: { id: true, name: true },
}), }),
ctx.db.worldState.findFirst({ select: { tickSeconds: true, config: true, meta: true } }), ctx.db.worldState.findFirst({ select: { tickSeconds: true, config: true, meta: true } }),
loadCurrentGameTime(ctx.db),
]); ]);
if (!nation) { if (!nation) {
throw new TRPCError({ code: 'NOT_FOUND', message: '국가 정보를 찾을 수 없습니다.' }); 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 => const traitName = (code: string, names: Map<string, { name: string }>): string =>
names.get(code)?.name ?? sanitizeInternalDisplayCode(code); names.get(code)?.name ?? sanitizeInternalDisplayCode(code);
const itemName = (code: string): string => itemNames.get(code) ?? 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 const mappedTroops = troops
.map((troop) => { .map((troop) => {
const leader = generalMap.get(troop.troopLeaderId); const leader = generalMap.get(troop.troopLeaderId);
@@ -348,8 +341,7 @@ export const troopRouter = router({
killTurn: readNumber(meta.killturn ?? meta.killTurn), killTurn: readNumber(meta.killturn ?? meta.killTurn),
remainingMinutes: resolveRemainingMinutes( remainingMinutes: resolveRemainingMinutes(
general.turnTime, general.turnTime,
lastExecuted, gameTime.now
worldState?.tickSeconds ?? 0
), ),
troopId: general.troopId, troopId: general.troopId,
troop: { troop: {
@@ -45,15 +45,10 @@ export const resolveRefreshScoreText = (score: number): string => {
export const resolveRemainingMinutes = ( export const resolveRemainingMinutes = (
turnTime: Date, turnTime: Date,
lastExecuted: Date | null, currentGameTime: Date | null
turnTermSeconds: number
): number | null => { ): number | null => {
if (!lastExecuted || !Number.isFinite(lastExecuted.getTime()) || turnTermSeconds <= 0) return null; if (!currentGameTime || !Number.isFinite(currentGameTime.getTime())) return null;
let nextTurnMillis = turnTime.getTime(); return Math.floor(Math.max(0, (turnTime.getTime() - currentGameTime.getTime()) / 60_000));
if (nextTurnMillis < lastExecuted.getTime()) {
nextTurnMillis += turnTermSeconds * 1_000;
}
return Math.floor(Math.min(999, Math.max(0, (nextTurnMillis - lastExecuted.getTime()) / 60_000)));
}; };
export interface NextTurnMonthOffsetInput { export interface NextTurnMonthOffsetInput {
@@ -36,11 +36,12 @@ describe('general basic card Ref projection', () => {
expect(resolveRefreshScoreText(12_800)).toBe('헐...'); expect(resolveRefreshScoreText(12_800)).toBe('헐...');
}); });
it('matches the Ref remaining-minute calculation and one-turn rollover', () => { it('matches the Ref remaining-minute calculation against the current game clock', () => {
const lastExecuted = new Date('2026-08-13T00:00:00.000Z'); const currentGameTime = new Date('2026-08-13T00:20:00.000Z');
expect(resolveRemainingMinutes(new Date('2026-08-13T00:07:06.000Z'), lastExecuted, 3_600)).toBe(7); expect(resolveRemainingMinutes(new Date('2026-08-13T01:20:00.000Z'), currentGameTime)).toBe(60);
expect(resolveRemainingMinutes(new Date('2026-08-12T23:59:00.000Z'), lastExecuted, 3_600)).toBe(59); expect(resolveRemainingMinutes(new Date('2026-08-13T01:08:59.000Z'), currentGameTime)).toBe(48);
expect(resolveRemainingMinutes(new Date('2026-08-13T00:07:06.000Z'), null, 3_600)).toBeNull(); 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', () => { it('moves the first reserved month only after the general turn bucket has passed', () => {
@@ -85,6 +85,7 @@ const createContext = (options: {
troopLeaderAction?: string | null; troopLeaderAction?: string | null;
refreshScore?: number; refreshScore?: number;
refreshScoreTotal?: number; refreshScoreTotal?: number;
gameClockNow?: Date;
rankRows?: Array<{ generalId?: number; type: string; value: number }>; rankRows?: Array<{ generalId?: number; type: string; value: number }>;
requestId?: string; requestId?: string;
transaction?: ReturnType<typeof vi.fn>; transaction?: ReturnType<typeof vi.fn>;
@@ -164,6 +165,10 @@ const createContext = (options: {
currentMonth: 1, currentMonth: 1,
tickSeconds: 600, tickSeconds: 600,
config: { const: { upgradeLimit: 20 } }, config: { const: { upgradeLimit: 20 } },
clockBaseTime: options.gameClockNow ?? null,
clockTick: options.gameClockNow ? 0n : null,
clockMode: 'manual',
clockWallAnchor: options.gameClockNow ?? null,
})), })),
}, },
logEntry: { logEntry: {
@@ -485,6 +490,7 @@ describe('in-game my information ownership', () => {
troopLeaderAction: '휴식', troopLeaderAction: '휴식',
refreshScore: 3, refreshScore: 3,
refreshScoreTotal: 1_141, refreshScoreTotal: 1_141,
gameClockNow: new Date('2026-01-01T00:00:00.000Z'),
}); });
await expect(appRouter.createCaller(fixture.context).general.me()).resolves.toMatchObject({ await expect(appRouter.createCaller(fixture.context).general.me()).resolves.toMatchObject({
@@ -495,7 +501,7 @@ describe('in-game my information ownership', () => {
retirementYear: 70, retirementYear: 70,
defenceTrain: 80, defenceTrain: 80,
killTurn: 6, killTurn: 6,
remainingMinutes: null, remainingMinutes: 7,
troop: { name: '정밀검증부대', status: 'inactive', leaderCityName: '업' }, troop: { name: '정밀검증부대', status: 'inactive', leaderCityName: '업' },
refreshScore: { current: 3, total: 1_141, text: '열심' }, refreshScore: { current: 3, total: 1_141, text: '열심' },
}, },
@@ -946,7 +946,7 @@ const install = async (
return requests; 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 install(page);
await page.setViewportSize({ width: 1200, height: 900 }); await page.setViewportSize({ width: 1200, height: 900 });
await page.goto('/'); 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( await expect(generalRepeat.locator('.menu-items > button')).toHaveText(
Array.from({ length: 12 }, (_, index) => `${index + 1}`) 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 }); await page.setViewportSize({ width: 500, height: 900 });
const mobileGeneralEditor = page.locator('[data-command-scope="general"]:visible'); 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"]'); const main = nationMenu.locator('[data-navigation-id="tournament"]');
await expect(main).toHaveText(lifecycle.label); await expect(main).toHaveText(lifecycle.label);
await expect(main).toHaveAttribute('href', `${basePath}${lifecycle.route}`); 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"]'); const toggle = nationMenu.locator('[data-menu-id="tournament-betting"]');
await toggle.click(); await toggle.click();
@@ -1851,9 +1855,12 @@ test('tournament split main action follows recruitment, betting, finals, and tou
if (lifecycle.stage === 6) { if (lifecycle.stage === 6) {
await expect(tournamentItem).not.toHaveClass(/highlight/); await expect(tournamentItem).not.toHaveClass(/highlight/);
await expect(bettingItem).toHaveClass(/highlight/); await expect(bettingItem).toHaveClass(/highlight/);
} else { } else if (lifecycle.stage === 1) {
await expect(tournamentItem).toHaveClass(/highlight/); await expect(tournamentItem).toHaveClass(/highlight/);
await expect(bettingItem).not.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'); await page.keyboard.press('Escape');
@@ -1865,9 +1872,12 @@ test('tournament split main action follows recruitment, betting, finals, and tou
if (lifecycle.stage === 6) { if (lifecycle.stage === 6) {
await expect(mobileTournament).not.toHaveClass(/highlight/); await expect(mobileTournament).not.toHaveClass(/highlight/);
await expect(mobileBetting).toHaveClass(/highlight/); await expect(mobileBetting).toHaveClass(/highlight/);
} else { } else if (lifecycle.stage === 1) {
await expect(mobileTournament).toHaveClass(/highlight/); await expect(mobileTournament).toHaveClass(/highlight/);
await expect(mobileBetting).not.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'); 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('7분 남음');
await expect(generalCard).toContainText('백마대'); await expect(generalCard).toContainText('백마대');
await expect(generalCard).toContainText('보통 120점(3)'); 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 desktopGeometry = await title.evaluate((element) => {
const rect = element.getBoundingClientRect(); const rect = element.getBoundingClientRect();
@@ -4239,6 +4255,18 @@ test('all main Lumen button families share the rounded pressed geometry', async
await page.mouse.up(); 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; state.permission = 0;
await page.locator('.main-turn-controls').getByRole('button', { name: '갱 신' }).click(); await page.locator('.main-turn-controls').getByRole('button', { name: '갱 신' }).click();
const disabledSecret = page.locator('.layout-desktop [data-navigation-id="secret-board"]'); const disabledSecret = page.locator('.layout-desktop [data-navigation-id="secret-board"]');
@@ -32,6 +32,7 @@ const props = withDefaults(
storageKey: string; storageKey: string;
editModeStorageKey?: string; editModeStorageKey?: string;
maxPushTurn?: number; maxPushTurn?: number;
maxShiftTurn?: number;
compact?: boolean; compact?: boolean;
mobile?: boolean; mobile?: boolean;
title?: string; title?: string;
@@ -44,6 +45,7 @@ const props = withDefaults(
{ {
editModeStorageKey: undefined, editModeStorageKey: undefined,
maxPushTurn: 6, maxPushTurn: 6,
maxShiftTurn: 6,
compact: false, compact: false,
mobile: false, mobile: false,
title: '', title: '',
@@ -498,7 +500,7 @@ const clickOutsideMenu = (event: Event) => {
<summary>당기기</summary> <summary>당기기</summary>
<div class="menu-items"> <div class="menu-items">
<button <button
v-for="amount in props.maxPushTurn" v-for="amount in props.maxShiftTurn"
:key="amount" :key="amount"
@click=" @click="
emit('shift', -amount); emit('shift', -amount);
@@ -513,7 +515,7 @@ const clickOutsideMenu = (event: Event) => {
<summary>미루기</summary> <summary>미루기</summary>
<div class="menu-items"> <div class="menu-items">
<button <button
v-for="amount in props.maxPushTurn" v-for="amount in props.maxShiftTurn"
:key="amount" :key="amount"
@click=" @click="
emit('shift', amount); emit('shift', amount);
@@ -695,12 +697,38 @@ const clickOutsideMenu = (event: Event) => {
</div> </div>
<div v-if="!props.compact" class="bottom-actions"> <div v-if="!props.compact" class="bottom-actions">
<button class="legacy-button legacy-button--secondary" type="button" @click="emit('shift', -1)"> <details class="legacy-menu bottom-shift-menu">
당기기 <summary class="legacy-button legacy-button--secondary" role="button">당기기</summary>
</button> <div class="menu-items">
<button class="legacy-button legacy-button--secondary" type="button" @click="emit('shift', 1)"> <button
미루기 v-for="amount in props.maxShiftTurn"
</button> :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"> <button class="legacy-button legacy-button--secondary" type="button" @click="expanded = !expanded">
{{ expanded ? '접기' : '펼치기' }} {{ expanded ? '접기' : '펼치기' }}
</button> </button>
@@ -831,7 +859,8 @@ const clickOutsideMenu = (event: Event) => {
} }
.control-pad > button, .control-pad > button,
.clock, .clock,
.legacy-menu > summary { .control-pad > .legacy-menu > summary,
.advanced-actions .legacy-menu > summary {
box-sizing: border-box; box-sizing: border-box;
min-height: 34px; min-height: 34px;
border: 0; border: 0;
@@ -272,11 +272,19 @@ const specialText = computed(() => {
<strong class="stat-value" :style="{ color: injuryInfo.color }"> <strong class="stat-value" :style="{ color: injuryInfo.color }">
<span>{{ stat.value }}</span> <span>{{ stat.value }}</span>
<span v-if="stat.bonus > 0" class="leadership-bonus">+{{ stat.bonus }}</span> <span v-if="stat.bonus > 0" class="leadership-bonus">+{{ stat.bonus }}</span>
<span class="bar-cell" :data-stat-progress="stat.key"> <span class="bar-cell">
<LegacyProgressBar <RichTooltip
:percent="stat.percent" :title="`${stat.label} 성장`"
:label="`${stat.label} 성장 ${stat.accumulated} / ${stat.limit}`" :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> </span>
</strong> </strong>
</template> </template>
@@ -539,6 +547,12 @@ const specialText = computed(() => {
grid-column: 3; grid-column: 3;
} }
.bar-cell :deep(.rich-tooltip-trigger),
.bar-cell [data-stat-progress] {
display: block;
width: 100%;
}
.bar-cell, .bar-cell,
.experience-bar { .experience-bar {
display: grid; display: grid;
@@ -17,7 +17,7 @@ export const resolveTournamentMainPresentation = (
tournamentStage: number, tournamentStage: number,
tournamentType: number | null tournamentType: number | null
): TournamentMainPresentation => { ): TournamentMainPresentation => {
const active = tournamentStage > 0 || tournamentType !== null; const active = tournamentStage === 1 || tournamentStage === 6;
const bettingActive = tournamentStage === 6; const bettingActive = tournamentStage === 6;
const tournamentLabel = tournamentType === null ? undefined : tournamentLabels[tournamentType]; const tournamentLabel = tournamentType === null ? undefined : tournamentLabels[tournamentType];
return { return {
@@ -3,14 +3,14 @@ import test from 'node:test';
import { resolveTournamentMainPresentation } from '../src/utils/tournamentNavigation.ts'; 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 = ['전력전', '통솔전', '일기토', '설전']; const expectedLabels = ['전력전', '통솔전', '일기토', '설전'];
for (const [type, expectedLabel] of expectedLabels.entries()) { for (const [type, expectedLabel] of expectedLabels.entries()) {
for (const stage of [1, 2, 3, 4, 5, 7, 8, 9, 10, 0]) { for (const stage of [1, 2, 3, 4, 5, 7, 8, 9, 10, 0]) {
const presentation = resolveTournamentMainPresentation(stage, type); const presentation = resolveTournamentMainPresentation(stage, type);
assert.equal(presentation.compactLabel, expectedLabel); assert.equal(presentation.compactLabel, expectedLabel);
assert.equal(presentation.to, '/tournament'); assert.equal(presentation.to, '/tournament');
assert.equal(presentation.active, true); assert.equal(presentation.active, stage === 1);
} }
} }
}); });