fix(game-ui): 메인 턴과 장수 상태 표시를 보완
This commit is contained in:
@@ -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 ?? '-',
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user