merge: 최신 main을 전투 특기 비전투 커맨드 수정에 통합

This commit is contained in:
2026-08-21 03:00:27 +00:00
6 changed files with 173 additions and 17 deletions
@@ -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>