fix(game-ui): 암행부 예약 명령 요약을 복원
암행부 API가 앞 5턴의 action과 args를 함께 반환하도록 수정한다.\n개인턴·사령부 공용 formatter를 암행부와 세력도시 연동 표에 적용하고 Chromium 회귀를 추가한다.
This commit is contained in:
@@ -64,16 +64,16 @@ export const getSecretGeneralList = accessAuthedProcedure.query(async ({ ctx })
|
||||
const turns = generalIds.length
|
||||
? await ctx.db.generalTurn.findMany({
|
||||
where: { generalId: { in: generalIds }, turnIdx: { lt: 5 } },
|
||||
select: { generalId: true, turnIdx: true, actionCode: true },
|
||||
select: { generalId: true, turnIdx: true, actionCode: true, arg: true },
|
||||
orderBy: [{ generalId: 'asc' }, { turnIdx: 'asc' }],
|
||||
})
|
||||
: [];
|
||||
const cityNames = new Map(cities.map((city) => [city.id, city.name]));
|
||||
const troopNames = new Map(troops.map((troop) => [troop.troopLeaderId, troop.name]));
|
||||
const turnMap = new Map<number, string[]>();
|
||||
const turnMap = new Map<number, Array<{ action: string; args: unknown }>>();
|
||||
for (const turn of turns) {
|
||||
const list = turnMap.get(turn.generalId) ?? [];
|
||||
list[turn.turnIdx] = turn.actionCode;
|
||||
list[turn.turnIdx] = { action: turn.actionCode, args: turn.arg };
|
||||
turnMap.set(turn.generalId, list);
|
||||
}
|
||||
const generals = generalRows.map((general) => {
|
||||
|
||||
@@ -84,7 +84,16 @@ const fixture = (generals: GeneralRow[], userId = 'u1') => {
|
||||
city: { findMany: vi.fn(async () => [{ id: 1, name: '업' }]) },
|
||||
troop: { findMany: vi.fn(async () => [{ troopLeaderId: 2, name: '선봉대' }]) },
|
||||
worldState: { findFirst: vi.fn(async () => null) },
|
||||
generalTurn: { findMany: vi.fn(async () => [{ generalId: 1, turnIdx: 0, actionCode: '징병' }]) },
|
||||
generalTurn: {
|
||||
findMany: vi.fn(async () => [
|
||||
{
|
||||
generalId: 1,
|
||||
turnIdx: 0,
|
||||
actionCode: 'che_징병',
|
||||
arg: { crewType: 1, amount: 300 },
|
||||
},
|
||||
]),
|
||||
},
|
||||
generalAccessLog: {
|
||||
findMany: vi.fn(async () => generals.map((g) => ({ generalId: g.id, refreshScoreTotal: g.id * 10 }))),
|
||||
},
|
||||
@@ -134,6 +143,14 @@ describe('nation general and secret office permissions', () => {
|
||||
expect(result.viewer).toEqual({ generalId: 2, permission: 2 });
|
||||
expect(result.generals.map((g) => g.id)).toEqual([1, 2, 3]);
|
||||
expect(result.summary).toMatchObject({ gold: 5000, crew: 800, generalCount: 3 });
|
||||
expect(result.generals[0]?.reservedCommands).toEqual([
|
||||
{ action: 'che_징병', args: { crewType: 1, amount: 300 } },
|
||||
]);
|
||||
expect(db.generalTurn.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
select: { generalId: true, turnIdx: true, actionCode: true, arg: true },
|
||||
})
|
||||
);
|
||||
expect(db.general.findFirst).toHaveBeenCalledWith({ where: { userId: 'u2' } });
|
||||
expect(db.general.findMany).toHaveBeenCalledWith(expect.objectContaining({ where: { nationId: 1 } }));
|
||||
});
|
||||
|
||||
@@ -76,6 +76,37 @@ const cities = [
|
||||
},
|
||||
] as const;
|
||||
|
||||
const commandTable = {
|
||||
general: [
|
||||
{
|
||||
category: '내정',
|
||||
values: [
|
||||
{
|
||||
key: 'che_농지개간',
|
||||
name: '농지 개간',
|
||||
reqArg: false,
|
||||
status: 'available',
|
||||
possible: true,
|
||||
inputFields: [],
|
||||
},
|
||||
{ key: 'che_훈련', name: '훈련', reqArg: false, status: 'available', possible: true, inputFields: [] },
|
||||
],
|
||||
},
|
||||
],
|
||||
nation: [],
|
||||
inputOptions: {
|
||||
cities: cities.map((city) => ({ value: city.id, label: city.name })),
|
||||
nations: [{ value: 1, label: '위' }],
|
||||
generals: [],
|
||||
crewTypes: [{ value: 1, label: '보병' }],
|
||||
armTypes: [],
|
||||
nationTypes: [],
|
||||
colors: [],
|
||||
items: {},
|
||||
recruitment: null,
|
||||
},
|
||||
};
|
||||
|
||||
const overviewFixture = (state: FixtureState) => ({
|
||||
me: { id: state.role === 'head' ? 20 : 21, officerLevel: state.role === 'head' ? 5 : 1 },
|
||||
nation: {
|
||||
@@ -161,7 +192,10 @@ const secretGeneral = (id: number, name: string, cityId: number, overrides: Reco
|
||||
atmos: 90,
|
||||
killTurn: 7,
|
||||
turnTime: '2026-01-01T01:02:00.000Z',
|
||||
reservedCommands: ['농지 개간', '훈련'],
|
||||
reservedCommands: [
|
||||
{ action: 'che_농지개간', args: {} },
|
||||
{ action: 'che_훈련', args: {} },
|
||||
],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
@@ -292,6 +326,7 @@ const install = async (page: Page, state: FixtureState): Promise<void> => {
|
||||
: response(secretFixture());
|
||||
}
|
||||
if (operation === 'nation.getPersonnelInfo') return response(personnelFixture(state));
|
||||
if (operation === 'turns.getCommandTable') return response(commandTable);
|
||||
if (operation === 'nation.appoint') {
|
||||
const input = requestInput(route, index);
|
||||
state.appointmentInputs.push({
|
||||
|
||||
@@ -4,6 +4,35 @@ import { gameProfile, gameTrpcRoute } from './gameTestPaths.js';
|
||||
const response = (data: unknown) => ({ result: { data } });
|
||||
const operations = (route: Route) =>
|
||||
decodeURIComponent(new URL(route.request().url()).pathname.split('/trpc/')[1] ?? '').split(',');
|
||||
const commandTable = {
|
||||
general: [
|
||||
{
|
||||
category: '전체',
|
||||
values: [
|
||||
{ key: '휴식', name: '휴식', reqArg: false, status: 'available', possible: true, inputFields: [] },
|
||||
{ key: 'che_이동', name: '이동', reqArg: true, status: 'available', possible: true, inputFields: [] },
|
||||
{ key: 'che_징병', name: '징병', reqArg: true, status: 'available', possible: true, inputFields: [] },
|
||||
{ key: 'che_증여', name: '증여', reqArg: true, status: 'available', possible: true, inputFields: [] },
|
||||
{ key: 'che_화계', name: '화계', reqArg: true, status: 'available', possible: true, inputFields: [] },
|
||||
],
|
||||
},
|
||||
],
|
||||
nation: [],
|
||||
inputOptions: {
|
||||
cities: [{ value: 1, label: '업 (위)' }],
|
||||
nations: [{ value: 1, label: '위' }],
|
||||
generals: [
|
||||
{ value: 1, label: '테스트장수 (위 · 업)' },
|
||||
{ value: 2, label: '다른장수 (위 · 업)' },
|
||||
],
|
||||
crewTypes: [{ value: 1, label: '보병' }],
|
||||
armTypes: [],
|
||||
nationTypes: [],
|
||||
colors: [],
|
||||
items: {},
|
||||
recruitment: null,
|
||||
},
|
||||
};
|
||||
const general = {
|
||||
id: 1,
|
||||
name: '테스트장수',
|
||||
@@ -112,11 +141,18 @@ const install = async (page: Page, secretAllowed = true) => {
|
||||
atmos: 90,
|
||||
killTurn: 7,
|
||||
turnTime: '2026-01-01T01:02:00.000Z',
|
||||
reservedCommands: ['징병', '훈련'],
|
||||
reservedCommands: [
|
||||
{ action: 'che_이동', args: { destCityId: 1 } },
|
||||
{ action: 'che_징병', args: { crewType: 1, amount: 300 } },
|
||||
{ action: 'che_증여', args: { destGeneralId: 2, isGold: false, amount: 200 } },
|
||||
{ action: 'che_화계', args: { destCityId: 1 } },
|
||||
{ action: '휴식', args: {} },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
if (operation === 'turns.getCommandTable') return response(commandTable);
|
||||
return { error: { message: `unhandled ${operation}`, data: { code: 'BAD_REQUEST' } } };
|
||||
});
|
||||
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(results) });
|
||||
@@ -311,7 +347,7 @@ test('nation generals filter buttons open Ref operator menus and apply compound
|
||||
await page.screenshot({ path: testInfo.outputPath('core-mobile-filter-menu.png'), fullPage: true });
|
||||
});
|
||||
|
||||
test('both pages preserve the legacy 1000px overflow contract at 500px', async ({ page }) => {
|
||||
test('both pages preserve the legacy 1000px overflow contract at 500px', async ({ page }, testInfo) => {
|
||||
await install(page);
|
||||
await page.setViewportSize({ width: 500, height: 900 });
|
||||
for (const path of ['nation/generals', 'nation/secret']) {
|
||||
@@ -319,18 +355,60 @@ test('both pages preserve the legacy 1000px overflow contract at 500px', async (
|
||||
await expect(
|
||||
page.locator(path.endsWith('secret') ? '#secret-general-list' : '#nation-general-list')
|
||||
).toBeVisible();
|
||||
expect(await page.locator('main').evaluate((el) => el.getBoundingClientRect().width)).toBe(1000);
|
||||
const fixedWidthElement = path.endsWith('secret')
|
||||
? page.locator('.secret-page .title').first()
|
||||
: page.locator('.general-page');
|
||||
expect(await fixedWidthElement.evaluate((el) => el.getBoundingClientRect().width)).toBe(1000);
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeGreaterThanOrEqual(1000);
|
||||
if (path.endsWith('secret')) {
|
||||
await page.screenshot({ path: testInfo.outputPath('secret-command-brief-mobile-500.png'), fullPage: true });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('secret office renders summary, turns, and the forbidden error flow', async ({ page }) => {
|
||||
test('secret office renders five Ref-style command briefs and the forbidden error flow', async ({ page }, testInfo) => {
|
||||
await install(page);
|
||||
await page.setViewportSize({ width: 1200, height: 900 });
|
||||
await page.goto('nation/secret');
|
||||
await expect(page.locator('.summary')).toContainText('전체 금');
|
||||
await expect(page.locator('#secret-general-list')).toContainText('1 : 징병');
|
||||
expect(await page.locator('.secret-page').evaluate((el) => el.getBoundingClientRect().width)).toBe(1000);
|
||||
const commandRows = page.locator('#secret-general-list .turns div');
|
||||
await expect(commandRows).toHaveCount(5);
|
||||
await expect(commandRows).toHaveText([
|
||||
'1 : 【업】으로 이동',
|
||||
'2 : 【보병】 300명 징병',
|
||||
'3 : 【다른장수】에게 쌀 200을 증여',
|
||||
'4 : 【업】에 화계실행',
|
||||
'5 : 휴식',
|
||||
]);
|
||||
await expect(commandRows.nth(2)).toHaveAttribute('title', '【다른장수】에게 쌀 200을 증여');
|
||||
const geometry = await page.locator('#secret-general-list .turns').evaluate((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
const style = getComputedStyle(element);
|
||||
return {
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
fontSize: style.fontSize,
|
||||
textAlign: style.textAlign,
|
||||
horizontalOverflow: element.scrollWidth - element.clientWidth,
|
||||
};
|
||||
});
|
||||
expect(geometry.width).toBeGreaterThanOrEqual(190);
|
||||
expect(geometry.width).toBeLessThanOrEqual(230);
|
||||
expect(geometry.height).toBeGreaterThanOrEqual(60);
|
||||
expect(geometry).toMatchObject({ fontSize: '11px', textAlign: 'left', horizontalOverflow: 0 });
|
||||
const titleBox = await page.locator('.secret-page .title').first().boundingBox();
|
||||
const listBox = await page.locator('#secret-general-list').boundingBox();
|
||||
expect(titleBox?.width).toBe(1000);
|
||||
expect(listBox?.width).toBe(974);
|
||||
await testInfo.attach('secret-command-brief-geometry', {
|
||||
body: JSON.stringify(
|
||||
{ viewport: { width: 1200, height: 900 }, titleBox, listBox, commandCell: geometry },
|
||||
null,
|
||||
2
|
||||
),
|
||||
contentType: 'application/json',
|
||||
});
|
||||
await page.screenshot({ path: testInfo.outputPath('secret-command-brief-desktop-1200.png'), fullPage: true });
|
||||
|
||||
await page.unroute(gameTrpcRoute);
|
||||
await install(page, false);
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
import { formatServerDateTime } from '@sammo-ts/common/time/ServerDateTime';
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { formatReservedCommandBrief } from '../components/command/reservedCommandBrief';
|
||||
import type { CommandTable } from '../components/command/types';
|
||||
import { useGameFeedback } from '../composables/useGameFeedback';
|
||||
import { getNpcColor } from '../utils/npcColor';
|
||||
import { legacyNationTextColor } from '../utils/legacyNationColor';
|
||||
@@ -13,10 +15,12 @@ type SecretResult = Awaited<ReturnType<typeof trpc.nation.getSecretGeneralList.q
|
||||
type PersonnelResult = Awaited<ReturnType<typeof trpc.nation.getPersonnelInfo.query>>;
|
||||
type City = Result['cities'][number];
|
||||
type SecretGeneral = SecretResult['generals'][number];
|
||||
type ReservedCommand = SecretGeneral['reservedCommands'][number];
|
||||
type OfficerLevel = 2 | 3 | 4;
|
||||
type Sort = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12;
|
||||
const data = ref<Result | null>(null);
|
||||
const secretData = ref<SecretResult | null>(null);
|
||||
const commandTable = ref<CommandTable | null>(null);
|
||||
const personnelData = ref<PersonnelResult | null>(null);
|
||||
const error = ref('');
|
||||
const integrationError = ref('');
|
||||
@@ -148,6 +152,8 @@ const commandNeedsAttention = (city: City, command: string): boolean => {
|
||||
if (normalized.includes('성벽보수')) return city.wall - city.wallMax > -700;
|
||||
return false;
|
||||
};
|
||||
const commandBrief = (command: ReservedCommand): string =>
|
||||
formatReservedCommandBrief('general', command.action, command.args, commandTable.value);
|
||||
|
||||
const loadSecretIntegration = async (): Promise<void> => {
|
||||
if (secretLoading.value) {
|
||||
@@ -161,7 +167,10 @@ const loadSecretIntegration = async (): Promise<void> => {
|
||||
secretLoading.value = true;
|
||||
integrationError.value = '';
|
||||
try {
|
||||
secretData.value = await trpc.nation.getSecretGeneralList.query();
|
||||
const secret = await trpc.nation.getSecretGeneralList.query();
|
||||
const table = await trpc.turns.getCommandTable.query({ generalId: secret.viewer.generalId });
|
||||
secretData.value = secret;
|
||||
commandTable.value = table;
|
||||
} catch (cause) {
|
||||
integrationError.value = cause instanceof Error ? cause.message : '암행부 연동에 실패했습니다.';
|
||||
showErrorToast(integrationError.value);
|
||||
@@ -496,13 +505,17 @@ onMounted(async () => {
|
||||
<div
|
||||
v-for="(command, commandIndex) in general.reservedCommands"
|
||||
:key="commandIndex"
|
||||
:title="commandBrief(command)"
|
||||
>
|
||||
{{ commandIndex + 1 }} :
|
||||
<span
|
||||
:class="{
|
||||
'command-attention': commandNeedsAttention(city, command),
|
||||
'command-attention': commandNeedsAttention(
|
||||
city,
|
||||
commandBrief(command)
|
||||
),
|
||||
}"
|
||||
>{{ command }}</span
|
||||
>{{ commandBrief(command) }}</span
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import { formatServerDateTime } from '@sammo-ts/common/time/ServerDateTime';
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { formatReservedCommandBrief } from '../components/command/reservedCommandBrief';
|
||||
import type { CommandTable } from '../components/command/types';
|
||||
import { trpc } from '../utils/trpc';
|
||||
type Result = Awaited<ReturnType<typeof trpc.nation.getSecretGeneralList.query>>;
|
||||
type ReservedCommand = Result['generals'][number]['reservedCommands'][number];
|
||||
type Sort = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8;
|
||||
const data = ref<Result | null>(null);
|
||||
const commandTable = ref<CommandTable | null>(null);
|
||||
const error = ref('');
|
||||
const loading = ref(false);
|
||||
const sort = ref<Sort>(7);
|
||||
@@ -13,7 +17,10 @@ const load = async () => {
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
data.value = await trpc.nation.getSecretGeneralList.query();
|
||||
const result = await trpc.nation.getSecretGeneralList.query();
|
||||
const table = await trpc.turns.getCommandTable.query({ generalId: result.viewer.generalId });
|
||||
data.value = result;
|
||||
commandTable.value = table;
|
||||
} catch (cause) {
|
||||
error.value = cause instanceof Error ? cause.message : '암행부를 불러오지 못했습니다.';
|
||||
} finally {
|
||||
@@ -35,6 +42,8 @@ const generals = computed(() =>
|
||||
const closeWindow = () => window.close();
|
||||
const displayName = (general: { name: string; npcState: number }) =>
|
||||
general.npcState > 0 && !/^[ⓜⓝ㉥]/u.test(general.name) ? `ⓝ${general.name}` : general.name;
|
||||
const commandBrief = (command: ReservedCommand): string =>
|
||||
formatReservedCommandBrief('general', command.action, command.args, commandTable.value);
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
@@ -142,8 +151,12 @@ onMounted(load);
|
||||
<td class="turns">
|
||||
<template v-if="general.npcState >= 2">NPC 장수</template
|
||||
><template v-else
|
||||
><div v-for="(command, index) in general.reservedCommands" :key="index">
|
||||
{{ index + 1 }} : {{ command }}
|
||||
><div
|
||||
v-for="(command, index) in general.reservedCommands"
|
||||
:key="index"
|
||||
:title="commandBrief(command)"
|
||||
>
|
||||
{{ index + 1 }} : {{ commandBrief(command) }}
|
||||
</div></template
|
||||
>
|
||||
</td>
|
||||
|
||||
Reference in New Issue
Block a user