feat: 세력도시 암행부와 인사부 연동 추가

This commit is contained in:
2026-08-20 15:44:41 +00:00
parent 1431995a41
commit 1eed5e87a9
3 changed files with 774 additions and 7 deletions
@@ -0,0 +1,420 @@
import { expect, test, type Page, type Route } from '@playwright/test';
import { gameProfile, gameTrpcRoute } from './gameTestPaths.js';
type Role = 'head' | 'member';
type AppointmentInput = { destGeneralId: number; destCityId: number; officerLevel: number };
type FixtureState = {
role: Role;
appointed: boolean;
secretForbidden?: boolean;
appointmentInputs: AppointmentInput[];
};
const response = (data: unknown) => ({ result: { data } });
const errorResponse = (path: string, message: string, code = 'BAD_REQUEST') => ({
error: { message, code: -32000, data: { code, httpStatus: code === 'FORBIDDEN' ? 403 : 400, path } },
});
const operations = (route: Route): string[] =>
decodeURIComponent(new URL(route.request().url()).pathname.split('/trpc/')[1] ?? '').split(',');
const requestInput = (route: Route, index: number): Record<string, unknown> => {
const body: unknown = route.request().postData() ? route.request().postDataJSON() : {};
const record = body && typeof body === 'object' ? (body as Record<string, unknown>) : {};
const raw = record[String(index)] ?? record;
const payload = raw && typeof raw === 'object' ? (raw as Record<string, unknown>) : {};
const input = payload.input && typeof payload.input === 'object' ? (payload.input as Record<string, unknown>) : {};
const json = payload.json ?? input.json ?? payload;
return json && typeof json === 'object' ? (json as Record<string, unknown>) : {};
};
const cities = [
{
id: 1,
name: '허창',
level: 7,
region: 2,
population: 99_000,
populationMax: 100_000,
agriculture: 9_500,
agricultureMax: 10_000,
commerce: 8_000,
commerceMax: 10_000,
security: 8_000,
securityMax: 10_000,
trust: 80,
trade: 100,
defence: 4_500,
defenceMax: 5_000,
wall: 4_500,
wallMax: 5_000,
supplyState: 1,
frontState: 0,
incomes: { gold: 1000, rice: 900, wall: 800 },
},
{
id: 2,
name: '낙양',
level: 6,
region: 2,
population: 60_000,
populationMax: 100_000,
agriculture: 5_000,
agricultureMax: 10_000,
commerce: 5_000,
commerceMax: 10_000,
security: 5_000,
securityMax: 10_000,
trust: 70,
trade: 90,
defence: 2_500,
defenceMax: 5_000,
wall: 2_500,
wallMax: 5_000,
supplyState: 1,
frontState: 0,
incomes: { gold: 800, rice: 700, wall: 600 },
},
] as const;
const overviewFixture = (state: FixtureState) => ({
me: { id: state.role === 'head' ? 20 : 21, officerLevel: state.role === 'head' ? 5 : 1 },
nation: {
id: 1,
name: '위',
color: '#008000',
level: 3,
typeCode: 'che_법가',
capitalCityId: 1,
rate: 20,
},
chiefStatMin: 65,
cities: cities.map((city) => ({
...city,
officers: {
4: state.appointed
? { id: 21, name: '장료', npcState: 0, officerLevel: 4, cityId: 1, cityName: '허창' }
: null,
3: null,
2: null,
},
})),
generals: [
{
id: 1,
name: '조조',
npcState: 0,
officerLevel: 12,
cityId: 1,
officerCity: 0,
stats: { leadership: 90, strength: 80, intelligence: 90 },
},
{
id: 20,
name: '순욱',
npcState: 0,
officerLevel: 5,
cityId: 1,
officerCity: 0,
stats: { leadership: 75, strength: 70, intelligence: 90 },
},
{
id: 21,
name: '장료',
npcState: 0,
officerLevel: state.appointed ? 4 : 1,
cityId: 1,
officerCity: state.appointed ? 1 : 0,
stats: { leadership: 80, strength: 70, intelligence: 50 },
},
{
id: 22,
name: '조홍',
npcState: 2,
officerLevel: 1,
cityId: 2,
officerCity: 0,
stats: { leadership: 60, strength: 65, intelligence: 40 },
},
],
});
const secretGeneral = (id: number, name: string, cityId: number, overrides: Record<string, unknown> = {}) => ({
id,
name,
npcState: 0,
injury: 0,
stats: { leadership: 70, strength: 70, intelligence: 70 },
leadershipBonus: 0,
experienceLevel: 9,
troopId: 0,
troopName: null,
gold: 1000,
rice: 2000,
cityId,
cityName: cityId === 1 ? '허창' : '낙양',
defenceTrain: 90,
defenceTrainText: '☆',
crewTypeId: 1,
crewTypeName: '보병',
crew: 300,
train: 90,
atmos: 90,
killTurn: 7,
turnTime: '2026-01-01T01:02:00.000Z',
reservedCommands: ['농지 개간', '훈련'],
...overrides,
});
const secretFixture = () => ({
nation: { id: 1, name: '위', color: '#008000', level: 3 },
viewer: { generalId: 20, permission: 1 },
summary: {
gold: 4000,
rice: 8000,
crew: 1200,
generalCount: 4,
averageGold: 1000,
averageRice: 2000,
readiness: {
90: { crew: 1200, generals: 4 },
80: { crew: 1200, generals: 4 },
60: { crew: 1200, generals: 4 },
},
},
generals: [
secretGeneral(1, '조조', 1, { leadershipBonus: 6 }),
secretGeneral(20, '순욱', 1, { leadershipBonus: 3 }),
secretGeneral(21, '장료', 1, {
stats: { leadership: 80, strength: 70, intelligence: 50 },
}),
secretGeneral(22, '조홍', 2, { npcState: 2, reservedCommands: [] }),
],
});
const personnelGeneral = (id: number, name: string, officerLevel: number, overrides: Record<string, unknown> = {}) => ({
id,
name,
npcState: 0,
officerLevel,
cityId: 1,
cityName: '허창',
troopId: 0,
troopName: null,
picture: null,
imageServer: 0,
officerCity: officerLevel >= 2 && officerLevel <= 4 ? 1 : 0,
officerCityName: officerLevel >= 2 && officerLevel <= 4 ? '허창' : null,
stats: { leadership: 70, strength: 70, intelligence: 70 },
experience: 100,
dedication: 200,
injury: 0,
gold: 1000,
rice: 1000,
crew: 100,
personality: null,
specialDomestic: null,
specialWar: null,
belong: 10,
permission: 'normal',
...overrides,
});
const personnelFixture = (state: FixtureState) => {
const allGenerals = [
personnelGeneral(1, '조조', 12),
personnelGeneral(20, '순욱', 5, { stats: { leadership: 75, strength: 70, intelligence: 90 } }),
personnelGeneral(21, '장료', state.appointed ? 4 : 1, {
stats: { leadership: 80, strength: 70, intelligence: 50 },
}),
personnelGeneral(22, '조홍', 1, {
npcState: 2,
cityId: 2,
cityName: '낙양',
stats: { leadership: 60, strength: 65, intelligence: 40 },
}),
];
const canManage = state.role === 'head';
return {
me: {
id: canManage ? 20 : 21,
officerLevel: canManage ? 5 : 1,
canManage,
canChangePermissions: false,
canKick: canManage,
},
nation: {
id: 1,
name: '위',
color: '#008000',
level: 3,
typeCode: 'che_법가',
capitalCityId: 1,
chiefSet: 0,
},
chiefStatMin: 65,
generals: canManage ? allGenerals : [],
chiefAssignments: { 12: allGenerals[0], 5: allGenerals[1] },
cityAssignments: cities.map((city) => ({
id: city.id,
name: city.name,
level: city.level,
region: city.region,
officerSet: city.id === 1 && state.appointed ? 1 << 4 : 0,
officers: {
4: city.id === 1 && state.appointed ? allGenerals[2] : null,
3: null,
2: null,
},
})),
awards: { tigers: [], eagles: [] },
permissionCandidates: { ambassadors: [], auditors: [] },
};
};
const install = async (page: Page, state: FixtureState): Promise<void> => {
await page.addInitScript((profile) => {
localStorage.setItem('sammo-game-token', 'ga_city_office');
localStorage.setItem('sammo-game-profile', profile);
}, gameProfile);
await page.route(gameTrpcRoute, async (route) => {
const result = operations(route).map((operation, index) => {
if (operation === 'auth.status') return response({ ok: true });
if (operation === 'lobby.info') return response({ myGeneral: { id: 20, name: '순욱' } });
if (operation === 'join.getConfig') return response({});
if (operation === 'nation.getCityOverview') return response(overviewFixture(state));
if (operation === 'nation.getSecretGeneralList') {
return state.secretForbidden
? errorResponse(
operation,
'권한이 부족합니다. 수뇌부가 아니거나 사관년도가 부족합니다.',
'FORBIDDEN'
)
: response(secretFixture());
}
if (operation === 'nation.getPersonnelInfo') return response(personnelFixture(state));
if (operation === 'nation.appoint') {
const input = requestInput(route, index);
state.appointmentInputs.push({
destGeneralId: Number(input.destGeneralId),
destCityId: Number(input.destCityId),
officerLevel: Number(input.officerLevel),
});
state.appointed = true;
return response({ ok: true });
}
return errorResponse(operation, `Unhandled fixture operation: ${operation}`);
});
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(result) });
});
};
test('암행부 행을 도시별로 나누고 수뇌의 인사부 즉시 임명을 반영한다', async ({ page }, testInfo) => {
const state: FixtureState = { role: 'head', appointed: false, appointmentInputs: [] };
await install(page, state);
await page.setViewportSize({ width: 1200, height: 900 });
await page.goto('nation/cities');
await expect(page.locator('.nation-cities-page')).toBeVisible();
await expect(page.locator('.city-user-table')).toHaveCount(0);
await expect(page.getByRole('button', { name: '인사부 연동' })).toHaveCount(0);
await page.getByRole('button', { name: '암행부 연동' }).click();
await expect(page.locator('.city-user-table')).toHaveCount(2);
await expect(page.locator('.city[data-city-id="1"] .city-user-table tr[data-general-id="21"]')).toContainText(
'장료'
);
await expect(page.locator('.city[data-city-id="2"] .city-user-table tr[data-general-id="22"]')).toContainText(
'조홍'
);
await expect(page.locator('.city[data-city-id="2"] .city-user-table tr[data-general-id="21"]')).toHaveCount(0);
await expect(page.locator('.city[data-city-id="1"] tr[data-general-id="21"] .command-attention')).toHaveText(
'농지 개간'
);
const integratedBox = await page.locator('.city[data-city-id="1"] .city-user-table').evaluate((element) => {
const rect = element.getBoundingClientRect();
const style = getComputedStyle(element);
return { width: rect.width, borderCollapse: style.borderCollapse, fontSize: style.fontSize };
});
expect(integratedBox).toEqual({ width: 941, borderCollapse: 'collapse', fontSize: '14px' });
await page.getByRole('button', { name: '인사부 연동' }).click();
const ordinaryRow = page.locator('.city[data-city-id="1"] tr[data-general-id="21"]');
await expect(ordinaryRow.locator('.appointment-button')).toHaveCount(3);
await expect(ordinaryRow.locator('.mode-4')).toBeEnabled();
await expect(ordinaryRow.locator('.mode-3')).toBeDisabled();
await expect(ordinaryRow.locator('.mode-2')).toBeEnabled();
await expect(page.locator('tr[data-general-id="1"] .appointment-button')).toHaveCount(0);
const disabledStyle = await ordinaryRow.locator('.mode-3').evaluate((button) => {
const style = getComputedStyle(button);
return { borderTopWidth: style.borderTopWidth, backgroundColor: style.backgroundColor };
});
expect(disabledStyle).toEqual({ borderTopWidth: '0px', backgroundColor: 'rgba(0, 0, 0, 0)' });
const appointButton = page.getByRole('button', { name: '장료을(를) 허창 태수로 임명' });
await appointButton.hover();
expect(await appointButton.evaluate((button) => getComputedStyle(button).cursor)).toBe('pointer');
await appointButton.focus();
await expect(appointButton).toBeFocused();
await page.screenshot({ path: testInfo.outputPath('nation-city-integrated-desktop.png'), fullPage: true });
await appointButton.click();
await expect.poll(() => state.appointmentInputs).toEqual([{ destGeneralId: 21, destCityId: 1, officerLevel: 4 }]);
await expect(page.locator('.city[data-city-id="1"] .officer-4-value')).toHaveText('장료');
await expect(page.locator('.city[data-city-id="1"] .officer-4-value')).toHaveClass(/effective-officer/u);
await expect(page.locator('.city[data-city-id="1"] tr[data-general-id="21"] .mode-4')).toBeDisabled();
await page.setViewportSize({ width: 500, height: 900 });
expect(await page.locator('.nation-cities-page').evaluate((element) => element.getBoundingClientRect().width)).toBe(
1000
);
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeGreaterThanOrEqual(1000);
await page.screenshot({ path: testInfo.outputPath('nation-city-integrated-mobile.png'), fullPage: true });
});
test('수뇌 대상은 재확인하고 일반 장수에게는 임명 버튼을 열지 않는다', async ({ page }) => {
const headState: FixtureState = { role: 'head', appointed: false, appointmentInputs: [] };
await install(page, headState);
await page.goto('nation/cities');
await page.getByRole('button', { name: '암행부 연동' }).click();
await page.getByRole('button', { name: '인사부 연동' }).click();
const chiefButton = page.getByRole('button', { name: '순욱을(를) 허창 태수로 임명' });
expect(await chiefButton.evaluate((button) => getComputedStyle(button).color)).toBe('rgb(255, 0, 0)');
page.once('dialog', async (dialog) => {
expect(dialog.message()).toBe('수뇌입니다. 임명할까요?');
await dialog.dismiss();
});
await chiefButton.click();
await expect.poll(() => headState.appointmentInputs.length).toBe(0);
await page.unroute(gameTrpcRoute);
const memberState: FixtureState = { role: 'member', appointed: false, appointmentInputs: [] };
await install(page, memberState);
await page.reload();
await page.getByRole('button', { name: '암행부 연동' }).click();
page.once('dialog', async (dialog) => {
expect(dialog.message()).toBe('수뇌가 아닙니다!');
await dialog.accept();
});
await page.getByRole('button', { name: '인사부 연동' }).click();
await expect(page.locator('.appointment-button')).toHaveCount(0);
expect(memberState.appointmentInputs).toEqual([]);
});
test('암행부 권한 거부는 도시 기밀 행과 인사부 연동을 열지 않는다', async ({ page }) => {
const state: FixtureState = {
role: 'member',
appointed: false,
secretForbidden: true,
appointmentInputs: [],
};
await install(page, state);
await page.goto('nation/cities');
await page.getByRole('button', { name: '암행부 연동' }).click();
await expect(page.locator('.integration-error')).toContainText('권한이 부족합니다.');
await expect(page.locator('.city-user-table')).toHaveCount(0);
await expect(page.getByRole('button', { name: '인사부 연동' })).toHaveCount(0);
expect(state.appointmentInputs).toEqual([]);
});
@@ -21,6 +21,7 @@ export default defineConfig({
'troop.spec.ts',
'board.spec.ts',
'inGameInfo.spec.ts',
'nationCityOfficeIntegration.spec.ts',
'inGameMenus.spec.ts',
'nationOffices.spec.ts',
'diplomacy.spec.ts',