From db57c39c7a7cf2ef1d3e3a8410ac8f19ba96ac5f Mon Sep 17 00:00:00 2001 From: sganggs Date: Sun, 4 Oct 2026 17:12:24 +0800 Subject: [PATCH] =?UTF-8?q?feedback3=20WC:=20readying=20with=20funds=20lef?= =?UTF-8?q?t=20asks=20first=20(the=20prep=20end=20wipes=20them);=20never?= =?UTF-8?q?=20for=20=E5=9D=8E=E8=AF=BA=E7=89=B9,=20at=200=20funds=20or=20u?= =?UTF-8?q?nder=20AI=20=E6=89=98=E7=AE=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/PLAYING.md | 2 +- public/js/screens/game.js | 20 +++++++++-- public/js/ui/gameLogic.js | 23 ++++++++++++ test/ui/feedback3-ready.test.js | 62 +++++++++++++++++++++++++++++++++ 4 files changed, 104 insertions(+), 3 deletions(-) create mode 100644 test/ui/feedback3-ready.test.js diff --git a/docs/PLAYING.md b/docs/PLAYING.md index 95e28fd2..83eb0df6 100644 --- a/docs/PLAYING.md +++ b/docs/PLAYING.md @@ -39,7 +39,7 @@ | 项目 | 数值 | |---|---| | 每回合资金 | 第 r 回合 `min(3 + r, 12)`:4、5、6、7 … 从第 9 回合起 12 | -| 剩余资金 | **休整期结束时清零**(策略「坎诺特」例外) | +| 剩余资金 | **休整期结束时清零**(策略「坎诺特」例外);还有资金时点「准备就绪」(或按 Space)会先弹窗确认一次 | | 招募价格(按阶级 I–VI) | 2 / 3 / 3 / 3 / 4 / 4 | | 刷新 | 1 资金 | | 出售干员 | 任何干员(含精锐)都只返还 **1** 资金 | diff --git a/public/js/screens/game.js b/public/js/screens/game.js index 49a023a0..d782ff6f 100644 --- a/public/js/screens/game.js +++ b/public/js/screens/game.js @@ -62,7 +62,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from '../../vendor/hooks.module.js'; import { PHASE, GEO } from '../../../shared/constants.js'; import { fxForm } from '../../../shared/protocol.js'; -import { html, Spinner, PhaseBanner, Icon, Button, MicroLabel, confirmDialog, useTicker } from '../ui/components.js'; +import { html, Spinner, PhaseBanner, Icon, Button, MicroLabel, confirmDialog, closeAllDialogs, useTicker } from '../ui/components.js'; import { useGameData, GIcon } from '../ui/gameComponents.js'; import { useFieldView } from '../ui/fieldHost.js'; import { TopBar, liveLp, ownLeaks, uniteRemaining, tempInfo, tempReadyReason } from '../ui/hud.js'; @@ -92,7 +92,7 @@ import { snapHud, activeBubbles, shortcutFor, shortcutBlocked, closesOnFieldPress, phaseTotalSeconds, homeFieldId, ownFieldId, normalizeSp, sortedPlayers, countdownState, shopBlockReason, stageOverrides, effectiveStage, watchTarget, dropFailureReason, previewEnemyKey, prepCamera, prepCameraFor, foldCamera, deployFieldOf, panelSide, panelSlots, bondPopupPlace, chessLoadout, unitLoadout, - mergeTarget, modeOffBonds, + mergeTarget, modeOffBonds, readyFundsPrompt, } from '../ui/gameLogic.js'; import { toast } from '../ui/toasts.js'; import { BriefingScreen } from './briefing.js'; @@ -628,11 +628,27 @@ function MatchScreen() { // ---- actions ---------------------------------------------------------------------------------------------- const buy = useCallback((i) => actions.buy(i), []); + // 准备 with funds left asks first: the prep's end wipes them (community report #4; not 坎诺特, not at 0 funds, not + // under AI 托管 — gameLogic.readyFundsPrompt). The button and Space both come here. + const askingReady = useRef(false); const toggleReady = useCallback(async (r) => { + const L = live.current; + const me = Array.isArray(L.pub?.players) ? L.pub.players.find((p) => p && p.playerId === L.myId) : null; + const ask = r ? readyFundsPrompt(L.priv, { keptBands: data.get('config')?.economy?.leftoverFundsKeptByBands, autoplay: !!me?.autoplay }) : null; + if (ask) { + if (askingReady.current) return; + askingReady.current = true; + const ok = await confirmDialog(ask); + askingReady.current = false; + const now = live.current; + if (!ok || now.pub?.phase !== PHASE.PREP || now.priv?.ready) return; + } setReadyBusy(true); await actions.ready(r); setReadyBusy(false); }, []); + // the prep ended (timer) while the question was open: drop it — the funds are gone either way + useEffect(() => { if (phase !== PHASE.PREP && askingReady.current) closeAllDialogs(); }, [phase]); /** 出售 (operators, underframe +N) / 销毁 (items and Arts, confirmed: they cannot be sold). */ const sellPiece = useCallback(async (piece) => { diff --git a/public/js/ui/gameLogic.js b/public/js/ui/gameLogic.js index 488c249b..93d1d9c8 100644 --- a/public/js/ui/gameLogic.js +++ b/public/js/ui/gameLogic.js @@ -787,6 +787,29 @@ export function shopBlockReason(kind, { priv, editable, slot, getChess, getItem return null; } +/** + * The 准备 confirmation (community report #4 after 0.1.2): readying with funds left asks first — the prep's end wipes + * them (PRTS 卫戍协议/帮助 「本回合的剩余资金将清零」; the server still does: PlayerState.endPrep). act2autochess constData + * `noMoneyTipsBand` — data/config.json economy.leftoverFundsKeptByBands, ["band_cannot"] — names the strategies whose + * funds carry over and that get no such tip (坎诺特 利滚利). No prompt for an un-ready, with 0 funds, when already ready + * or out, or under AI 托管 (the server plays the seat). The dialog's wording is the remake's own [ASSUMED]: neither the + * tables nor PRTS hold the official one. + * @param {any} priv m.private + * @param {{ ready?: boolean, keptBands?: string[]|null, autoplay?: boolean }} [opts] `ready`: the state asked for + * @returns {{ title: string, text: string, okText: string, cancelText: string, micro: string } | null} + */ +export function readyFundsPrompt(priv, { ready = true, keptBands = null, autoplay = false } = {}) { + if (!ready || autoplay || !isObj(priv) || priv.alive === false || priv.ready) return null; + const funds = Math.trunc(Number(priv.funds) || 0); + if (!(funds > 0)) return null; + const kept = Array.isArray(keptBands) ? keptBands : ['band_cannot']; + if (typeof priv.bandId === 'string' && kept.includes(priv.bandId)) return null; + return { + title: '剩余资金', micro: 'FUNDS LEFT', okText: '准备就绪', cancelText: '继续整备', + text: `还有 ${funds} 资金未使用。休整期结束时,本回合的剩余资金将清零。确定准备就绪吗?`, + }; +} + // ---- placement (canPlace mirror) ------------------------------------------------------------------------ /** diff --git a/test/ui/feedback3-ready.test.js b/test/ui/feedback3-ready.test.js new file mode 100644 index 00000000..ea2f63f9 --- /dev/null +++ b/test/ui/feedback3-ready.test.js @@ -0,0 +1,62 @@ +// The 准备 confirmation with funds left (community report #4 after 0.1.2): public/js/ui/gameLogic.js readyFundsPrompt, +// asked by public/js/screens/game.js toggleReady (the 准备 button and Space). The prep's end wipes unspent funds +// (PRTS 卫戍协议/帮助 「本回合的剩余资金将清零」); act2autochess constData noMoneyTipsBand lists the strategies with no such +// tip (坎诺特). The server rule is unchanged: readying is accepted, endPrep wipes the funds. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { readyFundsPrompt } from '../../public/js/ui/gameLogic.js'; +import { makeMatch } from '../match/harness.js'; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +const CONFIG = JSON.parse(readFileSync(path.join(ROOT, 'data/config.json'), 'utf8')); +const KEPT = CONFIG.economy.leftoverFundsKeptByBands; +const priv = (o = {}) => ({ alive: true, ready: false, funds: 5, bandId: 'band_bldsk', ...o }); + +test('readying with funds left asks first; the text names the funds and the wipe', () => { + const ask = readyFundsPrompt(priv(), { keptBands: KEPT }); + assert.ok(ask); + assert.match(ask.text, /5 资金/); + assert.match(ask.text, /剩余资金将清零/); + assert.equal(ask.okText, '准备就绪'); + assert.equal(ask.cancelText, '继续整备'); + assert.ok(readyFundsPrompt(priv({ funds: 1 }), { keptBands: KEPT }), '1 fund is enough to ask'); +}); + +test('no prompt: 0 funds, 坎诺特 (noMoneyTipsBand), un-ready, already ready, eliminated, AI 托管, no state', () => { + assert.deepEqual(KEPT, ['band_cannot'], 'data/config.json carries the official noMoneyTipsBand'); + assert.equal(readyFundsPrompt(priv({ funds: 0 }), { keptBands: KEPT }), null); + assert.equal(readyFundsPrompt(priv({ funds: -2 }), { keptBands: KEPT }), null); + assert.equal(readyFundsPrompt(priv({ bandId: 'band_cannot', funds: 9 }), { keptBands: KEPT }), null, '坎诺特 keeps its funds'); + assert.equal(readyFundsPrompt(priv({ bandId: 'band_cannot', funds: 9 })), null, 'the official list is the fallback without config'); + assert.equal(readyFundsPrompt(priv(), { ready: false, keptBands: KEPT }), null, 'cancelling a ready never asks'); + assert.equal(readyFundsPrompt(priv({ ready: true }), { keptBands: KEPT }), null); + assert.equal(readyFundsPrompt(priv({ alive: false }), { keptBands: KEPT }), null); + assert.equal(readyFundsPrompt(priv(), { keptBands: KEPT, autoplay: true }), null, 'AI 托管'); + assert.equal(readyFundsPrompt(null, { keptBands: KEPT }), null); +}); + +test('the server rule stays: readying with funds is accepted and the prep end wipes them (坎诺特 keeps them)', () => { + const h = makeMatch({ mode: 'coop', humans: 2, seed: 5, fake: true }).start(); + h.toPrep(1); + const m = h.m; + const p0 = h.ps('p_0'), p1 = h.ps('p_1'); + p0.funds = 7; p0.bandId = 'band_bldsk'; + p1.funds = 6; p1.bandId = 'band_cannot'; + assert.deepEqual(m.handle('p_0', { t: 'g.ready', ready: true }), { ok: true }); + p0.endPrep(); p1.endPrep(); + assert.equal(p0.funds, 0); + assert.equal(p1.funds, 6); + m.dispose(); +}); + +test('game.js asks through readyFundsPrompt before g.ready (button and Space share toggleReady)', () => { + const src = readFileSync(path.join(ROOT, 'public/js/screens/game.js'), 'utf8'); + const body = src.slice(src.indexOf('const toggleReady = useCallback('), src.indexOf('const toggleReady = useCallback(') + 900); + assert.match(body, /readyFundsPrompt\(L\.priv, \{ keptBands: data\.get\('config'\)\?\.economy\?\.leftoverFundsKeptByBands, autoplay: !!me\?\.autoplay \}\)/); + assert.ok(body.indexOf('confirmDialog(ask)') < body.indexOf('actions.ready(r)'), 'confirmed before the request'); + assert.match(src, /onReady=\$\{toggleReady\}/); + assert.match(src, /toggleReady\(!L\.priv\.ready\)/, 'Space'); +});