Files
ark-stronghold-protocol/public/js/ui/gameLogic/shop.js
T
Sukvii 56a7b918a9 feedback7 dober: 教鞭 is the official personal choice of three 战术特训 cards again, not a random one (PR #353 by @Sukvii, the owner's decision of 2026-10-08); golden: coop2-NORMAL-8-serverrun and coop2-ABYSS-10-boosted move
Seen: the Art 教鞭 (杜宾's strategy) drew three 战术特训 cards and added a random one — a documented simplification (docs/META.md
§2.5: a personal choice would need its own phase, protocol message, timer and AI takeover). Official: act2autochess
choice_event hunter_band_1 is a PERSONAL_CHOOSE; PRTS 下半 记录 §法术 教鞭 "使用后销毁,于3个战术特训的悬赏任务中选择一项". The
owner decided on 2026-10-08 to follow it. Taken from PR #353 (code and tests by @Sukvii), ported to the split 0.2.2 modules.
Now: ctx.offerBountyChoice -> Match.offerBountyChoice keeps up to three different mode-eligible cards (15 of 20 in 标准 /
险境, 20 in 绝境 / 终极; one meta-rng shuffle) in PlayerState.personalChoice, m.private only (same cards after a
reconnect); g.choice { idx, choiceId } confirms it (Match.pickPersonalChoice: owner, id, round, index; a request without
a choiceId is still the public draft, a stale id never falls through to it). No new phase or timer: Ready is refused while
it is open, the prep's deadline picks at random before the temp pieces and Ready, a bot or a 托管 seat picks with the bounty
scorer (botPickCard, the bots' seeded rng; at the prep's start, after the cast, before Ready; the scheduler's fallback
picks at random if a step fails). The client reuses ChoiceOverlay (personal: no order, no turns, the prep's countdown) over
a locked prep, the Ready reasons, the mock harness; six UI strings in en / ja / ko / zh-TW. An eliminated player's offer
goes with it; invariants.js flags an offer outside its owner's own open prep.
Not taken from the PR: its §25.24 in the released docs/history/0.2.0.md (the 0.2.2 section is the integrator's), its edits
of the Ready toggle beyond the personal-choice case, its tail placement of the i18n keys.
Golden: coop2-NORMAL-8-serverrun (ai_0 杜宾 casts 教鞭 from R2 on and now scores the three cards: teamLp 39 -> 34, virtualMs
121400 -> 118400, rng shop 511 -> 483 / bots 18063 -> 18070 / meta 112 -> 103) and coop2-ABYSS-10-boosted (ai_1 杜宾: virtualMs
121650 -> 124650, shop 590 -> 589 / bots 22075 -> 22096 / meta 141 -> 154); both first differ in round 2, every other scenario
and family is unchanged; node tools/golden.mjs --twice is identical.
Tests: items.test.js (three distinct cards, the tapped one applied once, nothing before the confirm, a refused cast keeps
the Art / count / rng / uid, an eliminated player's offer), economy.test.js (Ready refused, the deadline picks before
endPrep, solo prep untimed), connection.test.js (two players' offers, reconnect, foreign / duplicate / old ids, an offline
player's pick at the deadline), bot.test.js (托管 takeover incl. the failing-step fallback), bot-smarter.test.js (the scorer's
pick, deterministic per seed, no rng draw when nothing is pending), followups.test.js (R14 / R15), draft.test.js,
unitStats.test.js (protocol), hardening.test.js (invariant), gameLogic / playtest3 / playtest4 (view model, Ready reason,
ChoiceView, actions), mock.e2e.test.js and playtest2.real.e2e.test.js (browser, SP_E2E=1).
2026-10-08 20:45:54 +08:00

189 lines
11 KiB
JavaScript

// ui/gameLogic/shop.js — shop prices, merges, offers, the ready-funds prompt. Re-exported from ../gameLogic.js.
import { GEO } from '../../../../shared/constants.js';
import { isObj } from './shared.js';
import { t, tName } from '../../../../shared/i18n.js';
// ---- shop ---------------------------------------------------------------------------------------------
/**
* Price tone of a shop slot: discounted (mint), marked up (red) or normal (gold).
* @param {{price?:number, basePrice?:number}|null} slot
* @returns {'discount'|'premium'|'gold'}
*/
export function priceTone(slot) {
if (!isObj(slot)) return 'gold';
const p = Number(slot.price);
const b = Number(slot.basePrice);
if (Number.isFinite(p) && p === 0) return 'discount';
if (!Number.isFinite(p) || !Number.isFinite(b)) return 'gold';
if (p < b) return 'discount';
if (p > b) return 'premium';
return 'gold';
}
/**
* Owned copies of a base chess (normal pieces on board/hand/temp) — shop cards show merge progress. An elite card
* never merges (server PlayerState.completesChessMerge refuses isGolden), so it reports 0 copies: no pips, no 可晋升
* tag and no mergeTarget tile.
* @param {any} priv
* @param {string} chessId
* @param {(id:string)=>any} [getChess]
* @returns {{ copies: number, need: number }}
*/
export function mergeProgress(priv, chessId, getChess = () => null) {
const c = getChess(chessId);
const base = c?.baseId || chessId;
const need = Number.isInteger(c?.upgradeNum) && c.upgradeNum > 0 ? c.upgradeNum : 3;
if (c?.isGolden) return { copies: 0, need };
let copies = 0;
const pieces = [...(Array.isArray(priv?.board) ? priv.board : []), ...(Array.isArray(priv?.hand) ? priv.hand : []), ...(Array.isArray(priv?.temp) ? priv.temp : [])];
for (const p of pieces) if (p?.kind === 'chess' && !p.golden && (p.id === base || getChess(p.id)?.baseId === base) && !String(p.id).endsWith('_b')) copies += 1;
return { copies, need };
}
/**
* Where the elite appears when gaining one more normal copy of `chessId` completes a merge now (PRTS 卫戍协议/帮助
* "若消耗已部署至作战区的干员,则发送至作战区对应位置"; mirror of server board.js mergeTile / PlayerState._mergeChess): the
* board tile of the deployed copy that deploys first (col asc, then row desc: by column from the left, top to bottom —
* Battle.start) — `{ row, col, dir }` — or null (no
* merge — an elite card never merges, see mergeProgress — or no copy is deployed: the elite goes to the hand). The
* copies stand on legal tiles, and the elite is the same operator, so the tile needs no legality check here.
* @param {any} priv
* @param {string} chessId
* @param {(id:string)=>any} [getChess]
* @returns {{ row: number, col: number, dir: string } | null}
*/
export function mergeTarget(priv, chessId, getChess = () => null) {
const { copies, need } = mergeProgress(priv, chessId, getChess);
if (!(copies > 0 && copies + 1 >= need)) return null;
const c = getChess(chessId);
const base = c?.baseId || chessId;
const board = (Array.isArray(priv?.board) ? priv.board : []).filter((p) => p?.kind === 'chess' && !p.golden && Number.isInteger(p.row) && Number.isInteger(p.col)
&& !String(p.id).endsWith('_b') && (p.id === base || getChess(p.id)?.baseId === base));
if (!board.length) return null;
board.sort((a, b) => a.col - b.col || b.row - a.row);
return { row: board[0].row, col: board[0].col, dir: board[0].dir || 'RIGHT' };
}
/** Whether every hand slot is taken (the server refuses every purchase / reward pick then: HAND_FULL, GitHub #82). */
export function handFull(priv) {
const hand = Array.isArray(priv?.hand) ? priv.hand : [];
return hand.length >= GEO.HAND_SIZE && hand.every((p) => p != null);
}
/**
* Would gaining one more `slot` (shop / reward card: { kind: 'chess'|'item', id }) complete a merge right away? Mirror
* of server PlayerState completesChessMerge / completesItemMerge. (A full hand refuses such a purchase all the same:
* PRTS 卫戍协议/帮助 §手牌区, GitHub #82.)
* @param {any} priv
* @param {{kind?:string, id:string}} slot
* @param {{ getChess?:(id:string)=>any, getItem?:(id:string)=>any, itemMergeCount?: number }} [o]
*/
export function completesMerge(priv, slot, { getChess = () => null, getItem = () => null, itemMergeCount = 2 } = {}) {
if (!isObj(slot) || typeof slot.id !== 'string') return false;
if (slot.kind === 'item') {
const rec = getItem(slot.id);
if (!rec || rec.isGolden || rec.itemType !== 'EQUIP' || !rec.mergeable) return false;
const n = Number.isInteger(rec.upgradeNum) ? rec.upgradeNum : itemMergeCount;
if (!(n > 1 && n < 100) || !getItem(rec.upgradeChessId || rec.goldenId)) return false;
let have = 0;
const holders = [...(Array.isArray(priv?.board) ? priv.board : []), ...(Array.isArray(priv?.hand) ? priv.hand : []), ...(Array.isArray(priv?.temp) ? priv.temp : [])];
for (const p of holders) {
if (!isObj(p)) continue;
if (p.kind === 'item' && p.id === slot.id) have += 1;
if (p.kind === 'chess') for (const it of Array.isArray(p.items) ? p.items : []) if (it?.id === slot.id) have += 1;
}
return have + 1 >= n;
}
const rec = getChess(slot.id);
if (!rec || rec.isGolden) return false;
const base = getChess(rec.baseId) || rec;
const golden = (base.goldenId && getChess(base.goldenId)) || getChess(String(base.chessId || slot.id).replace(/_a$/, '_b'));
if (!golden) return false;
const { copies, need } = mergeProgress(priv, slot.id, getChess);
return need > 1 && copies + 1 >= need;
}
/**
* How the shop bar names a free pick-one offer (m.private shop.rewardOffer): the promotion reward (`source` 'merge')
* reads 晋升奖励 / PROMOTION; any other offer — a strategy's special refresh (凯瑟琳 定向投放, 娜仁图亚 见者有份), an item
* (寻呼模块, 信标) or a 特质 (松果) — reads its `label` with the refresh icon (player report #6 after 0.1.0: 凯瑟琳's three
* items showed up as nameless operator cards under 晋升奖励). `items` = the offer holds items (cards drawn as item cards).
* `queued` = offers waiting behind this one (shop.rewardOffer.queued, e.g. a 定向投放 behind a promotion reward put
* off): `more` (header) and the pill's "+N" say so, null / no suffix when none.
* @param {any} offer
* @returns {{ title: string, micro: string, sub: string, icon: string, items: boolean, pill: string, queued: number, more: string|null }}
*/
export function offerHeader(offer) {
const slots = isObj(offer) && Array.isArray(offer.slots) ? offer.slots : [];
const items = slots.some((s) => isObj(s) && s.kind === 'item');
// (the server names the offer by its source in Chinese: a strategy's effect, an item, an operator — a data name)
const label = isObj(offer) && typeof offer.label === 'string' && offer.label ? tName(offer.label) : null;
const queued = isObj(offer) && Number.isInteger(offer.queued) && offer.queued > 0 ? offer.queued : 0;
const tail = { queued, more: queued ? t('之后还有 {queued} 项', { queued }) : null };
if (!items && (!isObj(offer) || offer.source === 'merge' || offer.source == null) && !label) {
return { title: t('晋升奖励'), micro: 'PROMOTION', sub: t('免费选择 1 名'), icon: 'crown', items: false, pill: t('晋升奖励待选择'), ...tail };
}
const title = label || (items ? t('装备补给') : t('特殊招募'));
return { title, micro: 'SPECIAL', sub: items ? t('免费选择 1 件') : t('免费选择 1 名'), icon: 'refresh', items, pill: t('{title}待选择', { title }), ...tail };
}
/**
* Why a shop action is unavailable (null when available).
* @param {'buy'|'reward'|'refresh'|'freeze'|'levelUp'|'ready'} kind
* @param {{ priv:any, editable:boolean, slot?:any }} ctx
* @returns {string|null} Chinese reason
*/
export function shopBlockReason(kind, { priv, editable, slot } = {}) {
if (!priv) return t('尚未就绪');
if (priv.alive === false) return t('你已被淘汰');
if (kind === 'ready') {
if (priv.personalChoice) return t('请先完成教鞭选择');
return priv.canReady === false ? t('临时整备区不为空,请先处理溢出的资源') : null;
}
if (!editable) {
if (kind === 'reward') return t('当前无法选择');
return priv.ready ? t('已准备就绪,取消准备后才能操作') : t('当前阶段无法进行该操作');
}
const funds = Number(priv.funds) || 0;
const shop = priv.shop || {};
if (kind === 'buy' || kind === 'reward') {
if (!isObj(slot) || slot.sold) return kind === 'reward' ? t('已选择') : t('已售出');
if ((Number(slot.price) || 0) > funds) return t('资金不足');
// a full hand refuses every purchase / pick, a merge-completing one too (PRTS 卫戍协议/帮助 §手牌区, GitHub #82)
if (handFull(priv)) return t('整备区已满');
return null;
}
if (kind === 'refresh') return (Number(shop.refreshPrice) || 0) > funds ? t('资金不足') : null;
if (kind === 'levelUp') {
if ((Number(shop.level) || 1) >= (Number(shop.maxLevel) || 6)) return t('调度中心已达最高等级');
return (Number(shop.upgradePrice) || 0) > funds ? t('资金不足') : null;
}
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: t('剩余资金'), micro: 'FUNDS LEFT', okText: t('准备就绪'), cancelText: t('继续整备'),
text: t('还有 {funds} 资金未使用。休整期结束时,本回合的剩余资金将清零。确定准备就绪吗?', { funds }),
};
}