Q / X hotkeys retreat and sell the selected operator in prep (#114)

Q retreats and X sells the selected operator during prep, through the same buttons (never an item, never while typing in a field); the buttons show [Q] / [X]. Contributed by @SpiritedAwayCN.
This commit is contained in:
Chun'an Shi authored and GitHub committed 2026-10-05 14:18:44 +08:00
1 parent a0a5419eb8
commit 3f05c65dec
9 files changed
+82 -12

No files matched your search

+15 -1
View File
@@ -494,6 +494,20 @@ describe('keyboard & settings', () => {
assert.equal(shortcutFor({ key: 'r', code: 'KeyR' }), 'refresh');
assert.equal(shortcutFor({ key: 'F', code: 'KeyF' }), 'freeze');
assert.equal(shortcutFor({ key: 'd' }), 'levelUp');
assert.equal(shortcutFor({ key: 'q', code: 'KeyQ' }), 'retreat');
assert.equal(shortcutFor({ key: 'Q' }), 'retreat');
assert.equal(shortcutFor({ code: 'KeyX' }), 'sell');
assert.equal(shortcutFor({ key: 'X' }), 'sell');
for (const key of ['q', 'x']) {
assert.equal(shortcutFor({ key, repeat: true }), null);
assert.equal(shortcutFor({ key, ctrlKey: true }), null);
assert.equal(shortcutFor({ key, metaKey: true }), null);
assert.equal(shortcutFor({ key, altKey: true }), null);
for (const tagName of ['INPUT', 'TEXTAREA', 'SELECT']) {
assert.equal(shortcutFor({ key, target: { tagName } }), null);
}
assert.equal(shortcutFor({ key, target: { isContentEditable: true } }), null);
}
assert.equal(shortcutFor({ key: ' ', code: 'Space' }), 'ready');
assert.equal(shortcutFor({ key: 'Escape' }), 'escape');
assert.equal(shortcutFor({ key: 'r', ctrlKey: true }), null);
@@ -503,7 +517,7 @@ describe('keyboard & settings', () => {
assert.equal(shortcutFor({ key: ' ', code: 'Space', target: { tagName: 'BUTTON' } }), 'ready', 'space readies even with a HUD button focused');
assert.equal(shortcutFor({ key: ' ', code: 'Space', target: { tagName: 'TEXTAREA' } }), null);
assert.equal(shortcutFor({ key: 'd', target: { tagName: 'DIV', isContentEditable: true } }), null);
assert.equal(shortcutFor({ key: 'x' }), null);
assert.equal(shortcutFor({ key: 'z' }), null);
assert.equal(shortcutFor(null), null);
});
test('sanitizeSettings', () => {
+42
View File
@@ -149,6 +149,48 @@ describe('in-match UI (mock harness, headless Chrome)', { skip: !ENABLED && 'set
await page.close();
});
test('prep: Q retreats and X sells only the selected operator', async () => {
const { page, problems } = await open('phase=PREP', { render: 'fallback' });
const initial = await mockState(page);
await page.keyboard.press('KeyQ');
await page.keyboard.press('KeyX');
assert.deepEqual(await mockState(page), initial, 'no selection means no action');
const unit = initial.board.find((piece) => piece.kind === 'chess' && !piece.golden);
await page.click(`.ff-piece[data-uid="${unit.uid}"]`);
await page.waitForSelector('.uframe__btn--retreat');
await page.evaluate(() => {
const input = document.createElement('input');
input.id = 'shortcut-test-input';
document.body.append(input);
input.focus();
});
await page.keyboard.press('KeyQ');
await page.keyboard.press('KeyX');
assert.deepEqual(await mockState(page), initial, 'typing must not retreat or sell');
await page.evaluate(() => document.getElementById('shortcut-test-input').remove());
await page.keyboard.press('KeyQ');
await page.waitForFunction((uid) => globalThis.__MOCK__.S().priv.hand.some((piece) => piece?.uid === uid), {}, unit.uid);
const retreated = await mockState(page);
assert.ok(!retreated.board.some((piece) => piece.uid === unit.uid));
assert.equal(retreated.funds, initial.funds, 'retreat does not sell');
await page.click(`.ff-piece[data-uid="${unit.uid}"]`);
await page.waitForSelector('.uframe__btn--sell');
assert.equal(await page.$('.uframe__btn--retreat'), null, 'bench operator cannot retreat');
await page.keyboard.press('KeyQ');
assert.deepEqual(await mockState(page), retreated, 'Q on the bench does nothing');
await page.keyboard.press('KeyX');
await page.waitForFunction((uid) => !globalThis.__MOCK__.S().priv.hand.some((piece) => piece?.uid === uid), {}, unit.uid);
assert.ok((await mockState(page)).funds > retreated.funds, 'X sells the selected operator');
const item = (await mockState(page)).hand.find((piece) => piece?.kind === 'item');
await page.click(`.ff-piece[data-uid="${item.uid}"]`);
await page.waitForSelector('.uframe__btn--destroy');
await page.keyboard.press('KeyX');
assert.ok((await mockState(page)).hand.some((piece) => piece?.uid === item.uid), 'X must not destroy an item');
assert.equal(await page.$('.modal'), null, 'no destroy confirmation');
assert.deepEqual(problems, []);
await page.close();
});
test('prep: drag & drop — tap-sell (no sell zone), hand → board + wheel, item → unit (equip)', async () => {
// DOM drag targets only exist in the fallback view (the Pixi engine's own drag is tested by the render module)
const { page, problems } = await open('phase=PREP', { render: 'fallback' });
+2
View File
@@ -115,6 +115,8 @@ describe('8: the detail card never covers the selected unit\'s underframe', () =
assert.doesNotMatch(css, /\.gm__bonds > \* \{ pointer-events: auto; \}/);
const dev = readFileSync(new URL('../../public/css/devices.css', import.meta.url), 'utf8');
assert.match(dev, /\.gm__hud > \.uframe \{ margin-left: calc\(-1 \* var\(--sa-l\)\); margin-top: calc\(-1 \* var\(--sa-t\)\); \}/);
const panels = readFileSync(new URL('../../public/css/screens/game-panels.css', import.meta.url), 'utf8');
assert.match(panels, /\.uframe__label\s*\{[^}]*white-space:\s*nowrap\s*;/, 'shortcut labels stay on one line');
});
test('underframeRect covers the diamond and its buttons', () => {
+1 -1
View File
@@ -35,7 +35,7 @@ describe('暂离 (AI 托管) is scoped to one match', () => {
describe('game shortcuts behind overlays', () => {
test('the 本局信息 / 敌方情报 drawer swallows R / F / D / Space, Esc still closes it', () => {
for (const act of ['refresh', 'freeze', 'levelUp', 'ready']) {
for (const act of ['refresh', 'freeze', 'levelUp', 'retreat', 'sell', 'ready']) {
assert.equal(shortcutBlocked(act, { drawer: true }), true, act);
assert.equal(shortcutBlocked(act, {}), false, act);
assert.equal(shortcutBlocked(act, { modal: true }), true, act);