Playtest #6 follow-up (v2.5.1): leader HP review — 直接乘算, one debuff instance, 999 layer cap, 限伤; elite to the board; 假想敌:胄 kit

- co-op leaders died within seconds: leader HP is the official data (40 values pinned); bond / strategy / item /
  机变 / 特质 "+X%" bonuses now add up (PRTS 直接乘算) instead of compounding; a same-named enemy debuff keeps one
  (strongest) instance per target (奥术 across a pair field); leader parts pass damage 1:1; fixed co-op pool (the user's call)
- official limits read from the client: each bond stops at 999 layers (MAX_GARRISON_STACK); a leader hit of 300000+ in
  boss / hidden battles is cancelled (MAX_BATTLE_DAMAGE, 限伤) — shared/constants.js switches, research 11
- a merge that consumes a deployed copy puts the elite on that tile and facing (user-confirmed); gold target highlight,
  promotion effect
- 假想敌:胄 per PRTS: one shell, shells within range, any drone death costs 2 % max HP, 剑/锤 sorties and 瘫痪
- DESIGN §20.10–§20.14; 2842 unit tests, all 28 browser suites green; Chrome = Node on boss fields
This commit is contained in:
sganggs committed 2026-10-02 04:19:28 +08:00
1 parent 6464e4b46a
commit 620f88c0d8
81 files changed
+4194 -447

No files matched your search

+7 -4
View File
@@ -204,7 +204,7 @@ function GarrisonBlock({ garrison, m }) {
</section>`;
}
export function ChessDetail({ chess, piece, unit, snapHp, editable, onSell, bonds, loadout, onBond, live = null }) {
export function ChessDetail({ chess, piece, unit, snapHp, editable, onSell, bonds, loadout, onBond, live = null, hint = null }) {
const m = data.get('assets');
const hp = hpOf(live, snapHp);
const lo = chessLoadout(chess, loadout, (id) => data.lookup('chess', id));
@@ -304,7 +304,10 @@ export function ChessDetail({ chess, piece, unit, snapHp, editable, onSell, bond
blocks.actions = piece && editable && piece.kind !== 'item' ? html`<div key="actions" class="dactions">
<${Button} variant="amber" icon="close" class="dpanel__sell" onClick=${() => onSell(piece, c)}>出售<span class="dsell num">+${sell}</span><//>
</div>` : null;
return CHESS_SECTIONS.map((k) => blocks[k]).filter(Boolean);
const out = CHESS_SECTIONS.map((k) => blocks[k]).filter(Boolean);
// a merge-completing shop / reward card: where the elite goes (shopBar mergeHint), right under the header
if (hint) out.splice(1, 0, html`<p key="merge" class="dhint dhint--merge"><${Icon} name="info" />可晋升:${hint}</p>`);
return out;
}
/**
@@ -467,7 +470,7 @@ export function resolveDetail(target, pieces) {
const c = data.lookup('chess', p.id);
return c ? { type: 'chess', chess: c, piece: p } : null;
}
if (target.kind === 'chess') { const c = data.lookup('chess', target.id); return c ? { type: 'chess', chess: c } : null; }
if (target.kind === 'chess') { const c = data.lookup('chess', target.id); return c ? { type: 'chess', chess: c, hint: target.hint || null } : null; }
if (target.kind === 'item') { const it = data.lookup('items', target.id); return it ? { type: 'item', item: it } : null; }
if (target.kind === 'enemy') { const en = data.lookup('enemies', target.id); return en ? { type: 'enemy', enemy: en, count: target.count } : null; }
if (target.kind === 'token') { const t = data.lookup('tokens', target.id); return t ? { type: 'token', token: t } : null; }
@@ -518,7 +521,7 @@ export function DetailPanel({ detail, editable, snapHp, onClose, onSell, onDestr
<button type="button" class="dpanel__close" aria-label="关闭" onClick=${onClose}><${Icon} name="close" /></button>
<div class="dpanel__scroll">
${detail.type === 'chess' ? html`<${ChessDetail} chess=${detail.chess} piece=${detail.piece} snapHp=${snapHp} editable=${editable} onSell=${sellIt}
bonds=${bonds} loadout=${loadout} onBond=${onBond} live=${liveNow} />` : null}
bonds=${bonds} loadout=${loadout} onBond=${onBond} live=${liveNow} hint=${detail.hint || null} />` : null}
${detail.type === 'item' ? html`<${ItemDetail} item=${detail.item} piece=${detail.piece} editable=${editable} onDestroy=${destroyIt} />` : null}
${detail.type === 'enemy' ? html`<${EnemyDetail} enemy=${detail.enemy} snapHp=${snapHp} count=${detail.count} live=${liveNow} />` : null}
${detail.type === 'token' ? html`<${TokenDetail} token=${detail.token} piece=${detail.piece} ownerId=${detail.ownerId ?? null} snapHp=${snapHp} live=${liveNow} />` : null}
+27 -1
View File
@@ -458,7 +458,9 @@ export function priceTone(slot) {
}
/**
* Owned copies of a base chess (normal pieces on board/hand/temp) — shop cards show merge progress.
* 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]
@@ -468,12 +470,36 @@ 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 (row desc, then col asc) — `{ 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) => b.row - a.row || a.col - b.col);
return { row: board[0].row, col: board[0].col, dir: board[0].dir || 'RIGHT' };
}
/** Whether every hand slot is taken (the server refuses a purchase / reward that needs a slot: HAND_FULL). */
export function handFull(priv) {
const hand = Array.isArray(priv?.hand) ? priv.hand : [];
+39 -8
View File
@@ -19,7 +19,7 @@
import { useEffect, useState } from '../../vendor/hooks.module.js';
import { html, Icon, HexBadge, TierChip, Tooltip, MicroLabel } from './components.js';
import { Img, BondGlyph, CoinGlyph, GIcon, RichText } from './gameComponents.js';
import { priceTone, mergeProgress, shopBlockReason, chessLoadout } from './gameLogic.js';
import { priceTone, mergeProgress, mergeTarget, shopBlockReason, chessLoadout } from './gameLogic.js';
import { chessPortraitUrl, itemIconUrl, profIconUrl, uiUrl, skillIconUrl, skillRecordIconUrl, moduleTypeIconUrl } from './assetUrls.js';
import { data } from '../data.js';
@@ -37,6 +37,16 @@ function PriceHex({ slot, free, poor = false }) {
/** Data lookups for shopBlockReason (full-hand purchases that complete a merge stay allowed). */
const LOOKUPS = { getChess: (id) => data.lookup('chess', id), getItem: (id) => data.lookup('items', id) };
/**
* Where a merge-completing buy sends the elite (DESIGN §20.11), null when the card completes no merge: the 可晋升 tag's
* title, and a line of the detail card the first tap opens — a touch screen never shows a title (QA 6b).
*/
export function mergeHint(priv, chessId) {
const prog = mergeProgress(priv, chessId, LOOKUPS.getChess);
if (!(prog.copies > 0 && prog.copies + 1 >= prog.need)) return null;
return mergeTarget(priv, chessId, LOOKUPS.getChess) ? '精锐干员将出现在作战区原位置' : '精锐干员将进入整备区';
}
/** The armed (first-tapped) card's confirm strip: 确认购买 / 确认选择, or 无法购买 + why. */
function ArmedTag({ reason, free }) {
if (reason) return html`<span class="scard__confirm is-no" role="status"><b>无法购买</b><small>${reason}</small></span>`;
@@ -53,13 +63,14 @@ export function ChessCard({ slot, idx, priv, frozen = false, reason = null, free
const m = data.get('assets');
const tier = c?.tier ?? 1;
const prog = mergeProgress(priv, slot.id, (id) => data.lookup('chess', id));
const willMerge = prog.copies + 1 >= prog.need && prog.copies > 0;
const hint = mergeHint(priv, slot.id);
const willMerge = !!hint;
const bonds = Array.isArray(c?.bonds) ? c.bonds : [];
const disabled = !!reason;
const lo = c ? chessLoadout(c, priv?.loadout, LOOKUPS.getChess) : null;
const tap = () => { if (onTap) onTap(idx); else if (!disabled) onBuy(idx); else onDetail(slot.id); };
const tap = () => { if (onTap) onTap(idx); else if (!disabled) onBuy(idx); else onDetail(slot.id, 'chess', hint); };
const card = html`<button type="button" class=${cx('scard', `scard--t${tier}`, frozen && 'is-frozen', disabled && 'is-disabled', willMerge && 'is-merge', armed && 'is-armed')}
onClick=${tap} onContextMenu=${(e) => { e.preventDefault(); onDetail(slot.id); }}
onClick=${tap} onContextMenu=${(e) => { e.preventDefault(); onDetail(slot.id, 'chess', hint); }}
aria-label=${`${c?.name || '干员'},价格 ${slot.price}${armed ? (disabled ? ',无法购买' : ',再次点击确认') : ''}`} aria-pressed=${onTap ? String(!!armed) : undefined}>
<span class="scard__bg" aria-hidden="true"></span>
<span class="scard__water" aria-hidden="true">${bonds[0] ? html`<${BondGlyph} bondId=${bonds[0]} />` : null}</span>
@@ -82,7 +93,7 @@ export function ChessCard({ slot, idx, priv, frozen = false, reason = null, free
<span>${c?.subProfessionName || ''}</span>
</span>
</span>
${willMerge ? html`<span class="scard__mergetag">可晋升</span>` : null}
${willMerge ? html`<span class="scard__mergetag" title=${hint}>可晋升</span>` : null}
${frozen ? html`<span class="scard__ice" aria-hidden="true"><${Icon} name="snow" /></span>` : null}
${armed ? html`<${ArmedTag} reason=${reason} free=${free} />` : null}
</button>`;
@@ -160,6 +171,20 @@ function LevelCard({ shop, reason, armed = false, onTap }) {
/** Key of an armed shop card: kind + slot index + the id it showed (a reroll / sale changes it ⇒ disarm). */
export const armKey = (kind, idx, slot) => `${kind}:${idx}:${slot?.id ?? ''}`;
/**
* The slot an armed key names (`c` / `i` shop slots, `r` reward slots), or null ('lv', nothing armed, a stale key).
* @param {string|null} key
* @param {any[]} slots the shop's slots
* @param {any[]|null} rewardSlots the shown reward offer's slots
*/
export function armedSlotOf(key, slots, rewardSlots = null) {
const m = typeof key === 'string' ? /^([cir]):(\d+):(.*)$/.exec(key) : null;
if (!m) return null;
const list = m[1] === 'r' ? rewardSlots : slots;
const s = Array.isArray(list) ? list[Number(m[2])] : null;
return s && !s.sold && s.id === m[3] ? s : null;
}
/**
* Two-tap state of the bar (first tap arms, second confirms). Disarms on a tap outside the shop / detail panel, on
* Escape, when the armed card changes or disappears, and when the bar stops being editable.
@@ -210,12 +235,12 @@ function RewardCards({ offer, priv, editable, onPick, onDetail, onLater, armed,
/**
* The bar.
* @param {{ priv:any, editable:boolean, collapsed:boolean, onCollapse:(c:boolean)=>void,
* onBuy:(i:number)=>void, onLevel:Function, onRefresh:Function, onFreeze:Function, onDetail:(id:string, kind?:string)=>void,
* onBuy:(i:number)=>void, onLevel:Function, onRefresh:Function, onFreeze:Function, onDetail:(id:string, kind?:string, hint?:string|null)=>void,
* onDetailClose?: () => void, onRefuse?: (reason: string) => void, barRef:any,
* reward?: any, onReward?: (idx:number)=>void, onRewardLater?: Function }} props
*/
export function ShopBar({ priv, editable, collapsed, onCollapse, onBuy, onLevel, onRefresh, onFreeze, onDetail, onDetailClose, onRefuse, barRef,
reward = null, onReward, onRewardLater }) {
reward = null, onReward, onRewardLater, onArm = null }) {
const shop = priv?.shop || {};
const slots = Array.isArray(shop.slots) ? shop.slots : [];
const chessSlots = slots.map((s, i) => ({ s, i })).filter(({ s }) => !s || s.kind !== 'item');
@@ -237,10 +262,16 @@ export function ShopBar({ priv, editable, collapsed, onCollapse, onBuy, onLevel,
if (showReward) reward.slots.forEach((s, i) => { if (s && !s.sold) keys.add(armKey('r', i, s)); });
}
const [armed, setArmed] = useTwoTap({ editable, keys });
// the armed card's slot (game.js lights the tile a merge's elite will take — gameLogic.mergeTarget); kept before the
// collapsed tab's early return (hook order)
const armedSlot = armedSlotOf(armed, slots, showReward ? reward.slots : null);
const armedSig = armedSlot ? `${armedSlot.kind}:${armedSlot.id}` : '';
useEffect(() => { onArm?.(armedSlot ? { kind: armedSlot.kind === 'item' ? 'item' : 'chess', id: armedSlot.id } : null); }, [armedSig]);
useEffect(() => () => onArm?.(null), []);
/** First tap arms + opens the detail; the second buys (or says why it can't). */
const tapCard = (kind, idx, slot, detailKind, reason, buy) => {
const key = armKey(kind, idx, slot);
if (armed !== key) { setArmed(key); onDetail(slot.id, detailKind); return; }
if (armed !== key) { setArmed(key); onDetail(slot.id, detailKind, detailKind === 'chess' ? mergeHint(priv, slot.id) : null); return; }
if (reason) { onRefuse?.(reason); return; }
setArmed(null);
onDetailClose?.();