Merge branch 'main' into feature/nation-personnel-finance-parity
# Conflicts: # app/game-frontend/e2e/playwright.config.mjs # app/game-frontend/package.json # docs/frontend-legacy-parity.md
This commit is contained in:
@@ -0,0 +1,119 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { mkdir, readFile } from 'node:fs/promises';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
|
||||
import { chromium } from '@playwright/test';
|
||||
|
||||
const targetUrl = process.env.REF_BOARD_URL ?? 'https://dev-sam-ref.hided.net/sam/';
|
||||
const username = process.env.REF_BOARD_USER ?? 'refuser1';
|
||||
const passwordFile = process.env.REF_BOARD_PASSWORD_FILE;
|
||||
const artifactRoot = process.env.REF_BOARD_ARTIFACT_DIR;
|
||||
|
||||
if (!passwordFile) {
|
||||
throw new Error('REF_BOARD_PASSWORD_FILE is required');
|
||||
}
|
||||
|
||||
const password = (await readFile(passwordFile, 'utf8')).trim();
|
||||
|
||||
const login = async (context, page) => {
|
||||
await page.goto(targetUrl, { waitUntil: 'networkidle', timeout: 60_000 });
|
||||
const globalSalt = await page.locator('#global_salt').inputValue();
|
||||
const passwordHash = createHash('sha512')
|
||||
.update(globalSalt + password + globalSalt)
|
||||
.digest('hex');
|
||||
const response = await context.request.post(new URL('api.php?path=Login/LoginByID', targetUrl).toString(), {
|
||||
data: { username, password: passwordHash },
|
||||
});
|
||||
const result = await response.json();
|
||||
if (!response.ok() || result.result !== true) {
|
||||
throw new Error('Reference login failed');
|
||||
}
|
||||
};
|
||||
|
||||
const measure = async (browser, name, viewport, isSecret) => {
|
||||
const context = await browser.newContext({
|
||||
viewport,
|
||||
deviceScaleFactor: 1,
|
||||
colorScheme: 'dark',
|
||||
locale: 'ko-KR',
|
||||
timezoneId: 'UTC',
|
||||
ignoreHTTPSErrors: true,
|
||||
});
|
||||
try {
|
||||
const page = await context.newPage();
|
||||
await login(context, page);
|
||||
const boardUrl = new URL('hwe/v_board.php', targetUrl);
|
||||
if (isSecret) {
|
||||
boardUrl.searchParams.set('isSecret', 'true');
|
||||
}
|
||||
await page.goto(boardUrl.toString(), { waitUntil: 'networkidle', timeout: 60_000 });
|
||||
await page.locator('.articleFrame').first().waitFor({ state: 'visible' });
|
||||
|
||||
if (artifactRoot) {
|
||||
const path = resolve(artifactRoot, `board-ref-${name}.png`);
|
||||
await mkdir(dirname(path), { recursive: true });
|
||||
await page.screenshot({ path, fullPage: true, animations: 'disabled' });
|
||||
}
|
||||
|
||||
const geometry = await page.evaluate(() => {
|
||||
const rect = (selector) => {
|
||||
const box = document.querySelector(selector).getBoundingClientRect();
|
||||
return { x: box.x, y: box.y, width: box.width, height: box.height };
|
||||
};
|
||||
const pageStyle = getComputedStyle(document.querySelector('#container'));
|
||||
const articleText = getComputedStyle(document.querySelector('.articleFrame .text'));
|
||||
return {
|
||||
title: document.title,
|
||||
container: rect('#container'),
|
||||
topBar: rect('.back_bar'),
|
||||
titleLabel: rect('#newArticle .articleTitle'),
|
||||
submitArticle: rect('#submitArticle'),
|
||||
articleAuthor: rect('.articleFrame .authorName'),
|
||||
articleDate: rect('.articleFrame .date'),
|
||||
icon: rect('.articleFrame .generalIcon'),
|
||||
commentAuthor: rect('.articleFrame .comment .authorName'),
|
||||
submitComment: rect('.articleFrame .submitComment'),
|
||||
bottomButton: rect('.bg0[style] .back_btn'),
|
||||
font: {
|
||||
family: pageStyle.fontFamily,
|
||||
size: pageStyle.fontSize,
|
||||
lineHeight: pageStyle.lineHeight,
|
||||
},
|
||||
whiteSpace: articleText.whiteSpace,
|
||||
walnut: getComputedStyle(document.querySelector('#newArticle')).backgroundImage,
|
||||
green: getComputedStyle(document.querySelector('.articleFrame > .bg1')).backgroundImage,
|
||||
blue: getComputedStyle(document.querySelector('.newArticleHeader')).backgroundImage,
|
||||
submitStyle: {
|
||||
backgroundColor: getComputedStyle(document.querySelector('#submitArticle')).backgroundColor,
|
||||
borderColor: getComputedStyle(document.querySelector('#submitArticle')).borderColor,
|
||||
color: getComputedStyle(document.querySelector('#submitArticle')).color,
|
||||
},
|
||||
};
|
||||
});
|
||||
const submit = page.locator('#submitArticle');
|
||||
await submit.hover();
|
||||
const hoverBackground = await submit.evaluate((element) => getComputedStyle(element).backgroundColor);
|
||||
await submit.focus();
|
||||
const focusOutline = await submit.evaluate((element) => getComputedStyle(element).outline);
|
||||
return {
|
||||
...geometry,
|
||||
submitInteraction: {
|
||||
hoverBackground,
|
||||
focusOutline,
|
||||
},
|
||||
};
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
};
|
||||
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
try {
|
||||
const measurements = {
|
||||
desktop: await measure(browser, 'desktop', { width: 1000, height: 800 }, false),
|
||||
mobile: await measure(browser, 'mobile', { width: 500, height: 800 }, true),
|
||||
};
|
||||
process.stdout.write(`${JSON.stringify(measurements, null, 2)}\n`);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
@@ -163,5 +163,63 @@ export const canonicalFrontendFixture = {
|
||||
globalAction: ['<L>●</> 유비가 내정을 수행했습니다.'],
|
||||
},
|
||||
},
|
||||
surveyList: {
|
||||
polls: [
|
||||
{
|
||||
id: 2,
|
||||
title: '선호하는 병종',
|
||||
startAt: '2026-07-26T00:00:00.000Z',
|
||||
endAt: null,
|
||||
closedAt: null,
|
||||
revealMode: 'after_vote',
|
||||
optionsCount: 3,
|
||||
multipleOptions: 1,
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
title: '지난 설문',
|
||||
startAt: '2026-07-20T00:00:00.000Z',
|
||||
endAt: '2026-07-21T00:00:00.000Z',
|
||||
closedAt: '2026-07-21T00:00:00.000Z',
|
||||
revealMode: 'after_vote',
|
||||
optionsCount: 2,
|
||||
multipleOptions: 1,
|
||||
},
|
||||
],
|
||||
voteReward: 90,
|
||||
},
|
||||
surveyDetail: {
|
||||
voteInfo: {
|
||||
id: 2,
|
||||
title: '선호하는 병종',
|
||||
body: '',
|
||||
options: ['보병', '기병', '궁병'],
|
||||
multipleOptions: 1,
|
||||
revealMode: 'after_vote',
|
||||
openerGeneralId: 9,
|
||||
openerName: '설문관리자',
|
||||
startAt: '2026-07-26T00:00:00.000Z',
|
||||
endAt: null,
|
||||
closedAt: null,
|
||||
},
|
||||
votes: [
|
||||
{ selection: [0], count: 2 },
|
||||
{ selection: [1], count: 1 },
|
||||
],
|
||||
comments: [
|
||||
{
|
||||
id: 1,
|
||||
voteId: 2,
|
||||
generalId: 3,
|
||||
nationId: 1,
|
||||
generalName: '관우',
|
||||
nationName: '촉',
|
||||
text: '기병이 좋습니다.',
|
||||
createdAt: '2026-07-26T01:23:00.000Z',
|
||||
},
|
||||
],
|
||||
myVote: null,
|
||||
userCnt: 17,
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
@@ -111,6 +111,8 @@ const installHallFixture = async (page: Page): Promise<void> => {
|
||||
};
|
||||
|
||||
const installAuthenticatedGameFixture = async (page: Page): Promise<void> => {
|
||||
let surveyVoted = false;
|
||||
const surveyComments = fixture.game.surveyDetail.comments.map((comment) => ({ ...comment }));
|
||||
await installImages(page);
|
||||
await page.addInitScript(
|
||||
({ gameToken, profile }) => {
|
||||
@@ -139,6 +141,38 @@ const installAuthenticatedGameFixture = async (page: Page): Promise<void> => {
|
||||
if (operation === 'public.getMapLayout') return fixture.game.mapLayout;
|
||||
if (operation === 'yearbook.getRange') return fixture.game.yearbookRange;
|
||||
if (operation === 'yearbook.getHistory') return fixture.game.yearbook;
|
||||
if (operation === 'vote.getVoteList') return fixture.game.surveyList;
|
||||
if (operation === 'vote.getVoteDetail') {
|
||||
return {
|
||||
...fixture.game.surveyDetail,
|
||||
votes: surveyVoted
|
||||
? [
|
||||
{ selection: [0], count: 3 },
|
||||
{ selection: [1], count: 1 },
|
||||
]
|
||||
: fixture.game.surveyDetail.votes,
|
||||
comments: surveyComments,
|
||||
myVote: surveyVoted ? [0] : null,
|
||||
};
|
||||
}
|
||||
if (operation === 'vote.submitVote') {
|
||||
surveyVoted = true;
|
||||
return { ok: true, wonLottery: false };
|
||||
}
|
||||
if (operation === 'vote.addComment') {
|
||||
surveyComments.push({
|
||||
id: surveyComments.length + 1,
|
||||
voteId: 2,
|
||||
generalId: 1,
|
||||
nationId: 1,
|
||||
generalName: '유비',
|
||||
nationName: '촉',
|
||||
text: '새 댓글',
|
||||
createdAt: '2026-07-26T02:34:00.000Z',
|
||||
});
|
||||
return { ok: true };
|
||||
}
|
||||
if (operation === 'vote.getAdminStatus') return { ok: false };
|
||||
throw new Error(`Unhandled authenticated game fixture operation: ${operation}`);
|
||||
});
|
||||
});
|
||||
@@ -494,3 +528,104 @@ test.describe('yearbook legacy parity', () => {
|
||||
await expect(page.getByLabel('연월 선택')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('survey legacy parity', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await installAuthenticatedGameFixture(page);
|
||||
});
|
||||
|
||||
for (const viewport of [
|
||||
{ name: 'desktop', width: 1365, height: 768, containerWidth: 1000, commentNameWidth: 260 },
|
||||
{ name: 'mobile', width: 390, height: 844, containerWidth: 500, commentNameWidth: 130 },
|
||||
]) {
|
||||
test(`renders the ref vote tables on ${viewport.name}`, async ({ page }) => {
|
||||
await page.setViewportSize(viewport);
|
||||
await page.goto('http://127.0.0.1:15102/che/survey');
|
||||
await expect(page.getByText('설문 조사(90금과 추첨으로 유니크템 증정!)')).toBeVisible();
|
||||
await expect(page.getByText('기병이 좋습니다.')).toBeVisible();
|
||||
await expect(page.locator('#vote-new-panel')).toHaveCount(0);
|
||||
if (artifactRoot) {
|
||||
await page.screenshot({
|
||||
path: resolve(artifactRoot, `survey-core-${viewport.name}.png`),
|
||||
fullPage: true,
|
||||
animations: 'disabled',
|
||||
});
|
||||
}
|
||||
|
||||
const geometry = await page.evaluate(() => {
|
||||
const rect = (selector: string) =>
|
||||
document.querySelector<HTMLElement>(selector)!.getBoundingClientRect();
|
||||
const container = document.querySelector<HTMLElement>('#container')!;
|
||||
const title = document.querySelector<HTMLElement>('#vote-title')!;
|
||||
return {
|
||||
containerWidth: rect('#container').width,
|
||||
containerHeight: rect('#container').height,
|
||||
resultWidth: rect('#vote-result').width,
|
||||
commentNameWidth: rect('#vote-comment .comment-name').width,
|
||||
fontFamily: getComputedStyle(container).fontFamily,
|
||||
fontSize: getComputedStyle(container).fontSize,
|
||||
backgroundImage: getComputedStyle(container).backgroundImage,
|
||||
title: {
|
||||
height: rect('#vote-title').height,
|
||||
fontSize: getComputedStyle(title).fontSize,
|
||||
backgroundImage: getComputedStyle(title).backgroundImage,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
expect(geometry.containerWidth).toBe(viewport.containerWidth);
|
||||
expect(geometry.containerHeight).toBeLessThan(viewport.height);
|
||||
expect(geometry.resultWidth).toBe(viewport.containerWidth);
|
||||
expect(geometry.commentNameWidth).toBe(viewport.commentNameWidth);
|
||||
expect(geometry.fontFamily).toContain('Pretendard');
|
||||
expect(geometry.fontSize).toBe('14px');
|
||||
expect(geometry.backgroundImage).toContain('back_walnut.jpg');
|
||||
expect(geometry.title.height).toBeCloseTo(37.8, 0);
|
||||
expect(geometry.title.fontSize).toBe('25.2px');
|
||||
expect(geometry.title.backgroundImage).toContain('back_blue.jpg');
|
||||
|
||||
const secondOption = page.locator('#v-vote-1');
|
||||
await secondOption.check();
|
||||
await expect(secondOption).toBeChecked();
|
||||
await secondOption.focus();
|
||||
await expect(secondOption).toBeFocused();
|
||||
|
||||
const voteButton = page.getByRole('button', { name: '투표', exact: true });
|
||||
const beforeHover = await voteButton.evaluate((element) => getComputedStyle(element).filter);
|
||||
await voteButton.hover();
|
||||
const afterHover = await voteButton.evaluate((element) => getComputedStyle(element).filter);
|
||||
expect(afterHover).not.toBe(beforeHover);
|
||||
});
|
||||
}
|
||||
|
||||
test('submits a vote and comment through the real screen controls', async ({ page }) => {
|
||||
await page.goto('http://127.0.0.1:15102/che/survey');
|
||||
await page.getByRole('button', { name: '투표', exact: true }).click();
|
||||
await expect(page.getByRole('status')).toHaveText('설문을 마쳤습니다.');
|
||||
await expect(page.getByText('결산', { exact: true })).toBeVisible();
|
||||
|
||||
await page.getByLabel('댓글').fill('새 댓글');
|
||||
await page.getByRole('button', { name: '댓글 달기' }).click();
|
||||
await expect(page.getByText('새 댓글')).toBeVisible();
|
||||
});
|
||||
|
||||
test('keeps the selected option after a vote API error', async ({ page }) => {
|
||||
await page.route('**/che/api/trpc/**', async (route) => {
|
||||
if (operationNames(route).includes('vote.submitVote')) {
|
||||
await route.fulfill({
|
||||
status: 500,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ error: { message: '이미 설문조사를 완료하였습니다.' } }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
await route.fallback();
|
||||
});
|
||||
await page.goto('http://127.0.0.1:15102/che/survey');
|
||||
const secondOption = page.locator('#v-vote-1');
|
||||
await secondOption.check();
|
||||
await page.getByRole('button', { name: '투표', exact: true }).click();
|
||||
await expect(page.getByRole('alert')).toBeVisible();
|
||||
await expect(secondOption).toBeChecked();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -554,6 +554,169 @@ integration('general sabotage probability clamp matrix', () => {
|
||||
);
|
||||
});
|
||||
|
||||
type SabotageValueBoundaryCase = {
|
||||
name: string;
|
||||
action: 'che_화계' | 'che_선동' | 'che_파괴' | 'che_탈취';
|
||||
stat: 'leadership' | 'strength' | 'intelligence';
|
||||
fixturePatches: FixturePatches;
|
||||
};
|
||||
|
||||
const sabotageValueBoundaryCases: SabotageValueBoundaryCase[] = [
|
||||
{
|
||||
name: 'fire attack does not reduce agriculture or commerce below zero',
|
||||
action: 'che_화계',
|
||||
stat: 'intelligence',
|
||||
fixturePatches: { cities: { 70: { agriculture: 1, commerce: 1 } } },
|
||||
},
|
||||
{
|
||||
name: 'agitation does not reduce security or trust below zero',
|
||||
action: 'che_선동',
|
||||
stat: 'leadership',
|
||||
fixturePatches: { cities: { 70: { security: 1, trust: 1 } } },
|
||||
},
|
||||
{
|
||||
name: 'destruction does not reduce defence or wall below zero',
|
||||
action: 'che_파괴',
|
||||
stat: 'strength',
|
||||
fixturePatches: { cities: { 70: { defence: 1, wall: 1 } } },
|
||||
},
|
||||
{
|
||||
name: 'seizure does not take more than supplied nation resources',
|
||||
action: 'che_탈취',
|
||||
stat: 'strength',
|
||||
fixturePatches: { nations: { 2: { gold: 1, rice: 1 } } },
|
||||
},
|
||||
{
|
||||
name: 'seizure does not reduce unsupplied city resources below zero',
|
||||
action: 'che_탈취',
|
||||
stat: 'strength',
|
||||
fixturePatches: {
|
||||
cities: { 70: { agriculture: 1, commerce: 1, supplyState: 0 } },
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const hasSuccessfulSabotageLog = (logs: Array<Record<string, unknown>>): boolean =>
|
||||
logs.some((entry) => typeof entry.text === 'string' && entry.text.includes('성공했습니다.'));
|
||||
|
||||
integration('general sabotage value boundary matrix', () => {
|
||||
it.each(sabotageValueBoundaryCases)(
|
||||
'$name matches the legacy clamped state delta',
|
||||
async ({ action, stat, fixturePatches }) => {
|
||||
const request = buildRequest(
|
||||
action,
|
||||
{ destCityID: 70 },
|
||||
{ [stat]: 100 },
|
||||
{
|
||||
...fixturePatches,
|
||||
generals: {
|
||||
...fixturePatches.generals,
|
||||
2: { ...fixturePatches.generals?.[2], [stat]: 10 },
|
||||
},
|
||||
cities: {
|
||||
...fixturePatches.cities,
|
||||
70: {
|
||||
...fixturePatches.cities?.[70],
|
||||
security: 0,
|
||||
securityMax: 2_000,
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
request.setup!.world!.hiddenSeed = 'general-value-0';
|
||||
const reference = runReferenceTurnCommandTraceRequest(
|
||||
workspaceRoot!,
|
||||
request as unknown as Record<string, unknown>
|
||||
);
|
||||
const core = await runCoreTurnCommandTrace(request, reference.before);
|
||||
|
||||
expect(reference.execution.outcome).toMatchObject({ completed: true });
|
||||
expect(core.execution.outcome).toMatchObject({
|
||||
requestedAction: action,
|
||||
actionKey: action,
|
||||
usedFallback: false,
|
||||
});
|
||||
expect(hasSuccessfulSabotageLog(reference.after.logs)).toBe(true);
|
||||
expect(hasSuccessfulSabotageLog(core.after.logs)).toBe(true);
|
||||
expect(core.rng).toEqual(reference.rng);
|
||||
expect(
|
||||
compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, {
|
||||
ignoredPathPatterns: ignoredLifecyclePaths,
|
||||
})
|
||||
).toEqual([]);
|
||||
},
|
||||
120_000
|
||||
);
|
||||
});
|
||||
|
||||
type SabotageInjuryBoundaryCase = {
|
||||
action: 'che_화계' | 'che_선동' | 'che_파괴';
|
||||
stat: 'leadership' | 'strength' | 'intelligence';
|
||||
hiddenSeed: string;
|
||||
};
|
||||
|
||||
const sabotageInjuryBoundaryCases: SabotageInjuryBoundaryCase[] = [
|
||||
{ action: 'che_화계', stat: 'intelligence', hiddenSeed: 'general-injury-4' },
|
||||
{ action: 'che_선동', stat: 'leadership', hiddenSeed: 'general-injury-13' },
|
||||
{ action: 'che_파괴', stat: 'strength', hiddenSeed: 'general-injury-4' },
|
||||
];
|
||||
|
||||
const injuryLogTexts = (logs: Array<Record<string, unknown>>): string[] =>
|
||||
logs
|
||||
.map((entry) => entry.text)
|
||||
.filter((text): text is string => typeof text === 'string' && text.includes('부상</>을 당했습니다.'));
|
||||
|
||||
const legacyInjuryLogBody = (text: string): string => text.replace(/^<C>●<\/>\d+월:/, '');
|
||||
|
||||
integration('general sabotage injury boundary matrix', () => {
|
||||
it.each(sabotageInjuryBoundaryCases)(
|
||||
'$action matches legacy injury cap, integer persistence, and log',
|
||||
async ({ action, stat, hiddenSeed }) => {
|
||||
const request = buildRequest(
|
||||
action,
|
||||
{ destCityID: 70 },
|
||||
{ [stat]: 100 },
|
||||
{
|
||||
generals: {
|
||||
2: {
|
||||
[stat]: 10,
|
||||
injury: 79,
|
||||
crew: 101,
|
||||
atmos: 51,
|
||||
train: 51,
|
||||
},
|
||||
},
|
||||
cities: { 70: { security: 0, securityMax: 2_000 } },
|
||||
}
|
||||
);
|
||||
request.setup!.world!.hiddenSeed = hiddenSeed;
|
||||
const reference = runReferenceTurnCommandTraceRequest(
|
||||
workspaceRoot!,
|
||||
request as unknown as Record<string, unknown>
|
||||
);
|
||||
const core = await runCoreTurnCommandTrace(request, reference.before);
|
||||
|
||||
expect(reference.execution.outcome).toMatchObject({ completed: true });
|
||||
expect(core.execution.outcome).toMatchObject({
|
||||
requestedAction: action,
|
||||
actionKey: action,
|
||||
usedFallback: false,
|
||||
});
|
||||
expect(injuryLogTexts(reference.after.logs)).toHaveLength(1);
|
||||
expect(injuryLogTexts(core.after.logs)).toEqual(
|
||||
injuryLogTexts(reference.after.logs).map(legacyInjuryLogBody)
|
||||
);
|
||||
expect(core.rng).toEqual(reference.rng);
|
||||
expect(
|
||||
compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, {
|
||||
ignoredPathPatterns: ignoredLifecyclePaths,
|
||||
})
|
||||
).toEqual([]);
|
||||
},
|
||||
120_000
|
||||
);
|
||||
});
|
||||
|
||||
type GeneralConstraintCase = {
|
||||
name: string;
|
||||
action: string;
|
||||
@@ -600,6 +763,40 @@ const constraintCases: GeneralConstraintCase[] = [
|
||||
action: 'che_주민선정',
|
||||
fixturePatches: { cities: { 3: { trust: 100 } } },
|
||||
},
|
||||
{
|
||||
name: 'sabotage targets the occupied city',
|
||||
action: 'che_화계',
|
||||
args: { destCityID: 3 },
|
||||
},
|
||||
{
|
||||
name: 'sabotage targets a neutral city',
|
||||
action: 'che_화계',
|
||||
args: { destCityID: 70 },
|
||||
fixturePatches: { cities: { 70: { nationId: 0 } } },
|
||||
},
|
||||
{
|
||||
name: 'sabotage targets a non-aggression nation',
|
||||
action: 'che_화계',
|
||||
args: { destCityID: 70 },
|
||||
fixturePatches: {
|
||||
diplomacy: {
|
||||
'1:2': { state: 7 },
|
||||
'2:1': { state: 7 },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'insufficient sabotage gold',
|
||||
action: 'che_화계',
|
||||
args: { destCityID: 70 },
|
||||
actorPatch: { gold: 0 },
|
||||
},
|
||||
{
|
||||
name: 'insufficient sabotage rice',
|
||||
action: 'che_화계',
|
||||
args: { destCityID: 70 },
|
||||
actorPatch: { rice: 0 },
|
||||
},
|
||||
];
|
||||
|
||||
integration('general command full-constraint fallback matrix', () => {
|
||||
|
||||
Reference in New Issue
Block a user