Complete instant diplomacy response parity
This commit is contained in:
@@ -0,0 +1,291 @@
|
||||
import { expect, test, type Page, type Route } from '@playwright/test';
|
||||
import { mkdir } from 'node:fs/promises';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
import { canonicalFrontendFixture as fixture } from './fixtures/canonical';
|
||||
|
||||
const artifactRoot = process.env.FRONTEND_PARITY_ARTIFACT_DIR;
|
||||
const response = (data: unknown) => ({ result: { data } });
|
||||
|
||||
const operationNames = (route: Route): string[] => {
|
||||
const pathname = new URL(route.request().url()).pathname;
|
||||
return decodeURIComponent(pathname.slice(pathname.lastIndexOf('/trpc/') + 6)).split(',');
|
||||
};
|
||||
|
||||
const general = {
|
||||
id: 1,
|
||||
name: '수락장수',
|
||||
npcState: 0,
|
||||
nationId: 1,
|
||||
cityId: 1,
|
||||
troopId: 0,
|
||||
picture: null,
|
||||
imageServer: 0,
|
||||
officerLevel: 5,
|
||||
stats: { leadership: 80, strength: 70, intelligence: 90 },
|
||||
gold: 1000,
|
||||
rice: 1000,
|
||||
crew: 500,
|
||||
train: 100,
|
||||
atmos: 100,
|
||||
injury: 0,
|
||||
experience: 1200,
|
||||
dedication: 900,
|
||||
items: { horse: null, weapon: null, book: null, item: null },
|
||||
};
|
||||
|
||||
const generalContext = {
|
||||
general,
|
||||
city: {
|
||||
id: 1,
|
||||
name: '낙양',
|
||||
level: 7,
|
||||
nationId: 1,
|
||||
population: 50000,
|
||||
agriculture: 5000,
|
||||
commerce: 5000,
|
||||
security: 5000,
|
||||
defence: 5000,
|
||||
wall: 5000,
|
||||
supplyState: 1,
|
||||
frontState: 2,
|
||||
},
|
||||
nation: {
|
||||
id: 1,
|
||||
name: '수락국',
|
||||
color: '#d32f2f',
|
||||
level: 5,
|
||||
gold: 10000,
|
||||
rice: 10000,
|
||||
tech: 1200,
|
||||
typeCode: 'che_군벌',
|
||||
capitalCityId: 1,
|
||||
},
|
||||
settings: {},
|
||||
penalties: {},
|
||||
};
|
||||
|
||||
const diplomacyMessage = {
|
||||
id: 701,
|
||||
msgType: 'diplomacy',
|
||||
src: { generalId: 2, generalName: '제안장수', nationId: 2, nationName: '제안국' },
|
||||
dest: { generalId: 1, generalName: '수락장수', nationId: 1, nationName: '수락국' },
|
||||
text: '제안국에서 191년 2월까지 불가침을 제안했습니다.',
|
||||
option: {
|
||||
action: 'noAggression',
|
||||
year: 191,
|
||||
month: 2,
|
||||
used: false,
|
||||
deletable: false,
|
||||
},
|
||||
time: '0190-03-01 00:00:00',
|
||||
};
|
||||
|
||||
const messageBundle = (visible: boolean, canRespondDiplomacy = true) => ({
|
||||
result: true,
|
||||
private: [],
|
||||
public: [],
|
||||
national: [],
|
||||
diplomacy: visible ? [diplomacyMessage] : [],
|
||||
sequence: visible ? diplomacyMessage.id : -1,
|
||||
nationId: 1,
|
||||
generalName: general.name,
|
||||
canRespondDiplomacy,
|
||||
latestRead: { diplomacy: 0, private: 0 },
|
||||
});
|
||||
|
||||
const installFixture = async (
|
||||
page: Page,
|
||||
options: { acceptResponse: boolean; canRespondDiplomacy?: boolean }
|
||||
): Promise<Array<{ operation: string; body: unknown }>> => {
|
||||
let visible = true;
|
||||
const mutations: Array<{ operation: string; body: unknown }> = [];
|
||||
await page.addInitScript(
|
||||
({ gameToken, profile }) => {
|
||||
window.localStorage.setItem('sammo-game-token', gameToken);
|
||||
window.localStorage.setItem('sammo-game-profile', profile);
|
||||
},
|
||||
{
|
||||
gameToken: fixture.game.session.gameToken,
|
||||
profile: fixture.game.session.profile,
|
||||
}
|
||||
);
|
||||
await page.route('**/image/**', (route) => route.fulfill({ status: 204, body: '' }));
|
||||
await page.route('**/che/api/events**', (route) => route.abort());
|
||||
await page.route('**/che/api/trpc/**', async (route) => {
|
||||
const operations = operationNames(route);
|
||||
const requestBody = route.request().postDataJSON();
|
||||
const results = operations.map((operation) => {
|
||||
if (operation === 'lobby.info') {
|
||||
return response({ ...fixture.game.lobby, myGeneral: general });
|
||||
}
|
||||
if (operation === 'general.me') return response(generalContext);
|
||||
if (operation === 'world.getMapLayout') return response(fixture.game.mapLayout);
|
||||
if (operation === 'world.getMap') {
|
||||
return response({ ...fixture.game.map, myCity: 1, myNation: 1 });
|
||||
}
|
||||
if (operation === 'turns.getCommandTable') return response({ general: [], nation: [] });
|
||||
if (operation === 'turns.reserved.getGeneral' || operation === 'turns.reserved.getNation') {
|
||||
return response([]);
|
||||
}
|
||||
if (operation === 'messages.getRecent') {
|
||||
return response(messageBundle(visible, options.canRespondDiplomacy));
|
||||
}
|
||||
if (operation === 'messages.respond') {
|
||||
mutations.push({ operation, body: requestBody });
|
||||
if (options.acceptResponse) {
|
||||
visible = false;
|
||||
return response({ result: true, reason: '불가침 제의를 수락했습니다.' });
|
||||
}
|
||||
return response({ result: false, reason: '현재 외교 상태에서는 수락할 수 없습니다.' });
|
||||
}
|
||||
throw new Error(`Unhandled instant diplomacy fixture operation: ${operation}`);
|
||||
});
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(results),
|
||||
});
|
||||
});
|
||||
return mutations;
|
||||
};
|
||||
|
||||
const openDiplomacyTab = async (page: Page) => {
|
||||
await page.goto('http://127.0.0.1:15102/che/');
|
||||
await expect(page.getByRole('heading', { name: '전장 현황' })).toBeVisible();
|
||||
await page.getByRole('button', { name: '외교', exact: true }).last().click();
|
||||
await expect(page.getByText(diplomacyMessage.text)).toBeVisible();
|
||||
};
|
||||
|
||||
test.describe('instant diplomacy response UI', () => {
|
||||
test('renders and accepts the actionable message in desktop Chromium', async ({ page }) => {
|
||||
const mutations = await installFixture(page, { acceptResponse: true });
|
||||
await page.setViewportSize({ width: 1365, height: 900 });
|
||||
await openDiplomacyTab(page);
|
||||
|
||||
const responseRow = page.locator('.message-response');
|
||||
const accept = responseRow.getByRole('button', { name: '수락' });
|
||||
const decline = responseRow.getByRole('button', { name: '거절' });
|
||||
const geometry = await responseRow.evaluate((element) => {
|
||||
const row = element.getBoundingClientRect();
|
||||
const buttons = Array.from(element.querySelectorAll('button')).map((button) => {
|
||||
const rect = button.getBoundingClientRect();
|
||||
const style = getComputedStyle(button);
|
||||
return {
|
||||
x: rect.x,
|
||||
y: rect.y,
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
color: style.color,
|
||||
fontSize: style.fontSize,
|
||||
borderWidth: style.borderWidth,
|
||||
cursor: style.cursor,
|
||||
};
|
||||
});
|
||||
return { row: { x: row.x, y: row.y, width: row.width, height: row.height }, buttons };
|
||||
});
|
||||
|
||||
expect(geometry.buttons).toHaveLength(2);
|
||||
expect(geometry.buttons[1]!.x - (geometry.buttons[0]!.x + geometry.buttons[0]!.width)).toBeCloseTo(4, 0);
|
||||
expect(geometry.buttons[0]).toMatchObject({
|
||||
color: 'rgb(143, 209, 143)',
|
||||
fontSize: '11.2px',
|
||||
borderWidth: '1px',
|
||||
cursor: 'pointer',
|
||||
});
|
||||
expect(geometry.buttons[1]).toMatchObject({
|
||||
color: 'rgb(224, 154, 154)',
|
||||
fontSize: '11.2px',
|
||||
borderWidth: '1px',
|
||||
cursor: 'pointer',
|
||||
});
|
||||
expect(geometry.buttons.every((button) => button.height >= 22 && button.height <= 26)).toBe(true);
|
||||
|
||||
await decline.hover();
|
||||
expect(await decline.evaluate((element) => getComputedStyle(element).cursor)).toBe('pointer');
|
||||
await accept.focus();
|
||||
await expect(accept).toBeFocused();
|
||||
if (artifactRoot) {
|
||||
await mkdir(artifactRoot, { recursive: true });
|
||||
await responseRow.screenshot({
|
||||
path: resolve(artifactRoot, 'instant-diplomacy-response-core-desktop.png'),
|
||||
animations: 'disabled',
|
||||
});
|
||||
}
|
||||
|
||||
page.once('dialog', async (dialog) => {
|
||||
expect(dialog.message()).toBe('수락하시겠습니까?');
|
||||
await dialog.dismiss();
|
||||
});
|
||||
await accept.click();
|
||||
expect(mutations).toHaveLength(0);
|
||||
await expect(page.getByText(diplomacyMessage.text)).toBeVisible();
|
||||
|
||||
page.once('dialog', async (dialog) => {
|
||||
expect(dialog.message()).toBe('수락하시겠습니까?');
|
||||
await dialog.accept();
|
||||
});
|
||||
await accept.click();
|
||||
await expect(page.getByText(diplomacyMessage.text)).toHaveCount(0);
|
||||
expect(mutations).toHaveLength(1);
|
||||
expect(JSON.stringify(mutations[0]!.body)).toContain('"response":true');
|
||||
});
|
||||
|
||||
test('keeps the message and exposes a rejected response on mobile Chromium', async ({ page }) => {
|
||||
const mutations = await installFixture(page, { acceptResponse: false });
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.goto('http://127.0.0.1:15102/che/');
|
||||
await expect(page.getByRole('heading', { name: '전장 현황' })).toBeVisible();
|
||||
await page.getByRole('button', { name: '메시지', exact: true }).click();
|
||||
await page.getByRole('button', { name: '외교', exact: true }).click();
|
||||
|
||||
const responseRow = page.locator('.message-response');
|
||||
await expect(responseRow).toBeVisible();
|
||||
const itemWidth = await page
|
||||
.locator('.message-item')
|
||||
.evaluate((element) => element.getBoundingClientRect().width);
|
||||
expect(itemWidth).toBeGreaterThan(320);
|
||||
expect(itemWidth).toBeLessThanOrEqual(342);
|
||||
|
||||
page.once('dialog', async (dialog) => {
|
||||
expect(dialog.message()).toBe('거절하시겠습니까?');
|
||||
await dialog.accept();
|
||||
});
|
||||
await responseRow.getByRole('button', { name: '거절' }).click();
|
||||
await expect(page.locator('.error')).toHaveText('현재 외교 상태에서는 수락할 수 없습니다.');
|
||||
await expect(page.getByText(diplomacyMessage.text)).toBeVisible();
|
||||
expect(mutations).toHaveLength(1);
|
||||
expect(JSON.stringify(mutations[0]!.body)).toContain('"response":false');
|
||||
|
||||
if (artifactRoot) {
|
||||
await mkdir(artifactRoot, { recursive: true });
|
||||
await page.locator('.mobile-panel').screenshot({
|
||||
path: resolve(artifactRoot, 'instant-diplomacy-response-error-core-mobile.png'),
|
||||
animations: 'disabled',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('shows but disables legacy response controls without diplomacy authority', async ({ page }) => {
|
||||
const mutations = await installFixture(page, {
|
||||
acceptResponse: true,
|
||||
canRespondDiplomacy: false,
|
||||
});
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.goto('http://127.0.0.1:15102/che/');
|
||||
await expect(page.getByRole('heading', { name: '전장 현황' })).toBeVisible();
|
||||
await page.getByRole('button', { name: '메시지', exact: true }).click();
|
||||
await page.getByRole('button', { name: '외교', exact: true }).click();
|
||||
|
||||
const accept = page.locator('.message-response').getByRole('button', { name: '수락' });
|
||||
await expect(accept).toBeDisabled();
|
||||
expect(
|
||||
await accept.evaluate((element) => {
|
||||
const style = getComputedStyle(element);
|
||||
return { cursor: style.cursor, opacity: style.opacity };
|
||||
})
|
||||
).toEqual({ cursor: 'not-allowed', opacity: '0.5' });
|
||||
await accept.click({ force: true });
|
||||
expect(mutations).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -6,7 +6,7 @@ const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../..')
|
||||
|
||||
export default defineConfig({
|
||||
testDir: '.',
|
||||
testMatch: ['visual-parity.spec.ts', 'public-gaps.spec.ts'],
|
||||
testMatch: ['visual-parity.spec.ts', 'public-gaps.spec.ts', 'instant-diplomacy-message.spec.ts'],
|
||||
fullyParallel: false,
|
||||
workers: 1,
|
||||
timeout: 30_000,
|
||||
|
||||
@@ -30,11 +30,11 @@ export interface CanonicalTurnCommandTrace {
|
||||
schemaVersion: 1;
|
||||
engine: CanonicalEngine;
|
||||
execution: {
|
||||
kind: 'general' | 'nation';
|
||||
kind: 'general' | 'nation' | 'instantNation';
|
||||
actorGeneralId: number;
|
||||
action: string;
|
||||
args: unknown;
|
||||
seedDomain: 'generalCommand' | 'nationCommand';
|
||||
seedDomain: 'generalCommand' | 'nationCommand' | 'none';
|
||||
outcome?: unknown;
|
||||
};
|
||||
before: CanonicalTurnSnapshot;
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
findTurnDifferentialWorkspaceRoot,
|
||||
runReferenceTurnCommandTraceRequest,
|
||||
} from '../src/turn-differential/referenceSnapshot.js';
|
||||
|
||||
const configuredWorkspaceRoot = process.env.TURN_DIFFERENTIAL_WORKSPACE_ROOT;
|
||||
const workspaceRoot = configuredWorkspaceRoot ?? findTurnDifferentialWorkspaceRoot(process.cwd());
|
||||
const integration = describe.skipIf(!workspaceRoot || process.env.TURN_DIFFERENTIAL_REFERENCE !== '1');
|
||||
|
||||
const baseSetup = (state: number, term: number) => ({
|
||||
isolateWorld: true,
|
||||
world: { year: 190, month: 3 },
|
||||
nations: [
|
||||
{ id: 1, name: '수락국', capitalCityId: 1 },
|
||||
{ id: 2, name: '제안국', capitalCityId: 2 },
|
||||
],
|
||||
cities: [
|
||||
{ id: 1, nationId: 1, supplyState: 1, frontState: 1 },
|
||||
{ id: 2, nationId: 2, supplyState: 1, frontState: 1 },
|
||||
],
|
||||
generals: [
|
||||
{ id: 1, name: '수락장수', nationId: 1, cityId: 1, officerLevel: 5 },
|
||||
{ id: 2, name: '제안장수', nationId: 2, cityId: 2, officerLevel: 5 },
|
||||
],
|
||||
diplomacy: [
|
||||
{ fromNationId: 1, toNationId: 2, state, term },
|
||||
{ fromNationId: 2, toNationId: 1, state, term },
|
||||
],
|
||||
});
|
||||
|
||||
const observe = {
|
||||
generalIds: [1, 2],
|
||||
nationIds: [1, 2],
|
||||
cityIds: [1, 2],
|
||||
logAfterId: 0,
|
||||
messageAfterId: 0,
|
||||
};
|
||||
|
||||
const addedLogs = (trace: ReturnType<typeof runReferenceTurnCommandTraceRequest>) =>
|
||||
trace.after.logs.filter((log) => Number(log.id) > trace.before.watermarks.logId);
|
||||
|
||||
integration('legacy instant diplomacy responses', () => {
|
||||
it('accepts non-aggression without RNG and copies received assistance', () => {
|
||||
const setup = baseSetup(2, 0);
|
||||
setup.nations[1] = {
|
||||
...setup.nations[1],
|
||||
nationEnv: { recv_assist: { n1: [1, 37] } },
|
||||
} as (typeof setup.nations)[number];
|
||||
const trace = runReferenceTurnCommandTraceRequest(workspaceRoot!, {
|
||||
kind: 'instantNation',
|
||||
actorGeneralId: 1,
|
||||
action: 'che_불가침수락',
|
||||
args: { destNationID: 2, destGeneralID: 2, year: 191, month: 2 },
|
||||
setup,
|
||||
observe,
|
||||
});
|
||||
|
||||
expect(trace.execution).toMatchObject({
|
||||
kind: 'instantNation',
|
||||
action: 'che_불가침수락',
|
||||
seedDomain: 'none',
|
||||
});
|
||||
expect(trace.rng).toEqual([]);
|
||||
expect(trace.after.diplomacy).toEqual([
|
||||
expect.objectContaining({ fromNationId: 1, toNationId: 2, state: 7, term: 12 }),
|
||||
expect.objectContaining({ fromNationId: 2, toNationId: 1, state: 7, term: 12 }),
|
||||
]);
|
||||
expect(trace.after.nations[1]).toMatchObject({
|
||||
meta: {
|
||||
recv_assist: { n1: [1, 37] },
|
||||
resp_assist: { n1: [1, 37] },
|
||||
},
|
||||
});
|
||||
expect(addedLogs(trace).map((log) => [log.generalId, log.category])).toEqual([
|
||||
[1, 'history'],
|
||||
[1, 'action'],
|
||||
[2, 'history'],
|
||||
[2, 'action'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('accepts non-aggression cancellation with legacy grouped log order', () => {
|
||||
const trace = runReferenceTurnCommandTraceRequest(workspaceRoot!, {
|
||||
kind: 'instantNation',
|
||||
actorGeneralId: 1,
|
||||
action: 'che_불가침파기수락',
|
||||
args: { destNationID: 2, destGeneralID: 2 },
|
||||
setup: baseSetup(7, 12),
|
||||
observe,
|
||||
});
|
||||
|
||||
expect(trace.rng).toEqual([]);
|
||||
expect(trace.after.diplomacy).toEqual([
|
||||
expect.objectContaining({ fromNationId: 1, toNationId: 2, state: 2, term: 0 }),
|
||||
expect.objectContaining({ fromNationId: 2, toNationId: 1, state: 2, term: 0 }),
|
||||
]);
|
||||
expect(addedLogs(trace).map((log) => [log.generalId, log.category])).toEqual([
|
||||
[1, 'history'],
|
||||
[1, 'action'],
|
||||
[0, 'history'],
|
||||
[2, 'history'],
|
||||
[2, 'action'],
|
||||
]);
|
||||
expect(addedLogs(trace)[2]?.text).toContain('수락장수');
|
||||
});
|
||||
|
||||
it('accepts stop-war and recalculates both nations fronts', () => {
|
||||
const setup = baseSetup(0, 6);
|
||||
setup.diplomacy[1] = { fromNationId: 2, toNationId: 1, state: 1, term: 6 };
|
||||
const trace = runReferenceTurnCommandTraceRequest(workspaceRoot!, {
|
||||
kind: 'instantNation',
|
||||
actorGeneralId: 1,
|
||||
action: 'che_종전수락',
|
||||
args: { destNationID: 2, destGeneralID: 2 },
|
||||
setup,
|
||||
observe,
|
||||
});
|
||||
|
||||
expect(trace.rng).toEqual([]);
|
||||
expect(trace.after.diplomacy).toEqual([
|
||||
expect.objectContaining({ fromNationId: 1, toNationId: 2, state: 2, term: 0 }),
|
||||
expect.objectContaining({ fromNationId: 2, toNationId: 1, state: 2, term: 0 }),
|
||||
]);
|
||||
expect(trace.after.cities).toEqual([
|
||||
expect.objectContaining({ id: 1, frontState: 2 }),
|
||||
expect.objectContaining({ id: 2, frontState: 2 }),
|
||||
]);
|
||||
expect(addedLogs(trace).map((log) => [log.generalId, log.category])).toEqual([
|
||||
[1, 'history'],
|
||||
[1, 'action'],
|
||||
[0, 'history'],
|
||||
[2, 'history'],
|
||||
[2, 'action'],
|
||||
]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user