merge: 최신 main을 메인 현재 시각 갱신에 통합한다

This commit is contained in:
2026-08-21 17:20:23 +00:00
18 changed files with 95 additions and 19 deletions
+2 -1
View File
@@ -1,7 +1,7 @@
import { TRPCError } from '@trpc/server'; import { TRPCError } from '@trpc/server';
import { z } from 'zod'; import { z } from 'zod';
import { LogCategory, LogScope } from '@sammo-ts/logic'; import { getBillByLevel, LogCategory, LogScope } from '@sammo-ts/logic';
import { asRecord, type RankDataType } from '@sammo-ts/common'; import { asRecord, type RankDataType } from '@sammo-ts/common';
import type { GameApiContext } from '../../context.js'; import type { GameApiContext } from '../../context.js';
@@ -517,6 +517,7 @@ export const getGeneralContext = async (ctx: GameApiContext) => {
injury: general.injury, injury: general.injury,
experience: general.experience, experience: general.experience,
dedication: general.dedication, dedication: general.dedication,
bill: getBillByLevel(dedicationLevel),
age: general.age, age: general.age,
retirementYear, retirementYear,
turnTime: general.turnTime.toISOString(), turnTime: general.turnTime.toISOString(),
@@ -1,7 +1,7 @@
import { TRPCError } from '@trpc/server'; import { TRPCError } from '@trpc/server';
import { asRecord, type RankDataType } from '@sammo-ts/common'; import { asRecord, type RankDataType } from '@sammo-ts/common';
import { LogCategory } from '@sammo-ts/logic'; import { getBillByLevel, LogCategory } from '@sammo-ts/logic';
import { accessAuthedProcedure } from '../../../trpc.js'; import { accessAuthedProcedure } from '../../../trpc.js';
import { import {
@@ -201,6 +201,7 @@ export const getBattleCenter = accessAuthedProcedure.query(async ({ ctx }) => {
}, },
experience: general.experience, experience: general.experience,
dedication: general.dedication, dedication: general.dedication,
bill: getBillByLevel(dedicationLevel),
injury: general.injury, injury: general.injury,
gold: general.gold, gold: general.gold,
rice: general.rice, rice: general.rice,
+3 -2
View File
@@ -79,7 +79,7 @@ export const troopRouter = router({
troopLeaderIds.length === 0 troopLeaderIds.length === 0
? [] ? []
: await ctx.db.generalTurn.findMany({ : await ctx.db.generalTurn.findMany({
where: { generalId: { in: troopLeaderIds } }, where: { generalId: { in: troopLeaderIds }, turnIdx: { lt: 5 } },
select: { generalId: true, turnIdx: true, actionCode: true }, select: { generalId: true, turnIdx: true, actionCode: true },
orderBy: [{ generalId: 'asc' }, { turnIdx: 'asc' }], orderBy: [{ generalId: 'asc' }, { turnIdx: 'asc' }],
}); });
@@ -94,7 +94,8 @@ export const troopRouter = router({
: 30; : 30;
for (const turn of turns) { for (const turn of turns) {
const list = reservedByLeader.get(turn.generalId) ?? []; const list = reservedByLeader.get(turn.generalId) ?? [];
list.push(turn.actionCode); // Ref 부대 편성은 앞쪽 슬롯이 집합인지 여부만 공개하고 다른 명령은 가립니다.
list.push(turn.actionCode === 'che_집합' ? '집합' : '-');
reservedByLeader.set(turn.generalId, list); reservedByLeader.set(turn.generalId, list);
} }
@@ -297,6 +297,7 @@ describe('in-game my information ownership', () => {
statUpgradeLimit: 20, statUpgradeLimit: 20,
dex: [350, 1_375, 3_500, 7_125, 12_650], dex: [350, 1_375, 3_500, 7_125, 12_650],
}, },
bill: 1_000,
}, },
city: { city: {
population: 322_886, population: 322_886,
@@ -433,6 +434,7 @@ describe('in-game my information ownership', () => {
stats: { attack: 100, defence: 150, speed: 7, avoid: 10, magicCoef: 0, cost: 9, rice: 9 }, stats: { attack: 100, defence: 150, speed: 7, avoid: 10, magicCoef: 0, cost: 9, rice: 9 },
}, },
progression: { experienceLevel: 4, dedicationLevel: 2, dedicationText: '29품관' }, progression: { experienceLevel: 4, dedicationLevel: 2, dedicationText: '29품관' },
bill: 800,
itemNames: { horse: '노새(+3)' }, itemNames: { horse: '노새(+3)' },
itemInfo: { horse: '통솔 +3' }, itemInfo: { horse: '통솔 +3' },
}, },
@@ -855,6 +857,7 @@ describe('battle-center general and user permissions', () => {
statUpgradeLimit: 20, statUpgradeLimit: 20,
dex: [0, 0, 0, 0, 0], dex: [0, 0, 0, 0, 0],
}, },
bill: 600,
serviceYears: 3, serviceYears: 3,
battleStats: { battleStats: {
kills: 5, kills: 5,
+28 -2
View File
@@ -77,10 +77,12 @@ const buildContext = (options: {
auth?: GameSessionTokenPayload | null; auth?: GameSessionTokenPayload | null;
requestId?: string; requestId?: string;
transaction?: ReturnType<typeof vi.fn>; transaction?: ReturnType<typeof vi.fn>;
turns?: Array<{ generalId: number; turnIdx: number; actionCode: string }>;
result: Awaited<ReturnType<TurnDaemonTransport['requestCommand']>>; result: Awaited<ReturnType<TurnDaemonTransport['requestCommand']>>;
}) => { }) => {
const me = options.me ?? buildGeneral(); const me = options.me ?? buildGeneral();
const requestCommand = vi.fn(async () => options.result); const requestCommand = vi.fn(async () => options.result);
const generalTurnFindMany = vi.fn(async () => options.turns ?? []);
const db = { const db = {
...(options.transaction ? { $transaction: options.transaction } : {}), ...(options.transaction ? { $transaction: options.transaction } : {}),
general: { general: {
@@ -110,7 +112,7 @@ const buildContext = (options: {
}, },
city: { findMany: vi.fn(async () => [{ id: 1, name: '북평' }]) }, city: { findMany: vi.fn(async () => [{ id: 1, name: '북평' }]) },
worldState: { findFirst: vi.fn(async () => ({ config: { const: { upgradeLimit: 20 } } })) }, worldState: { findFirst: vi.fn(async () => ({ config: { const: { upgradeLimit: 20 } } })) },
generalTurn: { findMany: vi.fn(async () => []) }, generalTurn: { findMany: generalTurnFindMany },
}; };
const accessTokenStore = new RedisAccessTokenStore( const accessTokenStore = new RedisAccessTokenStore(
{ {
@@ -134,7 +136,7 @@ const buildContext = (options: {
flushStore: new InMemoryFlushStore(), flushStore: new InMemoryFlushStore(),
gameTokenSecret: 'test-secret', gameTokenSecret: 'test-secret',
}; };
return { context, requestCommand }; return { context, requestCommand, generalTurnFindMany };
}; };
describe('troop router permissions and mutations', () => { describe('troop router permissions and mutations', () => {
@@ -175,6 +177,30 @@ describe('troop router permissions and mutations', () => {
}); });
}); });
it('returns only the first five Ref-redacted troop command labels without exposing action codes', async () => {
const fixture = buildContext({
me: buildGeneral({ troopId: 1 }),
turns: [
{ generalId: 1, turnIdx: 0, actionCode: 'che_집합' },
{ generalId: 1, turnIdx: 1, actionCode: 'che_이동' },
{ generalId: 1, turnIdx: 2, actionCode: 'che_징병' },
{ generalId: 1, turnIdx: 3, actionCode: '휴식' },
{ generalId: 1, turnIdx: 4, actionCode: 'che_화계' },
],
result: null,
});
const result = await appRouter.createCaller(fixture.context).troop.getList();
expect(result.troops[0]?.reservedCommands).toEqual(['집합', '-', '-', '-', '-']);
expect(JSON.stringify(result.troops)).not.toContain('che_');
expect(fixture.generalTurnFindMany).toHaveBeenCalledWith({
where: { generalId: { in: [1] }, turnIdx: { lt: 5 } },
select: { generalId: true, turnIdx: true, actionCode: true },
orderBy: [{ generalId: 'asc' }, { turnIdx: 'asc' }],
});
});
it('creates a troop only for the general owned by the authenticated user', async () => { it('creates a troop only for the general owned by the authenticated user', async () => {
const { context, requestCommand } = buildContext({ const { context, requestCommand } = buildContext({
result: { type: 'troopCreate', ok: true, generalId: 1, troopId: 1, troopName: '백마대' }, result: { type: 'troopCreate', ok: true, generalId: 1, troopId: 1, troopName: '백마대' },
@@ -629,6 +629,10 @@ test('nation directory reuses only the public general-directory row on hover and
await expect(preview.locator('[data-general-card-id="10"]')).toContainText('대담'); await expect(preview.locator('[data-general-card-id="10"]')).toContainText('대담');
await expect(preview.locator('[data-directory-tooltip="card-special-domestic-10"]')).toContainText('상재'); await expect(preview.locator('[data-directory-tooltip="card-special-domestic-10"]')).toContainText('상재');
await expect(preview.locator('[data-directory-tooltip="card-special-war-10"]')).toContainText('귀모'); await expect(preview.locator('[data-directory-tooltip="card-special-war-10"]')).toContainText('귀모');
await expect(preview.locator('[data-directory-tooltip="card-special-domestic-10"]')).toHaveCSS(
'text-decoration-line',
'none'
);
await expect(preview).not.toContainText('user-'); await expect(preview).not.toContainText('user-');
await expect(preview).not.toContainText('secret'); await expect(preview).not.toContainText('secret');
+8 -2
View File
@@ -112,6 +112,7 @@ const myGeneral = (state: FixtureState) => ({
injury: 0, injury: 0,
experience: 100, experience: 100,
dedication: 200, dedication: 200,
bill: 800,
age: 30, age: 30,
turnTime: '2026-01-01 00:10:00', turnTime: '2026-01-01 00:10:00',
recentWar: '2026-01-01 00:00:00', recentWar: '2026-01-01 00:00:00',
@@ -296,6 +297,7 @@ const battleCenter = (state: FixtureState) => ({
stats: { leadership: 70, strength: 60, intelligence: 50 }, stats: { leadership: 70, strength: 60, intelligence: 50 },
experience: 100, experience: 100,
dedication: 200, dedication: 200,
bill: 800,
injury: 0, injury: 0,
gold: 1_000, gold: 1_000,
rice: 2_000, rice: 2_000,
@@ -334,6 +336,7 @@ const battleCenter = (state: FixtureState) => ({
stats: { leadership: 50, strength: 50, intelligence: 50 }, stats: { leadership: 50, strength: 50, intelligence: 50 },
experience: 0, experience: 0,
dedication: 0, dedication: 0,
bill: 400,
injury: 0, injury: 0,
gold: 500, gold: 500,
rice: 500, rice: 500,
@@ -1245,6 +1248,7 @@ test('내 정보&설정 keeps desktop density and becomes a 390px horizontal-ide
await expect(page.locator('.general-table')).toContainText('병종보병'); await expect(page.locator('.general-table')).toContainText('병종보병');
await expect(page.locator('.general-table')).toContainText('삭턴6 턴'); await expect(page.locator('.general-table')).toContainText('삭턴6 턴');
await expect(page.locator('.battle-general-extra')).toContainText('계급29품관'); await expect(page.locator('.battle-general-extra')).toContainText('계급29품관');
await expect(page.locator('.battle-general-extra')).toContainText('봉급800');
await expect(page.locator('.battle-general-extra')).toContainText('전투8회'); await expect(page.locator('.battle-general-extra')).toContainText('전투8회');
await expect(page.locator('.battle-general-extra')).toContainText('계략12'); await expect(page.locator('.battle-general-extra')).toContainText('계략12');
await expect(page.locator('.battle-general-extra')).toContainText('사관4년'); await expect(page.locator('.battle-general-extra')).toContainText('사관4년');
@@ -1255,7 +1259,7 @@ test('내 정보&설정 keeps desktop density and becomes a 390px horizontal-ide
await expect(page.locator('.battle-general-extra__recent-value')).toHaveText('01-01 00:00'); await expect(page.locator('.battle-general-extra__recent-value')).toHaveText('01-01 00:00');
await expect(page.locator('.legacy-general-details')).toHaveCount(0); await expect(page.locator('.legacy-general-details')).toHaveCount(0);
expect(await readGeneralSummaryRows(page.locator('.battle-general-extra'))).toEqual([ expect(await readGeneralSummaryRows(page.locator('.battle-general-extra'))).toEqual([
['명성', '계급', ''], ['명성', '계급', '봉급'],
['전투', '계략', '사관'], ['전투', '계략', '사관'],
['승률', '승리', '패배'], ['승률', '승리', '패배'],
['살상률', '사살', '피살'], ['살상률', '사살', '피살'],
@@ -1429,6 +1433,7 @@ test('내 정보 항목과 국가 성향은 HTML 리치 툴팁을 마우스와
const showWithMouse = async (testId: string, expectedTexts: readonly string[]) => { const showWithMouse = async (testId: string, expectedTexts: readonly string[]) => {
const trigger = page.locator(`[data-rich-tooltip="${testId}"]`); const trigger = page.locator(`[data-rich-tooltip="${testId}"]`);
await expect(trigger).toHaveAttribute('tabindex', '0'); await expect(trigger).toHaveAttribute('tabindex', '0');
await expect(trigger).toHaveCSS('text-decoration-line', 'none');
await trigger.hover(); await trigger.hover();
await expect(visibleTooltip).toHaveCount(1); await expect(visibleTooltip).toHaveCount(1);
await expect(visibleTooltip).toHaveAttribute('role', 'tooltip'); await expect(visibleTooltip).toHaveAttribute('role', 'tooltip');
@@ -2039,6 +2044,7 @@ test('감찰부 keeps the selector interaction and shows the permission error pa
await expect(page.locator('.battle-general-name')).toContainText('간의대부'); await expect(page.locator('.battle-general-name')).toContainText('간의대부');
await expect(page.locator('.battle-general-name')).toContainText('건강'); await expect(page.locator('.battle-general-name')).toContainText('건강');
await expect(page.locator('.battle-general-extra')).toContainText('계급29품관'); await expect(page.locator('.battle-general-extra')).toContainText('계급29품관');
await expect(page.locator('.battle-general-extra')).toContainText('봉급800');
await expect(page.locator('.battle-general-card')).toContainText('병종보병'); await expect(page.locator('.battle-general-card')).toContainText('병종보병');
await expect(page.locator('.battle-general-card')).not.toContainText('che_'); await expect(page.locator('.battle-general-card')).not.toContainText('che_');
await expect(page.locator('.battle-general-card')).toHaveAttribute('data-general-basic-card', ''); await expect(page.locator('.battle-general-card')).toHaveAttribute('data-general-basic-card', '');
@@ -2049,7 +2055,7 @@ test('감찰부 keeps the selector interaction and shows the permission error pa
await expect(page.locator('.battle-general-extra')).toContainText('승률62.50%'); await expect(page.locator('.battle-general-extra')).toContainText('승률62.50%');
await expect(page.locator('.battle-general-extra')).toContainText('살상률181.84%'); await expect(page.locator('.battle-general-extra')).toContainText('살상률181.84%');
expect(await readGeneralSummaryRows(page.locator('.battle-general-extra'))).toEqual([ expect(await readGeneralSummaryRows(page.locator('.battle-general-extra'))).toEqual([
['명성', '계급', ''], ['명성', '계급', '봉급'],
['전투', '계략', '사관'], ['전투', '계략', '사관'],
['승률', '승리', '패배'], ['승률', '승리', '패배'],
['살상률', '사살', '피살'], ['살상률', '사살', '피살'],
@@ -2189,6 +2189,7 @@ test('main cards and command input stay inside their Ref-sized grid slots', asyn
expect(Math.max(...nationRowHeights) - Math.min(...nationRowHeights)).toBeLessThanOrEqual(0.01); expect(Math.max(...nationRowHeights) - Math.min(...nationRowHeights)).toBeLessThanOrEqual(0.01);
const strategicCell = nationCard.locator('.strategic'); const strategicCell = nationCard.locator('.strategic');
const strategicTooltip = strategicCell.getByRole('tooltip'); const strategicTooltip = strategicCell.getByRole('tooltip');
await expect(strategicCell).toHaveCSS('text-decoration-line', 'none');
await expect(strategicTooltip).toBeHidden(); await expect(strategicTooltip).toBeHidden();
await strategicCell.hover(); await strategicCell.hover();
await expect(strategicTooltip).toBeVisible(); await expect(strategicTooltip).toBeVisible();
@@ -266,6 +266,7 @@ test('renders Ref-shaped token cards, preserves keep cooldown and retries posses
}); });
const tooltip = page.locator('.npc-tooltip').first(); const tooltip = page.locator('.npc-tooltip').first();
const tooltipPopup = tooltip.getByRole('tooltip'); const tooltipPopup = tooltip.getByRole('tooltip');
await expect(tooltip).toHaveCSS('text-decoration-line', 'none');
await expect(tooltipPopup).toBeHidden(); await expect(tooltipPopup).toBeHidden();
await tooltip.hover(); await tooltip.hover();
await expect(tooltipPopup).toBeVisible(); await expect(tooltipPopup).toBeVisible();
+29 -2
View File
@@ -80,7 +80,7 @@ const baseTroops = (): TroopFixture[] => [
name: '백마대', name: '백마대',
nationId: 1, nationId: 1,
turnTime: '2026-07-25T08:20:30.000Z', turnTime: '2026-07-25T08:20:30.000Z',
reservedCommands: ['che_집합', 'che_이동'], reservedCommands: ['집합', '-'],
leader: { leader: {
id: 1, id: 1,
name: '공손찬', name: '공손찬',
@@ -96,7 +96,7 @@ const baseTroops = (): TroopFixture[] => [
name: '청룡대', name: '청룡대',
nationId: 1, nationId: 1,
turnTime: '2026-07-25T08:30:30.000Z', turnTime: '2026-07-25T08:30:30.000Z',
reservedCommands: ['che_징병'], reservedCommands: ['-'],
leader: { leader: {
id: 2, id: 2,
name: '관우', name: '관우',
@@ -224,6 +224,27 @@ const installApiFixture = async (page: Page, state: FixtureState) => {
}); });
}; };
test('shows only the Ref-safe first-five troop command labels on desktop and mobile', async ({ page }) => {
await installApiFixture(page, {
me: { id: 1, troopId: 1 },
permission: 4,
troops: baseTroops(),
});
for (const viewport of [
{ width: 1000, height: 800 },
{ width: 500, height: 800 },
]) {
await page.setViewportSize(viewport);
await gotoTroop(page);
const firstTroopCommands = page.locator('.troopReservedCommand').first();
await expect(firstTroopCommands).toContainText('1: 집합');
await expect(firstTroopCommands).toContainText('2: -');
await expect(page.locator('#troopList')).not.toContainText('che_');
}
});
test('renders the legacy desktop grid with matching computed geometry and states', async ({ page }) => { test('renders the legacy desktop grid with matching computed geometry and states', async ({ page }) => {
await installApiFixture(page, { await installApiFixture(page, {
me: { id: 1, troopId: 1 }, me: { id: 1, troopId: 1 },
@@ -233,6 +254,9 @@ test('renders the legacy desktop grid with matching computed geometry and states
await page.setViewportSize({ width: 1000, height: 800 }); await page.setViewportSize({ width: 1000, height: 800 });
await gotoTroop(page); await gotoTroop(page);
await expect(page.locator('.troopInfo').filter({ hasText: '백마대' })).toBeVisible(); await expect(page.locator('.troopInfo').filter({ hasText: '백마대' })).toBeVisible();
await expect(page.locator('.troopReservedCommand').first()).toContainText('1: 집합');
await expect(page.locator('.troopReservedCommand').first()).toContainText('2: -');
await expect(page.locator('#troopList')).not.toContainText('che_');
const geometry = await page const geometry = await page
.locator('.troopItem') .locator('.troopItem')
@@ -319,6 +343,9 @@ test('matches the legacy 500px responsive placement', async ({ page }) => {
await page.setViewportSize({ width: 500, height: 800 }); await page.setViewportSize({ width: 500, height: 800 });
await gotoTroop(page); await gotoTroop(page);
await expect(page.locator('.troopInfo').filter({ hasText: '백마대' })).toBeVisible(); await expect(page.locator('.troopInfo').filter({ hasText: '백마대' })).toBeVisible();
await expect(page.locator('.troopReservedCommand').first()).toContainText('1: 집합');
await expect(page.locator('.troopReservedCommand').first()).toContainText('2: -');
await expect(page.locator('#troopList')).not.toContainText('che_');
const geometry = await page const geometry = await page
.locator('.troopItem') .locator('.troopItem')
@@ -29,8 +29,6 @@ defineProps<{
} }
.directory-tooltip--enabled { .directory-tooltip--enabled {
cursor: help; cursor: help;
text-decoration: underline dotted rgb(150 210 255 / 85%);
text-underline-offset: 2px;
} }
.directory-tooltip--enabled:focus-visible { .directory-tooltip--enabled:focus-visible {
border-radius: 2px; border-radius: 2px;
@@ -7,6 +7,7 @@ export type GeneralBattleSummaryData = {
available?: boolean; available?: boolean;
experience?: number | null; experience?: number | null;
dedicationText?: string | null; dedicationText?: string | null;
bill?: number | null;
warnum?: number | null; warnum?: number | null;
wins?: number | null; wins?: number | null;
losses?: number | null; losses?: number | null;
@@ -62,8 +63,13 @@ const killRate = computed(() => {
<template v-else> <template v-else>
<span>명성</span><strong>{{ numberText(summary.experience) }}</strong> <span>계급</span <span>명성</span><strong>{{ numberText(summary.experience) }}</strong> <span>계급</span
><strong>{{ summary.dedicationText || '-' }}</strong> ><strong>{{ summary.dedicationText || '-' }}</strong>
<span class="battle-general-extra__empty" aria-hidden="true"></span> <template v-if="summary.bill !== undefined">
<strong class="battle-general-extra__empty" aria-hidden="true"></strong> <span>봉급</span><strong>{{ numberText(summary.bill) }}</strong>
</template>
<template v-else>
<span class="battle-general-extra__empty" aria-hidden="true"></span>
<strong class="battle-general-extra__empty" aria-hidden="true"></strong>
</template>
<span>전투</span <span>전투</span
><strong>{{ numberText(summary.warnum) }}<template v-if="summary.warnum != null"></template></strong> ><strong>{{ numberText(summary.warnum) }}<template v-if="summary.warnum != null"></template></strong>
<span>계략</span><strong>{{ numberText(summary.strategies) }}</strong> <span>계략</span><strong>{{ numberText(summary.strategies) }}</strong>
@@ -284,7 +284,6 @@ const displayChiefName = (chief: NationChief | undefined): string => {
.strategic.has-tooltip { .strategic.has-tooltip {
overflow: visible; overflow: visible;
text-decoration: underline dashed red;
} }
.cooldown-tooltip { .cooldown-tooltip {
@@ -97,8 +97,6 @@ watch(
.rich-tooltip-trigger--enabled { .rich-tooltip-trigger--enabled {
cursor: help; cursor: help;
text-decoration: underline dotted rgb(150 210 255 / 85%);
text-underline-offset: 2px;
} }
.rich-tooltip-trigger--enabled:focus-visible { .rich-tooltip-trigger--enabled:focus-visible {
@@ -291,6 +291,7 @@ onMounted(() => {
available: true, available: true,
experience: selectedGeneral.experience, experience: selectedGeneral.experience,
dedicationText: selectedGeneral.progression.dedicationText, dedicationText: selectedGeneral.progression.dedicationText,
bill: selectedGeneral.bill,
warnum: selectedGeneral.warnum, warnum: selectedGeneral.warnum,
wins: selectedGeneral.battleStats.kills, wins: selectedGeneral.battleStats.kills,
losses: selectedGeneral.battleStats.deaths, losses: selectedGeneral.battleStats.deaths,
-1
View File
@@ -1733,7 +1733,6 @@ onUnmounted(() => {
.npc-tooltip { .npc-tooltip {
position: relative; position: relative;
cursor: help; cursor: help;
text-decoration: underline dotted;
} }
.npc-tooltip [role='tooltip'] { .npc-tooltip [role='tooltip'] {
@@ -421,6 +421,7 @@ onMounted(() => {
available: true, available: true,
experience: data.general.experience, experience: data.general.experience,
dedicationText: data.general.progression?.dedicationText, dedicationText: data.general.progression?.dedicationText,
bill: data.general.bill,
warnum: data.general.records.battles, warnum: data.general.records.battles,
wins: data.general.records.wins, wins: data.general.records.wins,
losses: data.general.records.losses, losses: data.general.records.losses,
+4 -1
View File
@@ -36,7 +36,10 @@ available sort keys and placement. NPC·암행부·세력도시는 Ref의 고정
`내림차순 → 오름차순 → 해제`를 순환하고, 여러 열의 방향과 우선순위를 scoped `내림차순 → 오름차순 → 해제`를 순환하고, 여러 열의 방향과 우선순위를 scoped
SFC indicator로 표시합니다. 장수 일람의 성격·특기·부상 설명은 많은 행에서 SFC indicator로 표시합니다. 장수 일람의 성격·특기·부상 설명은 많은 행에서
eager popup instance를 만들지 않는 `DirectoryTooltip.vue` scoped CSS가 소유하며, eager popup instance를 만들지 않는 `DirectoryTooltip.vue` scoped CSS가 소유하며,
mobile에서는 viewport 가장자리 8px 안의 고정 설명판으로 전환합니다. mobile에서는 viewport 가장자리 8px 안의 고정 설명판으로 전환합니다. 게임 내
tooltip trigger는 hover/focus 동작과 `cursor: help`를 유지하되, tooltip이 있는 모든
텍스트에 점선 밑줄을 반복하지 않습니다. keyboard `focus-visible` outline은 별도의
접근성 상태로 유지합니다.
## Button composition ## Button composition