feedback5 WN: 补位 display follows the official game (the owner's recall of 2026-10-06) — a chess the player does not own is shown as its stand-in wherever the player's copy appears: shop and reward cards (the stand-in's portrait, name, class and backup skill; the chess's bonds, tier and price; a small 「替补」 mark), the hand and 临时整备区 pieces (the stand-in's model, a 「替补」 tag; the board already was), the detail card (the stand-in's portrait, name, skill, talents and module with the chess's bonds and 特质, 「银灰的替补」 under the name [ASSUMED]), bond popups (own and a teammate's by UnitInfo standInFor through ownerBoard / ownerOps), the result lineup (m.result standInFor), prep scouting of the bench, the equip-replace dialog, the elite and gift tickers; rules unchanged (bonds, price, tier, merges, pools and 特质 stay the chess's); new strings through t() with English; tests updated (standin-ui 15, standin-ownership 7, watch-bonds wiring) and the e2e now ends on the result screen (fastServer SP_FINISH_AFTER); golden unchanged
This commit is contained in:
1 parent
78251f499c
commit
0e8004a382
32 files changed
+518
-181
No files matched your search
@@ -38,6 +38,9 @@
|
||||
.uthumb.is-golden::after { content: ''; position: absolute; inset: -2px; border: 1px solid rgba(255, 198, 0, .7); pointer-events: none; clip-path: inherit; }
|
||||
.uthumb.is-dim .uthumb__art img { filter: grayscale(.85) brightness(.55); }
|
||||
.uthumb__tag { position: absolute; right: 0; bottom: 0; font-size: calc(var(--ut) * .17); line-height: 1.2; padding: 0 .03rem; background: #a9c3d8; color: #0b1520; font-weight: 700; }
|
||||
/* 0.2.0 补位: the small 「替补」 mark of a stand-in's thumbnail (the result lineup, bond popup members — screens/result.js,
|
||||
ui/bondStrip.js) */
|
||||
.uthumb__si { position: absolute; right: 0; bottom: 0; font-size: max(calc(var(--ut) * .2), 8px); line-height: 1.2; padding: 0 .03rem; background: var(--ice); color: var(--ice-900); font-weight: 700; opacity: .9; pointer-events: none; }
|
||||
|
||||
.bglyph { display: inline-grid; place-items: center; width: .24rem; height: .24rem; flex: none; }
|
||||
.bglyph img { width: 100%; height: 100%; object-fit: contain; filter: brightness(0) invert(.86) sepia(.4) hue-rotate(110deg) saturate(2.5); }
|
||||
@@ -82,6 +85,8 @@
|
||||
.dhead__chips { display: flex; align-items: center; gap: .08rem; flex-wrap: wrap; }
|
||||
.dhead__name { font-size: .32rem; font-weight: 900; letter-spacing: .04em; line-height: 1.2; }
|
||||
.dhead__en { font-family: var(--font-display); font-size: .12rem; letter-spacing: .2em; color: var(--text-dim); text-transform: uppercase; }
|
||||
/* 0.2.0 补位: the replaced operator under a stand-in's name (「银灰的替补」, ui/detailPanel.js ChessDetail) */
|
||||
.dhead__for { display: block; font-size: max(.13rem, 9px); letter-spacing: .04em; color: var(--ice); }
|
||||
.dhead__flavor { font-size: .13rem; color: var(--text-lo); font-style: italic; }
|
||||
.dhead__class { display: flex; align-items: center; gap: .06rem; flex-wrap: wrap; font-size: .15rem; color: var(--text-md); margin-top: .06rem; }
|
||||
.dhead__class .sep { width: 1px; height: .16rem; background: var(--line-2); }
|
||||
@@ -90,9 +95,9 @@
|
||||
.dtag-elite, .dtag-token, .dtag-kind { font-size: .13rem; font-weight: 700; padding: .01rem .08rem; letter-spacing: .08em; }
|
||||
.dtag-elite { background: var(--gold); color: #1d1400; }
|
||||
.dtag-token { background: #a9c3d8; color: #0b1520; }
|
||||
/* 0.2.0 补位: the card of a chess fielded as its stand-in (ui/detailPanel.js ChessDetail) */
|
||||
/* 0.2.0 自选编队 / 补位: the card of a 自选 piece, of a chess fielded as its stand-in (ui/detailPanel.js ChessDetail) */
|
||||
.dtag-diy { font-size: .13rem; font-weight: 700; padding: .01rem .08rem; letter-spacing: .08em; background: var(--mint-500); color: var(--text-on-mint); }
|
||||
.dtag-standin { font-size: .13rem; font-weight: 700; padding: .01rem .08rem; letter-spacing: .04em; background: var(--ice); color: var(--ice-900); }
|
||||
.dtag-standin { font-size: .13rem; font-weight: 700; padding: .01rem .08rem; letter-spacing: .08em; background: var(--ice); color: var(--ice-900); }
|
||||
.dtag-kind { border: 1px solid var(--line-2); color: var(--text-lo); font-weight: 500; }
|
||||
.drank { font-size: .13rem; font-weight: 700; padding: .01rem .08rem; }
|
||||
.drank--normal { background: var(--bg-4); color: var(--text-md); }
|
||||
|
||||
@@ -130,9 +130,10 @@
|
||||
.scard__class { display: flex; align-items: center; gap: .05rem; font-size: .13rem; color: var(--text-lo); }
|
||||
.scard__prof { width: .18rem; height: .18rem; object-fit: contain; opacity: .85; }
|
||||
.scard__mergetag { position: absolute; z-index: 3; right: 0; top: .38rem; font-size: .12rem; font-weight: 700; padding: .01rem .06rem; background: var(--gold); color: #1d1400; letter-spacing: .08em; }
|
||||
/* 0.2.0 补位: a chess the player does not own — its card stays, the stand-in fights (ui/shopBar.js ChessCard) */
|
||||
.scard__standin { position: absolute; z-index: 3; left: 0; top: .38rem; max-width: calc(100% - .1rem); font-size: .12rem; font-weight: 700; padding: .01rem .06rem;
|
||||
background: var(--ice); color: var(--ice-900); letter-spacing: .04em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; box-shadow: 0 1px 3px rgba(0, 0, 0, .6); }
|
||||
/* 0.2.0 补位: a chess the player does not own shows its stand-in (ui/shopBar.js ChessCard) — a small 「替补」 mark */
|
||||
.scard__standin { position: absolute; z-index: 3; left: 0; top: .38rem; max-width: calc(100% - .1rem); font-size: max(.11rem, 8px); font-weight: 700; line-height: 1.4;
|
||||
padding: 0 .05rem; background: var(--ice); color: var(--ice-900); letter-spacing: .08em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
opacity: .9; box-shadow: 0 1px 3px rgba(0, 0, 0, .6); }
|
||||
/* 0.2.0 自选编队: a DIY slot the player filled — the card is its operator, only in this player's shop (ui/shopBar.js) */
|
||||
.scard__diy { position: absolute; z-index: 3; left: 0; top: .38rem; font-size: .12rem; font-weight: 700; padding: .01rem .06rem;
|
||||
background: var(--mint-500); color: var(--text-on-mint); letter-spacing: .08em; white-space: nowrap; box-shadow: 0 1px 3px rgba(0, 0, 0, .6); }
|
||||
|
||||
@@ -875,6 +875,10 @@
|
||||
"自选": "Custom",
|
||||
"自选编队:所选技能与模组,没有特质,盟约按所属阵营分配": "Custom Squad: the chosen skill and module, no Attribute, Alliances by its factions",
|
||||
"自选编队:{name}": "Custom Squad: {name}",
|
||||
"替补": "Stand-in",
|
||||
"{name}的替补": "Stand-in for {name}",
|
||||
"未持有{name}:由替补干员 {standIn} 上场(盟约、特质、阶级与价格不变)": "{name} not owned: the stand-in {standIn} takes the field (Alliances, Attribute, tier and price unchanged)",
|
||||
"未持有的干员由官方指定的替补干员上场:商店、整备区、作战区与结算都显示替补干员(模型、技能、天赋、攻击范围),盟约、特质、阶级、价格与合成仍按原干员。预设干员无论是否持有都由本人上场,不在此列。": "An Operator you do not own takes the field as its official stand-in: the shop, the Bench, the battlefield and the results show the stand-in (model, skills, talents, attack range), while Alliances, Attribute, tier, price and promotion follow the original Operator. Preset Operators always take the field themselves, owned or not, and are not listed.",
|
||||
"全部清空": "Clear All",
|
||||
"清空全部 {n} 个自选名额?": "Clear all {n} Custom Squad {n|slot|slots}?",
|
||||
"文件过大,请选择「导出」下载的自选编队文件": "The file is too large. Choose a Custom Squad file downloaded with \"Export\".",
|
||||
|
||||
@@ -55,9 +55,9 @@
|
||||
// 0.1.0). Read-only: it takes the stats the sim computed last (`unit._s`) and the range grid it
|
||||
// keeps, and never makes the unit recompute them, so looking never changes the battle's floats.
|
||||
// battleRunner.unitIdOf(uid, ownerId, fieldId?) → the id of an own board piece's unit in that battle | null
|
||||
// battleRunner.ownerOps(ownerId, fieldId?) → [{ kind: 'op', ownerId, defId, items? }] that player's operators in the
|
||||
// battle on screen with their equipment (a teammate's bond popup: the members in play, DESIGN
|
||||
// §20.15, 变形同构体 wearers included) | []
|
||||
// battleRunner.ownerOps(ownerId, fieldId?) → [{ kind: 'op', ownerId, defId, items?, standInFor? }] that player's
|
||||
// operators in the battle on screen with their equipment (a teammate's bond popup: the members in
|
||||
// play, DESIGN §20.15, 变形同构体 wearers included; a 补位 stand-in names the replaced charId) | []
|
||||
//
|
||||
// createBattleRunner(deps) builds an instance with injectable net / store / clock / frame scheduler / sim loader
|
||||
// (test/match/runner.test.js drives it under Node).
|
||||
@@ -793,7 +793,11 @@ export function createBattleRunner(deps) {
|
||||
if (!e || typeof ownerId !== 'string' || !ownerId || (fieldId != null && e.fieldId !== fieldId)) return [];
|
||||
const list = Array.isArray(e.battle.allyUnits) ? e.battle.allyUnits : [];
|
||||
return list.filter((u) => u && u.kind === 'op' && u.ownerId === ownerId && typeof u.defId === 'string')
|
||||
.map((u) => (Array.isArray(u.items) && u.items.length ? { kind: 'op', ownerId, defId: u.defId, items: [...u.items] } : { kind: 'op', ownerId, defId: u.defId }));
|
||||
.map((u) => {
|
||||
const o = Array.isArray(u.items) && u.items.length ? { kind: 'op', ownerId, defId: u.defId, items: [...u.items] } : { kind: 'op', ownerId, defId: u.defId };
|
||||
const si = u.def && typeof u.def.standInFor === 'string' ? u.def.standInFor : null;
|
||||
return si ? { ...o, standInFor: si } : o;
|
||||
});
|
||||
},
|
||||
/** Re-show the current battle (the game screen remounted). */
|
||||
reshow() { if (cur) show(cur); },
|
||||
|
||||
@@ -275,7 +275,7 @@ export async function createFieldView(host, options = {}) {
|
||||
let penHidden = true; // the pen's figures are shown only by the pen camera (setPenHidden)
|
||||
let penList = null;
|
||||
let ownPen = null; // the own m.private.nextEnemies (fallback composition of a scouted teammate's pen)
|
||||
let standInList = []; // the own m.private.standIns (0.2.0 补位): board pieces of these chess draw the stand-in
|
||||
let standInList = []; // the own m.private.standIns (0.2.0 补位): pieces of these chess draw the stand-in
|
||||
let diyPicks = {}; // the own m.private.diy (0.2.0 自选编队): pieces of these DIY slots draw the operator
|
||||
let camBeforePen = null; // { kind, opts } the camera the pen returns to
|
||||
let leader = null; // { key, view, stand, area } the round leader standing on the boss field in the prep (setLeader)
|
||||
@@ -631,9 +631,9 @@ export async function createFieldView(host, options = {}) {
|
||||
return { kind: 'token', side: 'ally', defId: piece.id, spine: rec?.assets?.spine || piece.id, avatar: rec?.assets?.avatar || piece.id, tier: piece.tier || 1, golden: false, dir };
|
||||
}
|
||||
const chess = data.chess(piece.id);
|
||||
// 0.2.0 补位: a board piece of a chess the player does not own (m.private.standIns) is deployed as its stand-in —
|
||||
// the stand-in's model; on the bench it keeps the chess's own (the hand shows the original operator)
|
||||
const si = area === 'board' && chess && standInList.includes(chess.baseId || chess.chessId) ? data.standIn(piece.id) : null;
|
||||
// 0.2.0 补位: a piece of a chess the player does not own (m.private.standIns) is its stand-in's model, in the hand,
|
||||
// the 临时整备区 and on the board alike (the owner's recall of the official mode, 2026-10-06)
|
||||
const si = chess && standInList.includes(chess.baseId || chess.chessId) ? data.standIn(piece.id) : null;
|
||||
// 0.2.0 自选编队: a DIY slot the player filled is its operator, on the board and on the bench alike
|
||||
const pick = chess && chess.isDiy ? diyPicks[chess.baseId || chess.chessId] : null;
|
||||
const dr = pick ? data.diy(piece.id, pick) : null;
|
||||
@@ -720,7 +720,7 @@ export async function createFieldView(host, options = {}) {
|
||||
const key = 'p:' + e.uid;
|
||||
e.key = key;
|
||||
const info = pieceInfo(e.piece, e.area);
|
||||
// (the model is part of it: a 补位 chess changes body between the bench and the board — pieceInfo)
|
||||
// (the model is part of it: a piece whose body changes — a merge, an own 补位 / 自选 setting arriving — is rebuilt)
|
||||
const sig = `${info.kind}|${info.defId}|${info.golden ? 1 : 0}|${info.spine || ''}`;
|
||||
let v = views.get(key);
|
||||
if (v && v._sig !== sig) { dropView(key); v = null; }
|
||||
|
||||
@@ -96,7 +96,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, unitLoadout, deployedRecord,
|
||||
mergeTarget, modeOffBonds, readyFundsPrompt, ownerBandId, ownDiyRecord,
|
||||
mergeTarget, modeOffBonds, readyFundsPrompt, ownerBandId, ownDiyRecord, ownStandIn,
|
||||
} from '../ui/gameLogic.js';
|
||||
import { toast } from '../ui/toasts.js';
|
||||
import { BriefingScreen } from './briefing.js';
|
||||
@@ -240,6 +240,10 @@ function MatchScreen() {
|
||||
// the own pieces' records: a DIY slot the player filled is its 自选 operator (0.2.0, m.private.diy — position, range,
|
||||
// name; gameLogic/diy.js), every other chess its data record
|
||||
const ownChess = (id) => { const c = gd.chess(id); return ownDiyRecord(c, live.current.priv, { chess: data.get('chess'), backups: data.get('backups') }) || c; };
|
||||
// what the own pieces show (names and art of the facing wheel, the underframe, the equip-replace dialog): a chess the
|
||||
// player does not own is its stand-in (0.2.0 补位 — the owner's recall of the official mode, 2026-10-06); rules keep
|
||||
// ownChess (the composed record carries the chess's identity anyway)
|
||||
const ownShown = (id) => { const c = ownChess(id); return ownStandIn(c, live.current.priv, data.get('backups')) || c; };
|
||||
const placeCtx = useMemo(() => placementContext({
|
||||
priv, stage: gd.stage(pub?.stageId), editable, field: deployField,
|
||||
getChess: (id) => { const c = gd.chess(id); return ownDiyRecord(c, priv, { chess: data.get('chess'), backups: data.get('backups') }) || c; },
|
||||
@@ -729,7 +733,7 @@ function MatchScreen() {
|
||||
const L = live.current;
|
||||
if (intent.confirmReplace) {
|
||||
// both slots used: the player picks the equipped item to destroy (cancel ⇒ nothing is sent)
|
||||
const request = replaceRequest(L.placeCtx, intent, ownChess, gd.item);
|
||||
const request = replaceRequest(L.placeCtx, intent, ownShown, gd.item);
|
||||
if (request) {
|
||||
const uid = await openReplaceRef.current(request);
|
||||
if (!Number.isInteger(uid)) { audio.sfx('back', { volume: 0.5 }); return; }
|
||||
@@ -755,7 +759,7 @@ function MatchScreen() {
|
||||
/** Open the direction wheel for a legal board drop (the piece stays on the tile meanwhile). */
|
||||
const openFacing = (entry, t) => {
|
||||
const piece = entry.piece;
|
||||
const rec = piece.kind === 'item' ? gd.item(piece.id) : piece.kind === 'token' ? gd.token(piece.id) : ownChess(piece.id);
|
||||
const rec = piece.kind === 'item' ? gd.item(piece.id) : piece.kind === 'token' ? gd.token(piece.id) : ownShown(piece.id);
|
||||
holdPiece(view, piece.uid, { row: t.row, col: t.col });
|
||||
setSel(null);
|
||||
setFacing({ uid: piece.uid, piece, row: t.row, col: t.col, grid: previewGrid(lookups, piece), name: rec?.name || '' });
|
||||
@@ -1279,7 +1283,7 @@ function MatchScreen() {
|
||||
${bondPop ? html`<${BondPopup} bondId=${bondPop.bondId} entry=${bondPop.entry} priv=${bondPop.priv} banned=${pub?.bannedChess || []} owner=${bondPop.name}
|
||||
off=${offBonds.has(bondPop.bondId)}
|
||||
place=${bpPlace} over=${!!resolved && bpPlace === dSide}
|
||||
onClose=${() => setBondOpen(null)} onMember=${(id, items) => setDetail({ kind: 'chess', id, owner: bondPop.ownerId, items: items || null })} />` : null}
|
||||
onClose=${() => setBondOpen(null)} onMember=${(id, items, standInFor) => setDetail({ kind: 'chess', id, owner: bondPop.ownerId, items: items || null, standInFor: standInFor || null })} />` : null}
|
||||
|
||||
${resolved ? html`<${DetailPanel} detail=${resolved} snapHp=${snapHp} onClose=${() => { setDetail(null); setSel(null); }}
|
||||
bonds=${detailBonds} offBonds=${offBonds} loadout=${detailLoadout} side=${dSide} shopOpen=${shopOpen} live=${liveStats}
|
||||
@@ -1287,7 +1291,7 @@ function MatchScreen() {
|
||||
|
||||
${selEntry && editable && !facing && !drag && showPrep ? html`<${Underframe} key=${sel.uid} view=${view} uid=${sel.uid}
|
||||
row=${pieceTile(selEntry)?.row} col=${pieceTile(selEntry)?.col} actions=${underframeActions(placeCtx, sel.uid)} busy=${selBusy}
|
||||
name=${(selEntry.piece.kind === 'item' ? gd.item(selEntry.piece.id) : selEntry.piece.kind === 'token' ? gd.token(selEntry.piece.id) : ownChess(selEntry.piece.id))?.name || ''}
|
||||
name=${(selEntry.piece.kind === 'item' ? gd.item(selEntry.piece.id) : selEntry.piece.kind === 'token' ? gd.token(selEntry.piece.id) : ownShown(selEntry.piece.id))?.name || ''}
|
||||
onRetreat=${retreatSel} onSell=${sellSel} onDestroy=${sellSel} />` : null}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,20 +1,21 @@
|
||||
// public/js/screens/game/standInTags.js — 0.2.0 补位: the 「替补:X」 tag under the player's own prep pieces (hand,
|
||||
// 临时整备区, board) of a chess they do not own (m.private.standIns). The hand keeps the chess's own model (the card shows
|
||||
// the original operator); on the board the model is the stand-in's (render/app.js pieceInfo) — the tag says which
|
||||
// chess it is either way. 0.2.0 自选编队: the 「自选」 tag under the player's own pieces of a DIY slot it filled
|
||||
// public/js/screens/game/standInTags.js — 0.2.0 补位: the small 「替补」 tag under the player's own prep pieces (hand,
|
||||
// 临时整备区, board) of a chess they do not own (m.private.standIns): the model is the stand-in's everywhere
|
||||
// (render/app.js pieceInfo — the owner's recall of the official mode, 2026-10-06), the tag says it fields for another
|
||||
// chess (the detail card names it). 0.2.0 自选编队: the 「自选」 tag under the player's own pieces of a DIY slot it filled
|
||||
// (m.private.diy) — the model is the operator's everywhere. Anchored to each piece's drawn body (view.pieceScreenRect,
|
||||
// followed every frame like the direction wheel follows its tile); never takes the pointer, so the pieces stay
|
||||
// draggable.
|
||||
|
||||
import { useEffect, useRef, useState } from '../../../vendor/hooks.module.js';
|
||||
import { html } from '../../ui/components.js';
|
||||
import { fieldsStandIn, standInOf, standInLabel, ownDiyRecord } from '../../ui/gameLogic.js';
|
||||
import { ownStandIn, standInLabel, standInTip, ownDiyRecord } from '../../ui/gameLogic.js';
|
||||
import { t } from '../../../../shared/i18n.js';
|
||||
|
||||
const rawOf = (view) => (view && view.raw) || view || null;
|
||||
|
||||
/**
|
||||
* The player's own prep pieces that fight as stand-ins: [{ uid, label, name, area }] (hand, temp, board).
|
||||
* The player's own prep pieces that fight as stand-ins: [{ uid, label, name, area, tip }] (hand, temp, board; `name` =
|
||||
* the stand-in's, `tip` = which chess it fields for).
|
||||
* @param {any} priv m.private @param {(id: string) => any} getChess @param {any} backups data/backups.json
|
||||
*/
|
||||
export function standInPieces(priv, getChess, backups) {
|
||||
@@ -23,9 +24,8 @@ export function standInPieces(priv, getChess, backups) {
|
||||
const add = (p, area) => {
|
||||
if (!p || p.kind !== 'chess' || !Number.isInteger(p.uid)) return;
|
||||
const c = getChess(p.id);
|
||||
if (!fieldsStandIn(priv, c)) return;
|
||||
const si = standInOf(c, backups);
|
||||
if (si) out.push({ uid: p.uid, label: standInLabel(si), name: si.name, area });
|
||||
const si = ownStandIn(c, priv, backups);
|
||||
if (si) out.push({ uid: p.uid, label: standInLabel(si), name: si.name, area, tip: standInTip(si, c.name) });
|
||||
};
|
||||
for (const p of Array.isArray(priv.hand) ? priv.hand : []) add(p, 'hand');
|
||||
for (const p of Array.isArray(priv.temp) ? priv.temp : []) add(p, 'temp');
|
||||
@@ -85,8 +85,8 @@ export function StandInTags({ view, priv, getChess, backups, diyData = null }) {
|
||||
return html`<div class="sitags" aria-hidden="true" data-testid="standin-tags">
|
||||
${items.map((it) => {
|
||||
const p = pos[it.uid];
|
||||
return p ? html`<span key=${it.uid} class=${`sitag sitag--${it.area}${it.diy ? ' sitag--diy' : ''}`} data-uid=${it.uid} data-diy=${it.diy ? '1' : undefined}
|
||||
title=${it.diy ? t('自选编队:{name}', { name: it.name }) : `未持有:由 ${it.name} 上场`}
|
||||
return p ? html`<span key=${it.uid} class=${`sitag sitag--${it.area}${it.diy ? ' sitag--diy' : ''}`} data-uid=${it.uid} data-diy=${it.diy ? '1' : undefined} data-standin=${it.diy ? undefined : '1'}
|
||||
title=${it.diy ? t('自选编队:{name}', { name: it.name }) : it.tip}
|
||||
style=${`left:${p.x.toFixed(1)}px;top:${p.y.toFixed(1)}px`}>${it.label}</span>` : null;
|
||||
})}
|
||||
</div>`;
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
// 干员持有 (operator ownership, 0.2.0 补位 — the approved plan, owner's decision 2026-10-05): the second tab of the
|
||||
// 干员调配 overlay (screens/loadout.js). The 53 NORMAL chess of this season's shop, grouped by tier — each card shows the
|
||||
// operator and its official stand-in (avatar, name, the skill it fields) and switches between 持有 and 未持有 with a
|
||||
// tap. A chess marked 未持有 keeps its shop / hand card (name, bonds, price, with a 「替补:X」 badge) and is deployed
|
||||
// as the stand-in (model, skills, talents, range). PRESET chess always field their own operator and are not listed.
|
||||
// tap. A chess marked 未持有 is shown as its stand-in in the match — shop and reward cards, hand, board, detail card,
|
||||
// result (portrait, name, model, skills, talents, range; a small 「替补」 mark — the owner's recall of the official mode,
|
||||
// 2026-10-06) — while its bonds, 特质, tier, price and merge stay the chess's. PRESET chess always field their own
|
||||
// operator and are not listed.
|
||||
// Default: everything owned; 全部持有 resets. Out of match: the next match takes the list (in co-op it only affects the
|
||||
// player's own pieces). The list lives in ui/loadoutSync.js (localStorage + room.ownership); the model is
|
||||
// ui/ownershipModel.js. Styles: css/screens/loadout.css (own-*).
|
||||
@@ -14,6 +16,7 @@ import { chessAvatarUrl } from '../ui/assetUrls.js';
|
||||
import { data } from '../data.js';
|
||||
import { PROF_NAME } from '../ui/loadoutModel.js';
|
||||
import { ownershipRoster, rosterByTier, isOwned, standInSummary } from '../ui/ownershipModel.js';
|
||||
import { t } from '../../../shared/i18n.js';
|
||||
|
||||
const cx = (...p) => p.flat().filter(Boolean).join(' ');
|
||||
const ROMAN = ['', 'I', 'II', 'III', 'IV', 'V', 'VI'];
|
||||
@@ -62,7 +65,7 @@ export function OwnershipPanel({ m, roster, notOwned, onToggle }) {
|
||||
const groups = rosterByTier(list);
|
||||
return html`<main class="own" data-testid="ownership">
|
||||
<div class="own__bar">
|
||||
<p class="own__lead">未持有的干员由官方指定的<b>替补干员</b>上场:棋子的名字、盟约、特质、阶级与价格不变,上场的是替补干员(模型、技能、天赋、攻击范围)。预设干员无论是否持有都由本人上场,不在此列。</p>
|
||||
<p class="own__lead">${t('未持有的干员由官方指定的替补干员上场:商店、整备区、作战区与结算都显示替补干员(模型、技能、天赋、攻击范围),盟约、特质、阶级、价格与合成仍按原干员。预设干员无论是否持有都由本人上场,不在此列。')}</p>
|
||||
<button type="button" class=${cx('lo-toggle', offOnly && 'is-on')} aria-pressed=${offOnly ? 'true' : 'false'} onClick=${() => setOffOnly(!offOnly)}>
|
||||
<i class="lo-toggle__box"><${Icon} name="check" /></i>仅看未持有</button>
|
||||
</div>
|
||||
|
||||
@@ -8,7 +8,9 @@
|
||||
// title: 'comment_1' | { id, name? } | null,
|
||||
// lineup: [{ id /* chessId, golden id if elite */, golden?, tier?, items?: [itemId],
|
||||
// diy? /* 0.2.0 自选编队: a DIY slot's pick { charId, skillIndex, uniEquipId } — the card
|
||||
// draws the operator (gameLogic diyRecordFor) */ }],
|
||||
// draws the operator (gameLogic diyRecordFor) */,
|
||||
// standInFor? /* 0.2.0 补位: the replaced operator's charId — the card draws the stand-in
|
||||
// with a small 「替补」 mark (gameLogic standInOf; the owner's recall, 2026-10-06) */ }],
|
||||
// bonds?: [{ bondId, layers, active }],
|
||||
// stats: { dmgDealt, kills, leaks, gold /* funds SPENT */, refreshes, merges, bossDamage?, itemsEquipped?,
|
||||
// activatedLayers?, lpLost?, perfectRounds? } }] }
|
||||
@@ -16,7 +18,7 @@
|
||||
import { useEffect } from '../../vendor/hooks.module.js';
|
||||
import { html, Button, Icon, MicroLabel, DifficultyTag } from '../ui/components.js';
|
||||
import { useGameData, Img, UnitThumb, BandIcon, PlayerAvatar, BondGlyph, LpTower, Sprite } from '../ui/gameComponents.js';
|
||||
import { normalizeResult, fmtNum, diyRecordFor } from '../ui/gameLogic.js';
|
||||
import { normalizeResult, fmtNum, diyRecordFor, cardStandIn, standInForText } from '../ui/gameLogic.js';
|
||||
import { data } from '../data.js';
|
||||
import { enemyIconUrl, titleIconUrl, uiUrl } from '../ui/assetUrls.js';
|
||||
import { store, useStore, emptyMatch } from '../store.js';
|
||||
@@ -32,6 +34,18 @@ const STAT_ROWS = [
|
||||
['refreshes', '刷新次数'], ['leaks', '未击倒'], ['lpLost', '损失生命'],
|
||||
];
|
||||
|
||||
/**
|
||||
* One unit of a final lineup: a 自选 piece draws its operator; a 补位 piece (`standInFor`) its stand-in, with the small
|
||||
* 「替补」 mark — the title names the chess it fielded for.
|
||||
*/
|
||||
export function LineupThumb({ u, gd }) {
|
||||
const chess = u.kind === 'token' ? null : gd.chess(u.id);
|
||||
const dr = u.diy && chess ? diyRecordFor(chess, u.diy, { chess: data.get('chess'), backups: data.get('backups') }) : null;
|
||||
const si = !dr && chess ? cardStandIn(chess, { unit: u, backups: gd.backups }) : null;
|
||||
return html`<${UnitThumb} kind=${u.kind === 'token' ? 'token' : 'chess'} id=${u.id} golden=${!!u.golden} tier=${u.tier} size="sm" rec=${dr || si}
|
||||
title=${si ? `${si.name}(${standInForText(chess.name)})` : undefined} />`;
|
||||
}
|
||||
|
||||
function PlayerCard({ p, myId, titles, best, solo = false }) {
|
||||
const gd = useGameData();
|
||||
const titleRec = p.title ? titles.find((t) => t.id === p.title.id) || null : null;
|
||||
@@ -49,8 +63,7 @@ function PlayerCard({ p, myId, titles, best, solo = false }) {
|
||||
</div>
|
||||
<div class="rcard__mid">
|
||||
<div class="rcard__lineup">
|
||||
${lineup.length ? lineup.map((u, i) => html`<${UnitThumb} key=${i} kind=${u.kind === 'token' ? 'token' : 'chess'} id=${u.id} golden=${!!u.golden} tier=${u.tier} size="sm"
|
||||
rec=${u.diy ? diyRecordFor(gd.chess(u.id), u.diy, { chess: data.get('chess'), backups: data.get('backups') }) : null} />`)
|
||||
${lineup.length ? lineup.map((u, i) => html`<${LineupThumb} key=${i} u=${u} gd=${gd} />`)
|
||||
: html`<span class="rcard__noinfo">${p.alive === false ? '阵容已撤离' : 'NO INFO'}</span>`}
|
||||
</div>
|
||||
${bonds.length ? html`<div class="rcard__bonds">${bonds.map((b) => html`<span key=${b.bondId} class=${cx('rbond', b.active && 'is-on')} title=${gd.bond(b.bondId)?.name || b.bondId}>
|
||||
|
||||
+29
-13
@@ -18,10 +18,13 @@
|
||||
// Watching a teammate (DESIGN §20.15, ui/watchBonds.js) the strip and the popup show THAT player's bonds and layers:
|
||||
// the strip carries an amber "👁 name" tag (`owner`, the observing pill's spelling, research 09 §3.1) and amber rings,
|
||||
// the popup a "👁 name 的盟约" line; its member list reads the teammate's operators on the field (no hand: never sent).
|
||||
// 0.2.0 补位: a member the player fields as its stand-in (the own m.private.standIns; a teammate's unit that says so —
|
||||
// UnitInfo `standInFor`) is drawn as the stand-in — avatar, name, a small 「替补」 mark on the thumbnail — and its row
|
||||
// opens the stand-in's card (gameLogic memberStandIn; the owner's recall of the official mode, 2026-10-06).
|
||||
|
||||
import { html, BondDisc, Icon, MicroLabel, Tooltip } from './components.js';
|
||||
import { RichText, UnitThumb, BondGlyph, GIcon } from './gameComponents.js';
|
||||
import { sortBonds, bondMembers, nextThreshold, bondTier, harmonyMembers, HARMONY_BOND, memberHeadCount, briefingBondTip, diyGetter, diyRecordFor } from './gameLogic.js';
|
||||
import { sortBonds, bondMembers, nextThreshold, bondTier, harmonyMembers, HARMONY_BOND, memberHeadCount, briefingBondTip, diyGetter, diyRecordFor, memberStandIn, standInForText } from './gameLogic.js';
|
||||
import { formatBondEffect } from './richText.js';
|
||||
import { bondIconUrl } from './assetUrls.js';
|
||||
import { data } from '../data.js';
|
||||
@@ -83,11 +86,12 @@ export function BondStrip({ bonds, layersDisabled = false, onOpen, openId = null
|
||||
/**
|
||||
* Bond detail popup.
|
||||
* @param {{ bondId: string, entry?: any, priv?: any, banned?: string[], onClose: Function,
|
||||
* onMember?: (chessId:string, items?:string[]|null)=>void,
|
||||
* onMember?: (chessId:string, items?:string[]|null, standInFor?:string|null)=>void,
|
||||
* place?: 'left'|'beside'|'besideR'|'right'|null, over?: boolean, beside?: 'left'|'right'|null, owner?: string|null, off?: boolean }} props —
|
||||
* `place`: where it opens (gameLogic bondPopupPlace; `beside: 'left'` = the older spelling of 'beside'); `over`: above the
|
||||
* detail card; `owner`: the watched teammate's name (`entry` / `priv` are then theirs: ui/watchBonds.js); `onMember`
|
||||
* gets a 变形同构体 row's item ids too (its card shows the pair and the granted chip), null for a plain member; `off`: the
|
||||
* gets a 变形同构体 row's item ids too (its card shows the pair and the granted chip), null for a plain member, and a
|
||||
* stand-in row's `standInFor` (0.2.0 补位: a teammate's card then shows the stand-in too); `off`: the
|
||||
* mode never activates this bond (gameLogic modeOffBonds — 标准's 10 inactive bonds): 本局禁用 instead of 未激活, with a
|
||||
* note, no 在场 count and no 当前效果 block (its numbers would promise an effect the mode never gives; the bond text stays).
|
||||
* `entry.harmony` (the server's bond views: the +1 调和 added to `count`): 在场 n(含调和 +1) and the 调和 row
|
||||
@@ -105,13 +109,19 @@ export function BondPopup({ bondId, entry, priv, banned = [], onClose, onMember,
|
||||
const getChess = diyGetter((id) => data.lookup('chess', id), priv, dd);
|
||||
const pieceRecord = (p) => (p && p.diy && typeof p.diy === 'object' ? diyRecordFor(data.lookup('chess', p.id), p.diy, dd) : null) || getChess(p.id);
|
||||
const members = bondMembers(b, priv, banned, getChess, (id) => data.lookup('items', id), pieceRecord);
|
||||
// 补位: the stand-in a row shows (a 自选 row is its operator already)
|
||||
const rawChess = (id) => data.lookup('chess', id);
|
||||
const siOf = (mb) => (mb.diy ? null : memberStandIn(priv, rawChess(mb.id), data.get('backups'), rawChess));
|
||||
const countsHand = entry?.countsHand ?? b.countsHand;
|
||||
const next = nextThreshold(count, th);
|
||||
const hasNow = !off && !!(b.effectDescRaw || b.effectDesc);
|
||||
const at = place || (beside === 'left' ? 'beside' : 'left');
|
||||
// 调和's +1 in this count (the server's word), and the 调和 operators on that board who give it
|
||||
const harmony = !off && Number.isInteger(entry?.harmony) && entry.harmony > 0 ? entry.harmony : 0;
|
||||
const harmonyBy = harmony ? harmonyMembers(priv, getChess) : [];
|
||||
const harmonyBy = harmony ? harmonyMembers(priv, getChess).map((x) => {
|
||||
const si = memberStandIn(priv, rawChess(x.id), data.get('backups'), rawChess);
|
||||
return si ? { ...x, name: si.name } : x;
|
||||
}) : [];
|
||||
const harmonyName = data.lookup('bonds', HARMONY_BOND)?.name || '调和';
|
||||
const harmonyText = `${harmonyBy.length ? `${harmonyBy.map((x) => x.name).join('、')} 在场` : `${harmonyName}已激活`}:核心盟约激活人数 +${harmony}`;
|
||||
return html`<div class=${cx('bpop', 'brackets', `bpop--${at}`, over && 'is-over', owner && 'is-other')} data-place=${at} data-owner=${owner || null}
|
||||
@@ -150,15 +160,21 @@ export function BondPopup({ bondId, entry, priv, banned = [], onClose, onMember,
|
||||
: html`<div class="bpop__harmony" role="note" data-harmony=${harmony} title=${harmonyText}>
|
||||
<${BondGlyph} bondId=${HARMONY_BOND} /><b>${harmonyName} +${harmony}</b><span>${harmonyText}</span></div>`) : null}
|
||||
<div class="bpop__members">
|
||||
${members.map((mb) => html`<button key=${mb.id} type="button" class=${cx('bpop__member', mb.onBoard && 'is-on', mb.owned && !mb.onBoard && 'is-owned', mb.banned && 'is-banned', mb.granted && 'is-granted')}
|
||||
onClick=${() => onMember?.(mb.id, mb.granted && Array.isArray(mb.items) ? mb.items : null)} data-granted=${mb.granted ? '1' : null}
|
||||
title=${`${mb.name}${mb.granted ? '(变形同构体:视为本盟约成员)' : ''}${mb.banned ? '(本局禁用)' : mb.onBoard ? '(在场)' : mb.owned ? '(整备区)' : ''}`}>
|
||||
<${UnitThumb} kind="chess" id=${mb.id} size="sm" dim=${!mb.owned || mb.banned} rec=${mb.diy ? mb.rec : null} />
|
||||
<span class="bpop__mname">${mb.name}</span>
|
||||
${mb.banned ? html`<span class="bpop__ban"><${Icon} name="close" /></span>` : null}
|
||||
${mb.granted ? html`<span class="bpop__iso" aria-hidden="true">同构</span>` : null}
|
||||
${mb.diy ? html`<span class="bpop__iso bpop__diy" data-diy="1" aria-hidden="true">${t('自选')}</span>` : null}
|
||||
</button>`)}
|
||||
${members.map((mb) => {
|
||||
const si = siOf(mb);
|
||||
const name = si ? si.name : mb.name;
|
||||
const items = mb.granted && Array.isArray(mb.items) ? mb.items : null;
|
||||
return html`<button key=${mb.id} type="button" class=${cx('bpop__member', mb.onBoard && 'is-on', mb.owned && !mb.onBoard && 'is-owned', mb.banned && 'is-banned', mb.granted && 'is-granted', si && 'is-standin')}
|
||||
onClick=${() => (si ? onMember?.(mb.id, items, si.standInFor) : onMember?.(mb.id, items))}
|
||||
data-granted=${mb.granted ? '1' : null} data-standin=${si ? si.charId : null}
|
||||
title=${`${name}${si ? `(${standInForText(mb.name)})` : ''}${mb.granted ? '(变形同构体:视为本盟约成员)' : ''}${mb.banned ? '(本局禁用)' : mb.onBoard ? '(在场)' : mb.owned ? '(整备区)' : ''}`}>
|
||||
<${UnitThumb} kind="chess" id=${mb.id} size="sm" dim=${!mb.owned || mb.banned} rec=${si || (mb.diy ? mb.rec : null)} title=${name} />
|
||||
<span class="bpop__mname">${name}</span>
|
||||
${mb.banned ? html`<span class="bpop__ban"><${Icon} name="close" /></span>` : null}
|
||||
${mb.granted ? html`<span class="bpop__iso" aria-hidden="true">同构</span>` : null}
|
||||
${mb.diy ? html`<span class="bpop__iso bpop__diy" data-diy="1" aria-hidden="true">${t('自选')}</span>` : null}
|
||||
</button>`;
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
</div>`;
|
||||
|
||||
+21
-16
@@ -33,7 +33,7 @@
|
||||
|
||||
import { html, Icon, TierChip, MicroLabel, Button, confirmDialog, useTicker } from './components.js';
|
||||
import { Img, RichText, UnitThumb, BondGlyph, GIcon, diyToken } from './gameComponents.js';
|
||||
import { attackInterval, rangeGridBox, fmtNum, tileKey, chessLoadout, nextThreshold, bondTier, briefingBondTip, pieceBondIds, grantedBonds, morphPairings, fieldsStandIn, standInOf, standInLoadout, standInLabel, ownDiyRecord, ownDiyPick, diyRecordFor, pickGetter } from './gameLogic.js';
|
||||
import { attackInterval, rangeGridBox, fmtNum, tileKey, chessLoadout, nextThreshold, bondTier, briefingBondTip, pieceBondIds, grantedBonds, morphPairings, ownStandIn, standInOf, standInLoadout, standInLabel, standInTip, standInForText, ownDiyRecord, ownDiyPick, diyRecordFor, pickGetter } from './gameLogic.js';
|
||||
import { chessPortraitUrl, skillIconUrl, skillRecordIconUrl, profIconUrl, subProfIconUrl, itemIconUrl, enemyIconUrl, tokenAvatarUrl, factionIconUrl, uiUrl, moduleTypeIconUrl } from './assetUrls.js';
|
||||
import { abilityRows } from './abilityLines.js';
|
||||
import { data } from '../data.js';
|
||||
@@ -361,8 +361,10 @@ export function ChessDetail({ chess, piece, unit, snapHp, editable, onSell, bond
|
||||
// 0.2.0 自选编队: `chess` is then the composed 自选 record (the operator, the slot's tier / price); its skill and module are
|
||||
// the pick's — the lookups resolve the slot's ids to the same pick (shared/diy.js), so the loadout reads them as defaults
|
||||
const getChess = diy ? pickGetter((id) => data.lookup('chess', id), diy, { chess: data.get('chess'), backups: data.get('backups') }) : (id) => data.lookup('chess', id);
|
||||
// 0.2.0 补位: a chess fielded as its stand-in keeps its identity (name, tier, bonds, 特质, sell price) and shows the
|
||||
// stand-in's body — portrait, class, 特性, stats, range, skill, talents, module (its backup selection, no loadout)
|
||||
// 0.2.0 补位: a chess fielded as its stand-in shows the stand-in — portrait, name, class, 特性, stats, range, skill,
|
||||
// talents, module (its backup selection, no loadout) — under a small 「替补」 tag, with the replaced operator's name
|
||||
// where the English name usually is (「银灰的替补」, [ASSUMED] placement); the chess's tier, bonds, 特质 and sell price
|
||||
// still apply (the owner's recall of the official mode, 2026-10-06)
|
||||
const si = standIn && standIn.standInFor ? standIn : null;
|
||||
const body = si || chess;
|
||||
const lo = si ? standInLoadout(si, getChess, data.get('backups')) : chessLoadout(chess, loadout, getChess);
|
||||
@@ -387,18 +389,18 @@ export function ChessDetail({ chess, piece, unit, snapHp, editable, onSell, bond
|
||||
blocks.head = html`
|
||||
<div key="head" class="dhead">
|
||||
<div class=${cx('dhead__art', golden && 'is-golden', `dhead__art--t${c.tier}`)}>
|
||||
<${Img} src=${chessPortraitUrl(m, body)} fallback=${html`<${UnitThumb} kind="chess" id=${c.chessId} size="lg" />`} />
|
||||
<${Img} src=${chessPortraitUrl(m, body)} fallback=${html`<${UnitThumb} kind="chess" id=${c.chessId} size="lg" rec=${si} />`} />
|
||||
</div>
|
||||
<div class="dhead__info">
|
||||
<div class="dhead__chips">
|
||||
<${TierChip} tier=${c.tier} golden=${golden} size="lg" />
|
||||
${golden ? html`<span class="dtag-elite">精锐</span>` : null}
|
||||
${piece?.kind === 'token' ? html`<span class="dtag-token">召唤物</span>` : null}
|
||||
${si ? html`<span class="dtag-standin" data-standin=${si.charId} title=${`未持有${c.name}:由替补干员 ${si.name} 上场(盟约、特质、阶级与价格不变)`}>${standInLabel(si)}</span>` : null}
|
||||
${si ? html`<span class="dtag-standin" data-standin=${si.charId} title=${standInTip(si, c.name)}>${standInLabel(si)}</span>` : null}
|
||||
${diy ? html`<span class="dtag-diy" data-diy=${c.charId || ''} title=${t('自选编队:所选技能与模组,没有特质,盟约按所属阵营分配')}>${t('自选')}</span>` : null}
|
||||
</div>
|
||||
<h3 class="dhead__name">${c.name}</h3>
|
||||
<span class="dhead__en">${si ? `${si.name}${si.appellation && si.appellation !== si.name ? ` · ${si.appellation}` : ''}` : c.appellation || ''}</span>
|
||||
<h3 class="dhead__name">${si ? si.name : c.name}</h3>
|
||||
${si ? html`<span class="dhead__for" data-for=${c.chessId}>${standInForText(c.name)}</span>` : html`<span class="dhead__en">${c.appellation || ''}</span>`}
|
||||
<div class="dhead__class">
|
||||
<${Img} src=${profIconUrl(m, body.profession)} class="dhead__prof" />
|
||||
<span>${PROF_NAME[body.profession] || body.profession || ''}</span>
|
||||
@@ -453,7 +455,7 @@ export function ChessDetail({ chess, piece, unit, snapHp, editable, onSell, bond
|
||||
${talents.map((t, i) => html`<div key=${i} class="dtalent"><b>${t.name}</b><${RichText} text=${t.descRaw || t.desc} class="dtext" /></div>`)}
|
||||
<//>` : null;
|
||||
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><//>
|
||||
<${Button} variant="amber" icon="close" class="dpanel__sell" onClick=${() => onSell(piece, si || c)}>出售<span class="dsell num">+${sell}</span><//>
|
||||
</div>` : null;
|
||||
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
|
||||
@@ -616,13 +618,14 @@ export function TokenDetail({ token, piece, ownerId = null, snapHp = null, live
|
||||
* @param {{ kind:'piece'|'chess'|'item'|'enemy'|'unit'|'token', id?:string, uid?:number, unit?:any, count?:number }} target
|
||||
* @param {Map<number, any>} pieces indexPieces(priv)
|
||||
* @param {{ priv?: any, backups?: any }} [opts] 0.2.0 补位: the player's own pieces and cards of a chess in
|
||||
* m.private.standIns — and a unit carrying `standInFor` — resolve with `standIn` (the composed stand-in record the
|
||||
* card shows as the body); 0.2.0 自选编队: the player's own pieces and cards of a DIY slot it filled (m.private.diy) —
|
||||
* and a unit carrying `diy` — resolve to the composed 自选 record (`chess`, the operator) with `diy` = the pick
|
||||
* m.private.standIns — a unit carrying `standInFor`, and a teammate's bond popup row that says it (`target.standInFor`)
|
||||
* — resolve with `standIn` (the composed stand-in record the card shows: portrait, name, body, with the chess's
|
||||
* bonds and 特质); 0.2.0 自选编队: the player's own pieces and cards of a DIY slot it filled (m.private.diy) — and a
|
||||
* unit carrying `diy` — resolve to the composed 自选 record (`chess`, the operator) with `diy` = the pick
|
||||
*/
|
||||
export function resolveDetail(target, pieces, { priv = null, backups = data.get('backups') } = {}) {
|
||||
if (!target) return null;
|
||||
const ownStandIn = (c) => (fieldsStandIn(priv, c) ? standInOf(c, backups) : null);
|
||||
const ownSi = (c) => ownStandIn(c, priv, backups);
|
||||
const dd = { chess: data.get('chess'), backups };
|
||||
/** the own card of chess `c`: its 自选 record and pick when the player filled that DIY slot */
|
||||
const ownDiy = (c) => {
|
||||
@@ -638,18 +641,20 @@ export function resolveDetail(target, pieces, { priv = null, backups = data.get(
|
||||
const c = data.lookup('chess', p.id);
|
||||
const d = ownDiy(c);
|
||||
if (d) return { type: 'chess', chess: d.chess, piece: p, standIn: null, diy: d.diy };
|
||||
return c ? { type: 'chess', chess: c, piece: p, standIn: ownStandIn(c) } : null;
|
||||
return c ? { type: 'chess', chess: c, piece: p, standIn: ownSi(c) } : null;
|
||||
}
|
||||
if (target.kind === 'chess') {
|
||||
// a bond popup's 变形同构体 row hands the wearer's item ids on (bondStrip onMember): the card shows the pair and the chip
|
||||
const c = data.lookup('chess', target.id);
|
||||
const items = Array.isArray(target.items) ? target.items.filter((x) => typeof x === 'string') : [];
|
||||
// (a bond popup's member card of a teammate's strip — `owner` another player — and the mode's banned list (`foreign`)
|
||||
// show the chess as it is: the viewer's 补位 list is not theirs)
|
||||
// do not read the viewer's 补位 list: a teammate's row shows the stand-in only when its unit says it is one —
|
||||
// `target.standInFor`, from UnitInfo through ui/watchBonds.js ownerBoard)
|
||||
const foreign = !!target.foreign || (target.owner != null && !!priv && target.owner !== priv.playerId);
|
||||
const d = foreign ? null : ownDiy(c);
|
||||
if (d) return { type: 'chess', chess: d.chess, hint: target.hint || null, standIn: null, diy: d.diy, ...(items.length ? { unitItems: items } : {}) };
|
||||
return c ? { type: 'chess', chess: c, hint: target.hint || null, standIn: foreign ? null : ownStandIn(c), ...(items.length ? { unitItems: items } : {}) } : null;
|
||||
const si = !c ? null : foreign ? (typeof target.standInFor === 'string' && target.standInFor ? standInOf(c, backups) : null) : ownSi(c);
|
||||
return c ? { type: 'chess', chess: c, hint: target.hint || null, standIn: si, ...(items.length ? { unitItems: items } : {}) } : 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; }
|
||||
@@ -665,7 +670,7 @@ export function resolveDetail(target, pieces, { priv = null, backups = data.get(
|
||||
// follows m.private.standIns like the piece
|
||||
let si = null;
|
||||
if (c && typeof u.standInFor === 'string' && u.standInFor) si = standInOf(c, backups);
|
||||
else if (c && own?.piece) si = ownStandIn(c);
|
||||
else if (c && own?.piece) si = ownSi(c);
|
||||
// 0.2.0 自选编队: a unit says itself which operator fills its DIY slot (UnitInfo diy); an own piece's unit follows
|
||||
// m.private.diy like the piece
|
||||
const pick = c && u.diy && typeof u.diy === 'object' ? u.diy : own?.piece ? ownDiyPick(priv, c) : null;
|
||||
|
||||
@@ -7,8 +7,9 @@
|
||||
// _arrow_head, _avatart_bg / _frame, _round, _close, _comfirm_icon, _replace_icon), CSS look-alikes without them
|
||||
// (css/screens/game-panels.css .eqr).
|
||||
//
|
||||
// replaceRequest(ctx, intent, getChess, getItem) → { targetUid, targetId, targetName, item: {uid,id,name}, options }
|
||||
// | null (pure; the data the dialog shows — null = no dialog)
|
||||
// replaceRequest(ctx, intent, getChess, getItem) → { targetUid, targetId, targetName, targetRec, item: {uid,id,name},
|
||||
// options } | null (pure; the data the dialog shows — null = no dialog;
|
||||
// `getChess` = what the own pieces show: a 补位 piece's stand-in record)
|
||||
// replaceIntent(intent, replaceUid) → { t: 'g.equip', fields: { itemUid, targetUid, replaceUid } }
|
||||
// <EquipReplaceDialog request onConfirm(replaceUid) onCancel />
|
||||
|
||||
@@ -35,10 +36,13 @@ export function replaceRequest(ctx, intent, getChess = () => null, getItem = ()
|
||||
const equipped = (Array.isArray(target.items) ? target.items : []).filter((it) => isObj(it) && Number.isInteger(it.uid));
|
||||
if (equipped.length < 2) return null;
|
||||
const rec = (id) => getItem(id) || null;
|
||||
const shown = getChess(target.id) || null;
|
||||
return {
|
||||
targetUid: target.uid,
|
||||
targetId: target.id,
|
||||
targetName: getChess(target.id)?.name || '该干员',
|
||||
targetName: shown?.name || '该干员',
|
||||
// the record the avatar draws (a 补位 stand-in's or a 自选 operator's composed record; the data's for any other chess)
|
||||
targetRec: shown && (shown.standInFor || shown.diyFor) ? shown : null,
|
||||
golden: !!target.golden,
|
||||
item: { uid: item.uid, id: item.id, name: rec(item.id)?.name || '装备' },
|
||||
options: equipped.map((it) => ({ uid: it.uid, id: it.id, name: rec(it.id)?.name || it.id })),
|
||||
@@ -115,7 +119,7 @@ export function EquipReplaceDialog({ request, getItem = () => null, busy = false
|
||||
<p class="eqr__lead">「<b>${request.targetName}</b>」的装备栏已满(2/2)。选择一件装备进行替换,<b class="t-red">被替换的装备将被销毁</b>。</p>
|
||||
<div class="eqr__top">
|
||||
<div class="eqr__op" title=${request.targetName}>
|
||||
<span class="eqr__ava"><${UnitThumb} kind="chess" id=${request.targetId} golden=${request.golden} size="md" /></span>
|
||||
<span class="eqr__ava"><${UnitThumb} kind="chess" id=${request.targetId} golden=${request.golden} size="md" rec=${request.targetRec || null} /></span>
|
||||
<span class="eqr__opname">${request.targetName}</span>
|
||||
</div>
|
||||
<span class="eqr__swap" aria-hidden="true">${swapIcon ? html`<img src=${swapIcon} alt="" draggable=${false} />` : html`<${GIcon} name="refresh" />`}</span>
|
||||
|
||||
@@ -21,7 +21,7 @@ import { render } from '../../vendor/preact.module.js';
|
||||
import { html, TierChip } from './components.js';
|
||||
import { GEO } from '../../../shared/constants.js';
|
||||
import { chessAvatarUrl, itemIconUrl, tokenAvatarUrl, enemyIconUrl } from './assetUrls.js';
|
||||
import { tileKey, hasFlag, UF, penPlacement, PEN, fieldTile, fieldsStandIn, standInOf, ownDiyRecord } from './gameLogic.js';
|
||||
import { tileKey, hasFlag, UF, penPlacement, PEN, fieldTile, ownStandIn, ownDiyRecord } from './gameLogic.js';
|
||||
|
||||
const DRAG_PX = 6;
|
||||
const DMG_TTL = 900;
|
||||
@@ -119,14 +119,16 @@ export function createFallbackView(host, opts = {}) {
|
||||
const tempPos = (L, idx) => ({ x: (GEO.TEMP_C0 + idx) * L.tile, y: (L.rows + 1.72) * L.tile });
|
||||
|
||||
// ---- pieces (prep) ---------------------------------------------------------------------------------------
|
||||
function pieceArt(p, area = null) {
|
||||
function pieceArt(p) {
|
||||
const mm = m();
|
||||
if (p.kind === 'item') return itemIconUrl(mm, lookup('items', p.id));
|
||||
if (p.kind === 'token') return tokenAvatarUrl(mm, p.id);
|
||||
const chess = lookup('chess', p.id);
|
||||
// 0.2.0 补位: on the board a chess the player does not own is its stand-in (render/app.js pieceInfo)
|
||||
const si = area === 'board' && fieldsStandIn(st.priv, chess) ? standInOf(chess, dataStore?.get?.('backups') ?? null) : null;
|
||||
return chessAvatarUrl(mm, si || ownDiy(chess) || chess);
|
||||
return chessAvatarUrl(mm, ownSi(chess) || ownDiy(chess) || chess);
|
||||
}
|
||||
/** 0.2.0 补位: the player's own piece of a chess it does not own is its stand-in, bench and board alike (render/app.js pieceInfo) */
|
||||
function ownSi(chess) {
|
||||
return chess ? ownStandIn(chess, st.priv, dataStore?.get?.('backups') ?? null) : null;
|
||||
}
|
||||
/** 0.2.0 自选编队: the player's own piece of a DIY slot it filled is its operator (gameLogic ownDiyRecord) */
|
||||
function ownDiy(chess) {
|
||||
@@ -136,11 +138,11 @@ export function createFallbackView(host, opts = {}) {
|
||||
if (p.kind === 'item') return lookup('items', p.id)?.name || '道具';
|
||||
if (p.kind === 'token') return lookup('tokens', p.id)?.name || '召唤物';
|
||||
const chess = lookup('chess', p.id);
|
||||
return (ownDiy(chess) || chess)?.name || '干员';
|
||||
return (ownSi(chess) || ownDiy(chess) || chess)?.name || '干员';
|
||||
}
|
||||
|
||||
function Piece({ p, x, y, L, area }) {
|
||||
const src = pieceArt(p, area);
|
||||
const src = pieceArt(p);
|
||||
const dragging = st.drag?.uid === p.uid && st.drag.moved;
|
||||
const golden = !!p.golden;
|
||||
const tier = p.tier || lookup('chess', p.id)?.tier || lookup('items', p.id)?.tier;
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useState, useMemo } from '../../vendor/hooks.module.js';
|
||||
import { html, Icon, TierChip, Tooltip } from './components.js';
|
||||
import { data, useData, localAsset } from '../data.js';
|
||||
import { parseRichText, rtClassName } from './richText.js';
|
||||
import { t as tr } from '../../../shared/i18n.js';
|
||||
import {
|
||||
uiUrl, chessAvatarUrl, chessPortraitUrl, itemIconUrl, tokenAvatarUrl, enemyIconUrl, bondIconUrl, bandIconUrl,
|
||||
} from './assetUrls.js';
|
||||
@@ -101,15 +102,18 @@ export const isGoldenPiece = (piece, chess) => !!(piece?.golden || chess?.isGold
|
||||
* Square unit thumbnail for a piece / chess / item / token / enemy: art + tier chip + elite frame.
|
||||
* @param {{ kind?: 'chess'|'item'|'token'|'enemy', id: string, golden?: boolean, size?: 'xs'|'sm'|'md'|'lg', tier?: number,
|
||||
* showTier?: boolean, class?: string, dim?: boolean, badge?: any, title?: string, rec?: any }} props
|
||||
* `rec`: the chess record to draw instead of the data's (a 自选 piece's composed record — 0.2.0, gameLogic/diy.js)
|
||||
* `rec`: the chess record to draw instead of the data's (a 自选 piece's composed record — 0.2.0, gameLogic/diy.js; a
|
||||
* 补位 stand-in's — gameLogic/standIn.js: drawn with a small 「替补」 mark in the corner)
|
||||
*/
|
||||
export function UnitThumb({ kind = 'chess', id, golden, size = 'md', tier, showTier = true, class: cls, dim = false, badge = null, title, rec = null }) {
|
||||
const m = data.get('assets');
|
||||
let src = null;
|
||||
let name = '';
|
||||
let t = tier;
|
||||
let si = null;
|
||||
if (kind === 'chess') {
|
||||
const c = rec || data.lookup('chess', id);
|
||||
si = rec && typeof rec.standInFor === 'string' && rec.standInFor ? rec : null;
|
||||
src = chessAvatarUrl(m, c);
|
||||
name = c?.name || '';
|
||||
t = t ?? c?.tier;
|
||||
@@ -137,6 +141,7 @@ export function UnitThumb({ kind = 'chess', id, golden, size = 'md', tier, showT
|
||||
</span>
|
||||
${showTier && t && kind !== 'enemy' && kind !== 'token' ? html`<${TierChip} tier=${t} golden=${golden} size="sm" class="uthumb__tier" />` : null}
|
||||
${kind === 'token' ? html`<span class="uthumb__tag">召唤</span>` : null}
|
||||
${si ? html`<span class="uthumb__si" data-standin=${si.charId} aria-hidden="true">${tr('替补')}</span>` : null}
|
||||
${badge}
|
||||
</span>`;
|
||||
}
|
||||
|
||||
@@ -41,6 +41,6 @@ export { shortcutFor, closesOnFieldPress, shortcutBlocked } from './gameLogic/sh
|
||||
export { DEFAULT_SETTINGS, sanitizeSettings } from './gameLogic/settings.js';
|
||||
export { normalizeResult } from './gameLogic/result.js';
|
||||
export { chessLoadout, unitLoadout } from './gameLogic/loadout.js';
|
||||
export { standInIds, fieldsStandIn, standInOf, cardStandIn, standInGetter, standInLoadout, deployedRecord, deployedModuleId, standInLabel } from './gameLogic/standIn.js';
|
||||
export { standInIds, fieldsStandIn, standInOf, ownStandIn, cardStandIn, memberStandIn, standInGetter, standInLoadout, deployedRecord, deployedModuleId, standInLabel, standInForText, standInTip } from './gameLogic/standIn.js';
|
||||
export { diyPicks, ownDiyPick, diyRecordFor, ownDiyRecord, cardDiy, diyGetter, pickGetter, isDiyRecord } from './gameLogic/diy.js';
|
||||
export { panelSide, panelSlots, PANEL_RIGHT_GAP, PANEL_RIGHT_BOTTOM, PANEL_RIGHT_BOTTOM_SHOP, BPOP, bondPopupPlace } from './gameLogic/panel.js';
|
||||
@@ -1,18 +1,23 @@
|
||||
// ui/gameLogic/standIn.js — 补位 stand-ins in the match UI (0.2.0, the approved plan — owner's decision 2026-10-05).
|
||||
// Re-exported from ../gameLogic.js.
|
||||
//
|
||||
// A chess the player marked as not owned (干员持有) keeps its card — name, bonds, price, the shop / reward / hand art —
|
||||
// with a 「替补:<stand-in>」 badge, and is deployed as its official stand-in: the board model, the battle unit and the
|
||||
// detail card's body (portrait, class, 特性, stats, range, skill, talents, module) are the stand-in's, its identity
|
||||
// (name, tier, bonds, 特质) the chess's. Whose pieces: the player's own come from m.private.standIns (the list the seat
|
||||
// had when the match started — the server's, not this browser's current setting, which applies from the next match);
|
||||
// another player's units say it themselves (UnitInfo `standInFor`: the sim's, and Match.prepFieldMeta's for scouting).
|
||||
// The composed record is shared/standIn.js standInRecord over data/backups.json — the one the sim fields.
|
||||
// A chess the player marked as not owned (干员持有) is SHOWN as its official stand-in wherever the player's copy of it
|
||||
// appears — the shop and reward cards, the hand (整备区) and 临时整备区 pieces, the board, the detail card, the result
|
||||
// screen, a teammate's prep scouting and bond popups — the owner's first-hand recall of the official mode, 2026-10-06
|
||||
// (it reverses the first 0.2.0 build, where cards and the bench kept the chess's art and name with a 「替补:X」 badge).
|
||||
// Only what is shown changes: the record (shared/standIn.js standInRecord over data/backups.json — the one the sim
|
||||
// fields) keeps the chess's identity — ids, tier, bonds, 特质, price, merge — so every rule still reads the chess; its
|
||||
// body — portrait / avatar, name, class, model, 特性, stats, range, skill, talents, module — is the stand-in's; a small
|
||||
// 「替补」 mark (standInLabel) and the replaced operator's name on the detail card (standInForText) tell which chess it
|
||||
// is. Whose pieces: the player's own come from m.private.standIns (the list the seat had when the match started — the
|
||||
// server's, not this browser's current setting, which applies from the next match); another player's units say it
|
||||
// themselves (UnitInfo `standInFor`: the sim's, Match.prepFieldMeta's for scouting, the m.result lineups).
|
||||
|
||||
import { standInRecord } from '../../../../shared/standIn.js';
|
||||
import { resolveLoadout } from '../../../../shared/protocol.js';
|
||||
import { chessLoadout } from './loadout.js';
|
||||
import { isObj } from './shared.js';
|
||||
import { t } from '../../../../shared/i18n.js';
|
||||
|
||||
/** Base chess ids the player fields as stand-ins in this match (m.private.standIns; [] when absent). */
|
||||
export function standInIds(priv) {
|
||||
@@ -33,10 +38,13 @@ const CACHE = new WeakMap();
|
||||
/**
|
||||
* The composed 补位 record of a chess record (shared/standIn.js standInRecord: the chess's identity, the stand-in's
|
||||
* body; `standInFor` = the replaced charId), cached per data object; null for a PRESET / 自选 chess or missing data.
|
||||
* A composed record is its own stand-in (a lookup that already hands out stand-in records stays idempotent).
|
||||
* @param {any} chess data/chess.json record @param {any} backups data/backups.json
|
||||
*/
|
||||
export function standInOf(chess, backups) {
|
||||
if (!isObj(chess) || !isObj(backups)) return null;
|
||||
if (!isObj(chess)) return null;
|
||||
if (typeof chess.standInFor === 'string' && chess.standInFor) return chess;
|
||||
if (!isObj(backups)) return null;
|
||||
let m = CACHE.get(backups);
|
||||
if (!m) { m = new WeakMap(); CACHE.set(backups, m); }
|
||||
if (m.has(chess)) return m.get(chess);
|
||||
@@ -47,8 +55,18 @@ export function standInOf(chess, backups) {
|
||||
}
|
||||
|
||||
/**
|
||||
* The stand-in record the card of a chess shows for this viewer, or null: the player's own piece / card (m.private
|
||||
* standIns), or a unit that carries `standInFor` (another player's — the sim's UnitInfo, prep scouting).
|
||||
* The stand-in record the player's own card / piece of `chess` shows (the player fields its stand-in: m.private
|
||||
* standIns), or null.
|
||||
* @param {any} chess @param {any} priv m.private @param {any} backups
|
||||
*/
|
||||
export function ownStandIn(chess, priv, backups) {
|
||||
return fieldsStandIn(priv, chess) ? standInOf(chess, backups) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The stand-in record the card of a chess shows for this viewer, or null: a unit / lineup entry that carries
|
||||
* `standInFor` (another player's — the sim's UnitInfo, prep scouting, m.result), else the player's own piece / card
|
||||
* (m.private standIns).
|
||||
* @param {any} chess @param {{ priv?: any, unit?: any, backups?: any }} o
|
||||
*/
|
||||
export function cardStandIn(chess, { priv = null, unit = null, backups = null } = {}) {
|
||||
@@ -57,6 +75,27 @@ export function cardStandIn(chess, { priv = null, unit = null, backups = null }
|
||||
return mine ? standInOf(chess, backups) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The stand-in a bond popup's member row of base chess `chess` shows for the board `priv`, or null: a chess the player
|
||||
* fields as its stand-in (m.private.standIns — any copy, in play or not, as the shop shows it), or one whose piece on a
|
||||
* teammate's board says it is a stand-in (ui/watchBonds.js ownerBoard: the units' `standInFor`).
|
||||
* @param {any} priv m.private, or a teammate's board ({ board, hand, temp } of pieces with `standInFor`)
|
||||
* @param {any} chess the member's chess record @param {any} backups
|
||||
* @param {(id: string) => any} [getChess] the pieces' records (their base ids)
|
||||
*/
|
||||
export function memberStandIn(priv, chess, backups, getChess = () => null) {
|
||||
if (!isObj(chess) || typeof chess.chessId !== 'string') return null;
|
||||
if (fieldsStandIn(priv, chess)) return standInOf(chess, backups);
|
||||
const base = chess.baseId || chess.chessId;
|
||||
const baseOf = (id) => getChess(id)?.baseId || (typeof id === 'string' ? id.replace(/_b$/, '_a') : id);
|
||||
for (const list of [priv?.board, priv?.hand, priv?.temp]) {
|
||||
for (const p of Array.isArray(list) ? list : []) {
|
||||
if (isObj(p) && typeof p.standInFor === 'string' && p.standInFor && baseOf(p.id) === base) return standInOf(chess, backups);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** id → stand-in record (else the chess record): the lookup a stand-in's loadout / options resolve against. */
|
||||
export function standInGetter(getChess, backups) {
|
||||
return (id) => {
|
||||
@@ -104,7 +143,17 @@ export function deployedModuleId(chess, priv, getChess, backups) {
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
/** The 「替补:X」 badge text of a stand-in record (null without one). */
|
||||
/** The small 「替补」 mark of a stand-in record (null without one): cards, the own pieces' tags, thumbnails. */
|
||||
export function standInLabel(rec) {
|
||||
return isObj(rec) && rec.standInFor && rec.name ? `替补:${rec.name}` : null;
|
||||
return isObj(rec) && rec.standInFor ? t('替补') : null;
|
||||
}
|
||||
|
||||
/** The replaced operator's line of a stand-in's detail card: 「银灰的替补」 (`chessName` = the chess's own name). */
|
||||
export function standInForText(chessName) {
|
||||
return t('{name}的替补', { name: chessName || '' });
|
||||
}
|
||||
|
||||
/** The 「替补」 mark's title: which chess the stand-in fields for, and what stays the chess's ('' without a stand-in). */
|
||||
export function standInTip(rec, chessName) {
|
||||
return isObj(rec) && rec.standInFor ? t('未持有{name}:由替补干员 {standIn} 上场(盟约、特质、阶级与价格不变)', { name: chessName || '', standIn: rec.name || '' }) : '';
|
||||
}
|
||||
@@ -2,8 +2,9 @@
|
||||
// shared with the sync (ui/loadoutSync.js) and the tests. The approved plan (owner's decision 2026-10-05): a player marks
|
||||
// operators as not owned ("下掉"); only the 55 NORMAL chess can be dropped (shared/standIn.js isDroppableChess — the 74
|
||||
// PRESET chess always field their own operator); default = everything owned; out of match (the next match takes it);
|
||||
// co-op: the player's own pieces only; no friend borrowing. A dropped chess keeps its card (name, bonds, price) and is
|
||||
// deployed as its official stand-in (data/backups.json).
|
||||
// co-op: the player's own pieces only; no friend borrowing. A dropped chess is deployed and shown as its official
|
||||
// stand-in (data/backups.json; the owner's recall of 2026-10-06: cards, pieces and the result show the stand-in) while
|
||||
// its bonds, 特质, tier, price and merge stay the chess's.
|
||||
//
|
||||
// The per-browser setting is a sorted list of base chess ids, persisted in localStorage (`sp.pref.ownership` =
|
||||
// { v: 1, notOwned }) and sent with C2S `room.ownership { notOwned }` (the server keeps the droppable ids and drops the
|
||||
|
||||
+16
-13
@@ -18,15 +18,16 @@
|
||||
// Every operator card shows the skill it will fight with — the player's 干员调配 loadout (m.private.loadout, DESIGN
|
||||
// §16): the skill icon above the name, mint-framed with 已调配 in its title when it is not the default skill (and the
|
||||
// module type of an elite card, with its official type icon when the local-client art has it). A chess the player does
|
||||
// not own (0.2.0 补位, m.private.standIns) keeps its card — name, portrait, bonds, price — with an ice 「替补:X」 badge,
|
||||
// and the skill icon is the stand-in's (its backup skill: the one that fights). A 自选 piece (0.2.0, m.private.diy — a DIY
|
||||
// slot the player filled, only ever in its own shop) shows the operator — name, portrait, class, the bonds of its
|
||||
// factions, the pick's skill and module — with a mint 「自选」 badge.
|
||||
// not own (0.2.0 补位, m.private.standIns) shows its stand-in — portrait, name, class and its backup skill, the one that
|
||||
// fights — with the chess's bonds, tier and price and a small ice 「替补」 mark (the owner's recall of the official mode,
|
||||
// 2026-10-06; the replaced operator's name is in the mark's title and on the detail card the first tap opens). A 自选
|
||||
// piece (0.2.0, m.private.diy — a DIY slot the player filled, only ever in its own shop) shows the operator — name,
|
||||
// portrait, class, the bonds of its factions, the pick's skill and module — with a mint 「自选」 badge.
|
||||
|
||||
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, mergeTarget, shopBlockReason, chessLoadout, offerHeader, briefingBondTip, fieldsStandIn, standInOf, standInLoadout, standInLabel, ownDiyRecord, diyGetter } from './gameLogic.js';
|
||||
import { priceTone, mergeProgress, mergeTarget, shopBlockReason, chessLoadout, offerHeader, briefingBondTip, ownStandIn, standInLoadout, standInLabel, standInTip, standInForText, ownDiyRecord, diyGetter } from './gameLogic.js';
|
||||
import { chessPortraitUrl, itemIconUrl, profIconUrl, uiUrl, skillIconUrl, skillRecordIconUrl, moduleTypeIconUrl } from './assetUrls.js';
|
||||
import { data } from '../data.js';
|
||||
import { t } from '../../../shared/i18n.js';
|
||||
@@ -71,7 +72,10 @@ export function ChessCard({ slot, idx, priv, frozen = false, reason = null, free
|
||||
// 0.2.0 自选编队: a DIY slot the player filled is its operator (shared/diy.js, the pick of m.private.diy)
|
||||
const diyData = { chess: data.get('chess'), backups: data.get('backups') };
|
||||
const dr = c0 ? ownDiyRecord(c0, priv, diyData) : null;
|
||||
const c = dr || c0;
|
||||
// 0.2.0 补位: the player's not-owned chess shows its stand-in (art, name, class, its backup skill); the composed record
|
||||
// keeps the chess's identity — tier, bonds, price (a DIY slot is never droppable: one or the other)
|
||||
const si = c0 && !dr ? ownStandIn(c0, priv, data.get('backups')) : null;
|
||||
const c = dr || si || c0;
|
||||
const m = data.get('assets');
|
||||
const tier = c?.tier ?? 1;
|
||||
const prog = mergeProgress(priv, slot.id, (id) => data.lookup('chess', id));
|
||||
@@ -79,13 +83,12 @@ export function ChessCard({ slot, idx, priv, frozen = false, reason = null, free
|
||||
const willMerge = !!hint;
|
||||
const bonds = Array.isArray(c?.bonds) ? c.bonds : [];
|
||||
const disabled = !!reason;
|
||||
// 0.2.0 补位: the player's not-owned chess is deployed as its stand-in — badge + the stand-in's skill
|
||||
const si = c && fieldsStandIn(priv, c) ? standInOf(c, data.get('backups')) : null;
|
||||
const lo = si ? standInLoadout(si, LOOKUPS.getChess, data.get('backups')) : c ? chessLoadout(c, priv?.loadout, dr ? diyGetter(LOOKUPS.getChess, priv, diyData) : LOOKUPS.getChess) : null;
|
||||
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')}
|
||||
const name = `${c?.name || '干员'}${si ? `(${standInForText(c0.name)})` : ''}`;
|
||||
const card = html`<button type="button" class=${cx('scard', `scard--t${tier}`, frozen && 'is-frozen', disabled && 'is-disabled', willMerge && 'is-merge', armed && 'is-armed', si && 'is-standin')}
|
||||
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}>
|
||||
aria-label=${`${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>
|
||||
<${Img} src=${chessPortraitUrl(m, c)} class="scard__art" />
|
||||
@@ -96,10 +99,10 @@ export function ChessCard({ slot, idx, priv, frozen = false, reason = null, free
|
||||
${Array.from({ length: prog.need }, (_, i) => html`<i key=${i} class=${i < prog.copies ? 'on' : ''}></i>`)}
|
||||
</span>` : null}
|
||||
</span>
|
||||
${si ? html`<span class="scard__standin" data-standin=${si.charId} title=${`未持有:由替补干员 ${si.name} 上场(盟约、阶级与价格不变)`}>${standInLabel(si)}</span>` : null}
|
||||
${si ? html`<span class="scard__standin" data-standin=${si.charId} data-for=${c0.chessId} title=${standInTip(si, c0.name)}>${standInLabel(si)}</span>` : null}
|
||||
${dr ? html`<span class="scard__diy" data-diy=${dr.charId} title=${t('自选编队:{name}(只在你的商店出现)', { name: dr.name })}>${t('自选')}</span>` : null}
|
||||
<span class="scard__body">
|
||||
${lo?.skill ? html`<${SkillBadge} chess=${si || c} lo=${lo} standIn=${!!si} />` : null}
|
||||
${lo?.skill ? html`<${SkillBadge} chess=${c} lo=${lo} standIn=${!!si} />` : null}
|
||||
<span class="scard__name">${c?.name || slot.id}</span>
|
||||
<span class="scard__bonds">
|
||||
${bonds.slice(0, 3).map((b) => {
|
||||
@@ -128,7 +131,7 @@ function SkillBadge({ chess, lo, standIn = false }) {
|
||||
const src = custom ? skillRecordIconUrl(m, lo.skill, { empty: false }) : skillIconUrl(m, chess);
|
||||
const slot = Number.isInteger(lo.skill.index) ? `S${lo.skill.index + 1}` : null;
|
||||
const mod = lo.module && !lo.module.none ? lo.module : null;
|
||||
const tip = `${standIn ? `替补 ${chess.name} · ` : ''}技能${slot ? ` ${slot}` : ''}:${lo.skill.name || ''}${custom ? '(已调配)' : ''}${mod ? ` · 模组:${mod.name}` : lo.module?.none ? ' · 未装备模组' : ''}`;
|
||||
const tip = `技能${slot ? ` ${slot}` : ''}:${lo.skill.name || ''}${custom ? '(已调配)' : ''}${mod ? ` · 模组:${mod.name}` : lo.module?.none ? ' · 未装备模组' : ''}`;
|
||||
return html`<span class=${cx('scard__skill', custom && 'is-custom')} title=${tip} aria-label=${tip} data-skill=${lo.skill.skillId || ''}>
|
||||
<${Img} src=${src} fallback=${slot ? html`<span class="scard__sglyph num">${slot}</span>` : html`<${GIcon} name="bolt" />`} />
|
||||
${mod && mod.typeName ? html`<span class="scard__mod" data-type=${mod.typeName}><${Img} src=${moduleTypeIconUrl(data.get('local'), mod.typeName)} class="scard__modicon" />${mod.typeName}</span>` : null}
|
||||
|
||||
@@ -153,8 +153,9 @@ export function withLiveLayers(bonds, live) {
|
||||
* field's units) and, under client-side combat, the battle on screen's (`extra` = battle/runner.js ownerOps: the meta
|
||||
* the runner publishes is taken before the operators deploy). Their hand is never sent — members count as owned only
|
||||
* when they are on the field. Their operators keep the items the unit info carries (UnitInfo `items`), so a 变形同构体
|
||||
* wearer is listed as a member of the bond it grants (gameLogic grantedBonds). null without a field or battle.
|
||||
* @param {any} field @param {string} ownerId @param {any[]|null} [extra] UnitInfo-like { kind, ownerId, defId, items? }
|
||||
* wearer is listed as a member of the bond it grants (gameLogic grantedBonds), and a 补位 unit its `standInFor` (the
|
||||
* popup draws the stand-in). null without a field or battle.
|
||||
* @param {any} field @param {string} ownerId @param {any[]|null} [extra] UnitInfo-like { kind, ownerId, defId, items?, standInFor? }
|
||||
*/
|
||||
export function ownerBoard(field, ownerId, extra = null) {
|
||||
const fromField = isObj(field) && Array.isArray(field.units) ? field.units : null;
|
||||
@@ -166,6 +167,8 @@ export function ownerBoard(field, ownerId, extra = null) {
|
||||
const p = Array.isArray(u.items) && u.items.length ? { kind: 'chess', id: u.defId, items: u.items.filter((x) => typeof x === 'string') } : { kind: 'chess', id: u.defId };
|
||||
// 0.2.0 自选编队: a DIY slot's unit names its operator (UnitInfo diy) — the popup counts it for that operator's bonds
|
||||
if (isObj(u.diy) && typeof u.diy.charId === 'string') p.diy = u.diy;
|
||||
// 0.2.0 补位: a unit fighting as its stand-in says so (UnitInfo standInFor) — the popup draws that member as the stand-in
|
||||
if (typeof u.standInFor === 'string' && u.standInFor) p.standInFor = u.standInFor;
|
||||
return p;
|
||||
});
|
||||
return { board, hand: [], temp: [] };
|
||||
|
||||
@@ -75,12 +75,13 @@
|
||||
// * Operator ownership (0.2.0 补位, the approved plan — owner's decision 2026-10-05): the human's `seat.notOwned` (base
|
||||
// chess ids marked 未持有 on the 干员持有 screen) is re-checked against this match's data (shared/protocol.js
|
||||
// checkNotOwned + a stand-in record in data/backups.json) and fixed for the match (`standIns`; bots own every
|
||||
// operator). A piece of such a chess — normal or elite, wherever it is — keeps the chess's identity (name, bonds,
|
||||
// 特质, tier, price, sell price, merge: every rule that reads gd.chess) and fights as its official stand-in
|
||||
// operator). A piece of such a chess — normal or elite, wherever it is — keeps the chess's identity for the rules
|
||||
// (bonds, 特质, tier, price, sell price, merge: every rule that reads gd.chess) and is its official stand-in
|
||||
// (`fieldRecord` = gd.standIn: body, stats, range, position, skills / talents / module — the backup selection, the
|
||||
// loadout never applies to it, "对于补位干员其技能不可更改"): battleInput marks it `standIn: true`, placeClass /
|
||||
// summon range / the bots' range read the stand-in record, it makes no summons (none of the 17 stand-ins has one),
|
||||
// and the scouting views draw it with the stand-in's art on the board. m.private exposes `standIns`.
|
||||
// and what shows it shows the stand-in (the owner's recall of the official mode, 2026-10-06): the scouting views
|
||||
// (board and bench), the m.result lineup (`standInFor`), the elite / gift tickers' names. m.private exposes `standIns`.
|
||||
// * 自选编队 (0.2.0 DIY, the owner's decisions of 2026-10-05; player/diy.js): the human's `seat.diy` picks are re-checked
|
||||
// against this match's data and kits and fixed for the match (`diy`; bots none [ASSUMED]). With picks, `this.gd` is the
|
||||
// player's data view: the slotted slots' ids (normal and elite) are the composed 自选 records, so the player's piece
|
||||
|
||||
@@ -643,8 +643,15 @@ export function makeCtx(m, ps, source, hook, ev = null) {
|
||||
// a string or a shared/i18n.js msg(msgid, params)
|
||||
toast: (text, kind = 'info') => m.toast(ps, kind, text && typeof text === 'object' ? text : String(text)),
|
||||
ticker: (text) => m.tickerText(text && typeof text === 'object' ? text : String(text)),
|
||||
/** CHAR_GIFT broadcast to this player: "{0}博士给你赠送了{1}". */
|
||||
giftTicker: (fromName, chessId) => { const c = gd.chess(chessId); m.tickerFor('CHAR_GIFT', [String(fromName), c ? c.name : String(chessId)], { to: ps.playerId }); },
|
||||
/**
|
||||
* CHAR_GIFT broadcast to this player: "{0}博士给你赠送了{1}" — named as this player sees the gift (a chess it fields as
|
||||
* its 补位 stand-in by the stand-in's name: 0.2.0, the owner's recall of the official mode, 2026-10-06).
|
||||
*/
|
||||
giftTicker: (fromName, chessId) => {
|
||||
const c = gd.chess(chessId);
|
||||
const shown = c && typeof ps.fieldRecord === 'function' ? ps.fieldRecord(c) || c : c;
|
||||
m.tickerFor('CHAR_GIFT', [String(fromName), shown ? shown.name : String(chessId)], { to: ps.playerId });
|
||||
},
|
||||
|
||||
// ---- team
|
||||
teammates: () => m.alivePlayers().filter((p) => p !== ps).map((p) => makeCtx(m, p, source, hook, null)),
|
||||
|
||||
@@ -220,19 +220,20 @@ export class MatchViews {
|
||||
// items included (the client draws their floating plates). PRTS 帮助 counts the temp area with the hand (review of
|
||||
// PR #129). Part of the meta for every watcher alike — the spectator seat's copy equals a teammate's
|
||||
// (test/match/spectator.test.js). User playtest #2 item 1 (GitHub #44).
|
||||
// (a held chess the player fields as its stand-in keeps the chess's own art — the hand shows the original operator,
|
||||
// the approved 0.2.0 补位 plan — and carries `standInFor`: its card shows the stand-in it deploys as)
|
||||
// (a held chess the player fields as its stand-in is the stand-in there too — name, art, max HP — and carries
|
||||
// `standInFor`, like a board piece: the owner's recall of the official mode, 2026-10-06, the hand shows the stand-in)
|
||||
const benchUnit = (piece, i, y) => {
|
||||
const rec = piece.kind === 'item' ? gd.item(piece.id) : piece.kind === 'token' ? gd.token(piece.id) : gd.chess(piece.id);
|
||||
const assets = (rec && rec.assets) || {};
|
||||
const lo = piece.kind === 'chess' && rec ? ps.loadoutFor(rec) : null;
|
||||
const standIn = piece.kind === 'chess' && rec && ps.fieldsStandIn(rec) ? this.gd.standIn(rec.chessId) : null;
|
||||
const body = standIn || rec;
|
||||
const assets = (body && body.assets) || {};
|
||||
const lo = piece.kind === 'chess' && rec ? ps.loadoutFor(rec) : null;
|
||||
units.push({
|
||||
id: piece.uid, uid: piece.uid, kind: piece.kind === 'token' ? 'token' : piece.kind === 'item' ? 'item' : 'op',
|
||||
side: 'ally', ownerId: ps.playerId, defId: piece.id,
|
||||
name: rec ? rec.name : piece.id, tier: rec && Number.isInteger(rec.tier) ? rec.tier : 1, golden: !!(rec && rec.isGolden),
|
||||
spine: assets.spine || (rec && rec.charId) || piece.id, avatar: assets.avatar || (rec && rec.charId) || piece.id,
|
||||
x: i, y, maxHp: rec && rec.stats && Number.isFinite(rec.stats.maxHp) ? rec.stats.maxHp : 1,
|
||||
name: body ? body.name : piece.id, tier: rec && Number.isInteger(rec.tier) ? rec.tier : 1, golden: !!(rec && rec.isGolden),
|
||||
spine: assets.spine || (body && body.charId) || piece.id, avatar: assets.avatar || (body && body.charId) || piece.id,
|
||||
x: i, y, maxHp: body && body.stats && Number.isFinite(body.stats.maxHp) ? body.stats.maxHp : 1,
|
||||
skillIndex: lo && Number.isInteger(lo.skillIndex) ? lo.skillIndex : undefined,
|
||||
moduleId: lo && typeof lo.moduleId === 'string' ? lo.moduleId : undefined,
|
||||
items: piece.kind === 'chess' && Array.isArray(piece.items) && piece.items.length ? piece.items.map((it) => it.id) : undefined,
|
||||
|
||||
@@ -131,8 +131,11 @@ export class PlayerAcquire {
|
||||
}
|
||||
this.stats.merges++;
|
||||
this.pushRewardOffer('merge');
|
||||
// (named as everyone sees the elite: a chess this player fields as its stand-in by the stand-in's name — 0.2.0 补位,
|
||||
// the owner's recall of the official mode, 2026-10-06 [ASSUMED for the ticker])
|
||||
const rec = this.gd.chess(goldenId);
|
||||
this.m.tickerFor('GOLDEN_CHAR', [this.name, rec ? rec.name : goldenId], { playerId: this.playerId });
|
||||
const shown = rec ? this.fieldRecord(rec) || rec : null;
|
||||
this.m.tickerFor('GOLDEN_CHAR', [this.name, shown ? shown.name : goldenId], { playerId: this.playerId });
|
||||
this.m.dispatch(this, 'onMerge', { kind: 'chess', piece: elite, baseId, consumed: consumed.map((l) => l.piece.uid), area: where });
|
||||
return elite;
|
||||
}
|
||||
|
||||
@@ -124,8 +124,9 @@ export class PlayerBasics {
|
||||
* The record this player's piece of chess record `rec` fights with (0.2.0 补位): the stand-in record
|
||||
* (gd.standIn — the chess's identity, the stand-in's body) when the player fields its stand-in; for a DIY slot record
|
||||
* the player's 自选 record of that slot (this.gd — a caller holding the match's own record of the slot gets the
|
||||
* operator); else `rec` itself. Rules about the unit's body read it (placement class, summon / bot ranges, the
|
||||
* scouting art); rules about the chess (price, bonds, 特质, merges, pools) keep reading gd.chess.
|
||||
* operator); else `rec` itself. Rules about the unit's body read it (placement class, summon / bot ranges), and so
|
||||
* does what shows the piece (the scouting art, the elite / gift tickers' names — the owner's recall of 2026-10-06: the official
|
||||
* mode shows the stand-in everywhere); rules about the chess (price, bonds, 特质, merges, pools) keep reading gd.chess.
|
||||
* @param {object|null} rec
|
||||
*/
|
||||
fieldRecord(rec) {
|
||||
|
||||
@@ -89,7 +89,8 @@ export class PlayerViews {
|
||||
// DESIGN §16: the effective operator loadout ({ [baseChessId]: { skill, module } }; chess not listed use defaults)
|
||||
loadout: this.loadout,
|
||||
// 0.2.0 补位: the base chess ids this player fields as their stand-ins in this match (the not-owned list the seat had
|
||||
// at the match start; [] = every operator owned) — the client's 「替补:X」 badges, the stand-in's art and card
|
||||
// at the match start; [] = every operator owned) — the client shows these as their stand-ins (cards, pieces, the
|
||||
// detail card, with a small 「替补」 mark)
|
||||
standIns: this.standIns,
|
||||
// 0.2.0 自选编队: this player's 自选 picks in this match ({ [slotBaseId]: { charId, skillIndex, uniEquipId } } — the seat's
|
||||
// when the match started; {} = none) — the client composes the operator's record for its own cards and pieces
|
||||
|
||||
@@ -100,6 +100,10 @@ export function buildResult(m, outcome) {
|
||||
// 0.2.0 自选编队: a DIY slot's pick — the result screen names and draws the operator (shared/diy.js diyRecord)
|
||||
const pick = typeof ps.diyPickOf === 'function' ? ps.diyPickOf(piece.id) : null;
|
||||
if (pick) e.diy = { charId: pick.charId, skillIndex: pick.skillIndex, uniEquipId: pick.uniEquipId };
|
||||
// 0.2.0 补位: a chess this player fielded as its stand-in — the result screen draws the stand-in (the owner's recall
|
||||
// of the official mode, 2026-10-06); `standInFor` = the replaced operator's charId, like the sim's UnitInfo
|
||||
const si = typeof ps.fieldsStandIn === 'function' && ps.fieldsStandIn(piece.id) ? gd.standIn(piece.id) : null;
|
||||
if (si && si.standInFor) e.standInFor = si.standInFor;
|
||||
return e;
|
||||
});
|
||||
// the team's clear counts for the players still in; an eliminated / departed teammate did not pass the boss round
|
||||
|
||||
+5
-2
@@ -30,9 +30,11 @@ export function freePort() {
|
||||
* Start the real server (`node server/index.js`, or test/e2e/fastServer.mjs with `fast` speed-ups) on a free port (or
|
||||
* `port`: e.g. the port of a server just stopped — a server restart the open clients reconnect to).
|
||||
* @param {{ port?: number, fast?: { timerScale?: number, combatSpeed?: number, startRound?: 'boss'|'hidden'|number, kit?: number, chess?: string[],
|
||||
* items?: string[], idleBots?: boolean, kits?: string[][], botChess?: string[], autoPlace?: boolean, eliminate?: number[], stage?: string } }} [opts]
|
||||
* items?: string[], idleBots?: boolean, kits?: string[][], botChess?: string[], autoPlace?: boolean, eliminate?: number[], stage?: string,
|
||||
* finishAfter?: number } }} [opts]
|
||||
* kits: per-human starter kits (seat order); botChess: the AI seats' kit; autoPlace: the kits go onto the board at the
|
||||
* first prep; eliminate: humans (seat order) eliminated at the jump; stage: the stage of every match — fastServer.mjs hooks
|
||||
* first prep; eliminate: humans (seat order) eliminated at the jump; stage: the stage of every match; finishAfter: the
|
||||
* match ends (RESULT) once that round has settled — fastServer.mjs hooks
|
||||
* @returns {Promise<{ base: string, port: number, logs: string[], stop: (o?: { hard?: boolean }) => Promise<void> }>}
|
||||
*/
|
||||
export async function startRealServer(opts = {}) {
|
||||
@@ -57,6 +59,7 @@ export async function startRealServer(opts = {}) {
|
||||
if (opts.fast.eliminate?.length) env.SP_ELIMINATE = opts.fast.eliminate.join(',');
|
||||
if (opts.fast.stage) env.SP_STAGE = String(opts.fast.stage);
|
||||
if (opts.fast.level) env.SP_START_LEVEL = String(opts.fast.level);
|
||||
if (opts.fast.finishAfter) env.SP_FINISH_AFTER = String(opts.fast.finishAfter);
|
||||
}
|
||||
const child = spawn(process.execPath, [entry], { cwd: ROOT, env, stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
const logs = [];
|
||||
|
||||
@@ -24,6 +24,8 @@
|
||||
// 战场#08's pool).
|
||||
// SP_START_LEVEL=<1..6>: the starter kit also puts every human's 调度中心 at this level (and rerolls its shop there, before
|
||||
// SP_START_SHOP stocks its first slots) — test/ui/diy.e2e.test.js: a tier-5 自选 piece is sold from level 5.
|
||||
// SP_FINISH_AFTER=<round>: the match ends (a defeat: m.result, the RESULT screen) once that round has settled, instead
|
||||
// of going on to the next round — test/ui/standin.e2e.test.js: the result lineup of the board just fought with.
|
||||
// Not a test file (node --test runs it as a no-op module when NODE_TEST_CONTEXT is set).
|
||||
|
||||
import { startServer } from '../../server/index.js';
|
||||
@@ -49,6 +51,7 @@ if (!process.env.NODE_TEST_CONTEXT) {
|
||||
const eliminate = String(process.env.SP_ELIMINATE || '').split(',').map((x) => x.trim()).filter(Boolean).map(Number);
|
||||
const forcedStage = String(process.env.SP_STAGE || '').trim();
|
||||
const startLevel = Math.max(0, Math.min(6, Number(process.env.SP_START_LEVEL) || 0));
|
||||
const finishAfter = Math.max(0, Number(process.env.SP_FINISH_AFTER) || 0);
|
||||
|
||||
class FastMatch extends Match {
|
||||
constructor(opts) {
|
||||
@@ -119,6 +122,11 @@ if (!process.env.NODE_TEST_CONTEXT) {
|
||||
this.markPublic();
|
||||
}
|
||||
|
||||
afterSettle() {
|
||||
if (finishAfter && this.round >= finishAfter && !this.ended) { this.finish({ victory: false, reason: 'defeat' }); return; }
|
||||
super.afterSettle();
|
||||
}
|
||||
|
||||
scheduleBotPrep(ps, i = 0) {
|
||||
if (!idleBots) { super.scheduleBotPrep(ps, i); return; }
|
||||
const round = this.round;
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
// test/match/standin-ownership.test.js — 0.2.0 补位 end to end on the server (the approved plan, owner's decision
|
||||
// 2026-10-05): the not-owned list (room.ownership → seats[].notOwned) is checked leniently, fixed for the match and
|
||||
// applied to that player's own pieces only — the chess keeps its identity (name, bonds, price, merge) and its battle
|
||||
// unit is the official stand-in (PlayerBattleInput `standIn: true`: the stand-in's body, skill, talents, module, range,
|
||||
// position), in the player's own field, the 联防 field and the boss fields alike, client-run and server-run identical.
|
||||
// applied to that player's own pieces only — the chess keeps its identity for the rules (bonds, price, merge) and its
|
||||
// battle unit is the official stand-in (PlayerBattleInput `standIn: true`: the stand-in's body, skill, talents, module,
|
||||
// range, position), in the player's own field, the 联防 field and the boss fields alike, client-run and server-run
|
||||
// identical; what shows the piece shows the stand-in (the owner's recall of the official mode, 2026-10-06): prep
|
||||
// scouting (board and bench), the m.result lineup (`standInFor`), the elite and gift tickers.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { C2S, validateC2S, checkNotOwned, OWNERSHIP_LIMITS, checkLoadout } from '../../shared/protocol.js';
|
||||
@@ -211,7 +213,7 @@ test('联防 and the Final Assault field carry each player\'s own marks (p_0 sta
|
||||
mb.dispose();
|
||||
});
|
||||
|
||||
test('prep scouting (m.field): a teammate\'s stand-in shows the stand-in\'s art on the board, the original in the hand (standInFor on both)', REAL, () => {
|
||||
test('prep scouting (m.field): a teammate\'s stand-in shows the stand-in\'s art on the board and in the hand (standInFor on both)', REAL, () => {
|
||||
const h = ownershipMatch().start();
|
||||
const m = h.m;
|
||||
h.toPrep(1);
|
||||
@@ -225,6 +227,60 @@ test('prep scouting (m.field): a teammate\'s stand-in shows the stand-in\'s art
|
||||
const bu = by.get(onBoard.uid);
|
||||
assert.deepEqual([bu.defId, bu.name, bu.spine, bu.avatar, bu.standInFor, bu.skillIndex], [SILVER, 'Sharp', 'char_609_acguad', 'char_609_acguad', chess(SILVER).charId, 1]);
|
||||
const hu = by.get(held.uid);
|
||||
assert.deepEqual([hu.defId, hu.name, hu.spine, hu.standInFor], [MLYSS, chess(MLYSS).name, chess(MLYSS).assets.spine, chess(MLYSS).charId]);
|
||||
const tulip = m.gd.standIn(MLYSS);
|
||||
assert.deepEqual([hu.defId, hu.name, hu.spine, hu.avatar, hu.maxHp, hu.standInFor], [MLYSS, '郁金香', 'char_608_acpion', 'char_608_acpion', tulip.stats.maxHp, chess(MLYSS).charId],
|
||||
'the hand shows the stand-in too (the owner\'s recall, 2026-10-06)');
|
||||
// an owned chess in the hand keeps its own art
|
||||
const p1 = h.ps('p_1');
|
||||
const own = give(m, p1, MLYSS, 'hand');
|
||||
assert.deepEqual(m.handle('p_0', { t: 'g.watch', fieldId: 'n:p_1' }), { ok: true });
|
||||
const ou = wire(h.lastTo('p_0', 'm.field')).units.find((u) => u.uid === own.uid);
|
||||
assert.deepEqual([ou.name, ou.spine, ou.standInFor], [chess(MLYSS).name, chess(MLYSS).assets.spine, undefined]);
|
||||
m.dispose();
|
||||
});
|
||||
|
||||
test('what shows the piece shows the stand-in: the m.result lineup carries standInFor, the elite and gift tickers name the stand-in; the rules keep the chess', REAL, async () => {
|
||||
const { buildResult } = await import('../../server/match/results.js');
|
||||
const h = ownershipMatch().start();
|
||||
const m = h.m;
|
||||
h.toPrep(1);
|
||||
const p0 = h.ps('p_0');
|
||||
const p1 = h.ps('p_1');
|
||||
clearBoard(p0);
|
||||
clearBoard(p1);
|
||||
const a = give(m, p0, SILVER, 'board', legalTileFor(m, p0, SILVER));
|
||||
const b = give(m, p0, INSIDE, 'board', legalTileFor(m, p0, INSIDE));
|
||||
const c = give(m, p1, SILVER, 'board', legalTileFor(m, p1, SILVER));
|
||||
const res = buildResult(m, { victory: false, hiddenReached: false, hiddenCleared: false, reason: 'defeat' });
|
||||
const lineup = (pid) => new Map(res.players.find((x) => x.playerId === pid).lineup.map((e) => [e.id + '@' + e.row + ',' + e.col, e]));
|
||||
const l0 = [...lineup('p_0').values()];
|
||||
assert.equal(l0.find((e) => e.id === SILVER).standInFor, chess(SILVER).charId, 'p_0 fielded Sharp');
|
||||
assert.equal(l0.find((e) => e.id === INSIDE).standInFor, undefined, 'a PRESET chess is itself');
|
||||
assert.equal([...lineup('p_1').values()].find((e) => e.id === SILVER).standInFor, undefined, 'p_1 owns 银灰');
|
||||
assert.ok([a, b, c].every(Boolean));
|
||||
// the elite ticker: three copies merge (a rule of the chess) — the line names what everyone sees
|
||||
clearBoard(p0);
|
||||
clearBoard(p1);
|
||||
const goldName = (ps) => {
|
||||
const before = h.bc.length;
|
||||
give(m, ps, SILVER, 'hand');
|
||||
give(m, ps, SILVER, 'hand');
|
||||
ps.acquireChess(SILVER);
|
||||
const line = h.bc.slice(before).find((x) => x.t === 'm.ticker' && x.type === 'GOLDEN_CHAR');
|
||||
assert.ok(line, `${ps.playerId}: a GOLDEN_CHAR line`);
|
||||
assert.ok([...ps.hand, ...ps.temp].some((x) => x && x.id === chess(SILVER).goldenId), 'merged by the chess\'s rule');
|
||||
return line.args[1];
|
||||
};
|
||||
assert.equal(goldName(p0), 'Sharp');
|
||||
assert.equal(goldName(p1), chess(chess(SILVER).goldenId).name);
|
||||
// a gift (信标's CHAR_GIFT, sent to its receiver only) is named as the receiver sees it
|
||||
const { makeCtx } = await import('../../server/match/effectsMeta.js');
|
||||
const gift = (ps) => {
|
||||
const before = h.sent.length;
|
||||
makeCtx(m, ps, { key: 'test' }, 'onPrepStart').giftTicker('P9', SILVER);
|
||||
return h.sent.slice(before).find(([id, x]) => id === ps.playerId && x.t === 'm.ticker' && x.type === 'CHAR_GIFT')?.[1].args[1];
|
||||
};
|
||||
assert.equal(gift(p0), 'Sharp');
|
||||
assert.equal(gift(p1), chess(SILVER).name);
|
||||
m.dispose();
|
||||
});
|
||||
+133
-24
@@ -1,9 +1,11 @@
|
||||
// 0.2.0 补位 — client side (the approved plan, owner's decision 2026-10-05): the 干员持有 tab's model (storage, toggles,
|
||||
// the roster by tier, export / import) and its server sync (room.ownership), and the match UI of a not-owned chess —
|
||||
// the shop / reward card keeps the chess (name, bonds, price) with a 「替补:X」 badge and the stand-in's skill, the own
|
||||
// pieces' tags, the board / deploy rules of the stand-in's body (position, range), and the detail card's body (portrait,
|
||||
// class, skill, talents) from the stand-in for the player's own pieces (m.private.standIns) and any unit carrying
|
||||
// UnitInfo `standInFor`.
|
||||
// SHOWN as its stand-in wherever the player's copy appears (the owner's recall of the official mode, 2026-10-06): the
|
||||
// shop / reward card (the stand-in's portrait, name, class and skill; the chess's bonds and price; a small 「替补」 mark),
|
||||
// the own pieces (the stand-in's model in the hand, 临时整备区 and on the board, a 「替补」 tag), the detail card (the
|
||||
// stand-in's portrait, name, skill, talents; the chess's bonds and 特质; 「银灰的替补」), the bond popups (own and a
|
||||
// teammate's), the result lineup and the equip-replace dialog — while the rules keep the chess (placement and ranges
|
||||
// by the stand-in's body, as before).
|
||||
import { test, describe } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
@@ -27,6 +29,10 @@ const { createStore } = await import('../../public/js/store.js');
|
||||
const G = await import('../../public/js/ui/gameLogic.js');
|
||||
const { ChessCard } = await import('../../public/js/ui/shopBar.js');
|
||||
const { ChessDetail, resolveDetail } = await import('../../public/js/ui/detailPanel.js');
|
||||
const { BondPopup } = await import('../../public/js/ui/bondStrip.js');
|
||||
const { ownerBoard } = await import('../../public/js/ui/watchBonds.js');
|
||||
const { replaceRequest } = await import('../../public/js/ui/equipReplace.js');
|
||||
const { LineupThumb } = await import('../../public/js/screens/result.js');
|
||||
const { standInPieces } = await import('../../public/js/screens/game/standInTags.js');
|
||||
const { OwnCard } = await import('../../public/js/screens/ownership.js');
|
||||
const { data } = await import('../../public/js/data.js');
|
||||
@@ -49,6 +55,17 @@ function* walk(v) {
|
||||
}
|
||||
const hasClass = (v, c) => typeof v?.props?.class === 'string' && v.props.class.split(/\s+/).includes(c);
|
||||
const textOf = (v) => [...walk(v)].flatMap((n) => (Array.isArray(n.props?.children) ? n.props.children : [n.props?.children])).filter((x) => typeof x === 'string' || typeof x === 'number').join('');
|
||||
/** Render a component tree one level of function components deep at a time (htm vnodes with function types). */
|
||||
function expand(v, depth = 6) {
|
||||
if (Array.isArray(v)) return v.map((x) => expand(x, depth));
|
||||
if (!v || typeof v !== 'object') return v;
|
||||
if (typeof v.type === 'function' && depth > 0) {
|
||||
try { return expand(v.type(v.props || {}), depth - 1); } catch { return v; }
|
||||
}
|
||||
const children = v.props?.children;
|
||||
return children === undefined ? v : { ...v, props: { ...v.props, children: expand(children, depth) } };
|
||||
}
|
||||
const srcsOf = (v) => [...walk(v)].map((n) => n.props?.src).filter((x) => typeof x === 'string');
|
||||
|
||||
describe('干员持有 model (ui/ownershipModel.js)', () => {
|
||||
test('storage: tolerant parse, clean sorted ids, round trip; hostile keys dropped', () => {
|
||||
@@ -177,7 +194,10 @@ test('ownership sync: room.ownership after welcome and after edits (debounced);
|
||||
// ---- match UI -------------------------------------------------------------------------------------------------------------
|
||||
|
||||
describe('match UI of a not-owned chess', () => {
|
||||
const priv = { standIns: [SILVER, MLYSS, SARIA], loadout: {}, board: [], hand: [], temp: [] };
|
||||
const priv = { playerId: 'p_0', standIns: [SILVER, MLYSS, SARIA], loadout: {}, board: [], hand: [], temp: [] };
|
||||
const SHARP = 'char_609_acguad';
|
||||
const sharp = () => BACKUPS.units[SHARP];
|
||||
|
||||
test('gameLogic: own pieces by m.private.standIns (normal + elite); the composed record keeps the identity', () => {
|
||||
assert.deepEqual(G.standInIds(priv), [SILVER, MLYSS, SARIA]);
|
||||
assert.equal(G.fieldsStandIn(priv, getChess(SILVER)), true);
|
||||
@@ -185,14 +205,21 @@ describe('match UI of a not-owned chess', () => {
|
||||
assert.equal(G.fieldsStandIn({ standIns: [] }, getChess(SILVER)), false);
|
||||
assert.equal(G.fieldsStandIn(null, getChess(SILVER)), false);
|
||||
const si = G.standInOf(getChess(SILVER), BACKUPS);
|
||||
assert.deepEqual([si.charId, si.name, si.standInFor, si.chessId, si.price, si.bonds], ['char_609_acguad', 'Sharp', getChess(SILVER).charId, SILVER, getChess(SILVER).price, getChess(SILVER).bonds]);
|
||||
assert.deepEqual([si.charId, si.name, si.standInFor, si.chessId, si.price, si.bonds, si.garrisonIds], [SHARP, 'Sharp', getChess(SILVER).charId, SILVER, getChess(SILVER).price, getChess(SILVER).bonds, getChess(SILVER).garrisonIds]);
|
||||
assert.equal(G.standInOf(getChess(SILVER), BACKUPS), si, 'cached');
|
||||
assert.equal(G.standInOf(si, BACKUPS), si, 'a composed record is its own stand-in');
|
||||
assert.equal(G.standInOf(getChess(CATHY), BACKUPS), null, 'PRESET');
|
||||
assert.equal(G.standInLabel(si), '替补:Sharp');
|
||||
assert.equal(G.ownStandIn(getChess(SILVER), priv, BACKUPS), si);
|
||||
assert.equal(G.ownStandIn(getChess(SILVER), { standIns: [] }, BACKUPS), null);
|
||||
assert.equal(G.standInLabel(si), '替补', 'a small mark');
|
||||
assert.equal(G.standInLabel(getChess(SILVER)), null);
|
||||
assert.equal(G.standInForText('银灰'), '银灰的替补');
|
||||
assert.match(G.standInTip(si, '银灰'), /未持有银灰:由替补干员 Sharp 上场/);
|
||||
const lo = G.standInLoadout(si, getChess, BACKUPS);
|
||||
assert.equal(lo.skill.skillId, 'skchr_acguad_2', 'its backup skill (S2), never the player\'s loadout');
|
||||
// the deployed record: the stand-in's for a dropped chess, the loadout's otherwise
|
||||
assert.equal(G.deployedRecord(getChess(SILVER), priv, getChess, BACKUPS).charId, 'char_609_acguad');
|
||||
assert.equal(G.deployedRecord(getChess(SILVER), priv, getChess, BACKUPS).charId, SHARP);
|
||||
assert.equal(G.deployedRecord(si, priv, getChess, BACKUPS).charId, SHARP, 'a composed record deploys as itself');
|
||||
assert.equal(G.deployedRecord(getChess(SILVER), { standIns: [] }, getChess, BACKUPS).charId, getChess(SILVER).charId);
|
||||
const eg = getChess(getChess(MLYSS).goldenId);
|
||||
assert.equal(G.deployedModuleId(eg, priv, getChess, BACKUPS), eg.backup.uniEquipId, 'the backup module (SOL-X)');
|
||||
@@ -208,58 +235,140 @@ describe('match UI of a not-owned chess', () => {
|
||||
assert.equal(G.piecePosition(own, { kind: 'chess', id: SARIA }), 'MELEE');
|
||||
});
|
||||
|
||||
test('shop / reward card: the chess\'s name, price and bonds stay, the 「替补:X」 badge and the stand-in\'s skill show', () => {
|
||||
test('shop / reward card: the stand-in\'s portrait, name, class and skill; the chess\'s bonds and price; a small 「替补」 mark', () => {
|
||||
const slot = { kind: 'chess', id: SILVER, price: 3, basePrice: 3 };
|
||||
const card = ChessCard({ slot, idx: 0, priv, onBuy() {}, onDetail() {} });
|
||||
const nodes = [...walk(card)];
|
||||
const badge = nodes.find((v) => hasClass(v, 'scard__standin'));
|
||||
assert.ok(badge, 'badge');
|
||||
assert.equal(badge.props['data-standin'], 'char_609_acguad');
|
||||
assert.equal(badge.props.children, '替补:Sharp');
|
||||
assert.equal(nodes.find((v) => hasClass(v, 'scard__name')).props.children, '银灰', 'the card keeps the chess\'s name');
|
||||
assert.ok(badge, 'the mark');
|
||||
assert.deepEqual([badge.props['data-standin'], badge.props['data-for'], badge.props.children], [SHARP, SILVER, '替补']);
|
||||
assert.match(badge.props.title, /银灰/, 'its title names the chess');
|
||||
assert.equal(nodes.find((v) => hasClass(v, 'scard__name')).props.children, 'Sharp', 'the stand-in\'s name');
|
||||
assert.ok(hasClass(card, 'is-standin'));
|
||||
assert.match(card.props['aria-label'], /^Sharp(银灰的替补),价格 3/);
|
||||
assert.ok(srcsOf(card).some((u) => u.includes('char_609_acguad_1')), 'the stand-in\'s portrait');
|
||||
assert.ok(!srcsOf(card).some((u) => u.includes('svrash')), 'not the replaced operator\'s');
|
||||
assert.ok(textOf(nodes.find((v) => hasClass(v, 'scard__class'))).includes(sharp().subProfessionName), 'the stand-in\'s class (无畏者)');
|
||||
const bonds = nodes.filter((v) => hasClass(v, 'scard__bond')).map((v) => v.key);
|
||||
assert.deepEqual(bonds, getChess(SILVER).bonds, 'the chess\'s bonds still count');
|
||||
const skill = nodes.find((v) => v?.type && v.props?.lo);
|
||||
assert.equal(skill.props.lo.skill.skillId, 'skchr_acguad_2');
|
||||
const owned = ChessCard({ slot, idx: 0, priv: { standIns: [] }, onBuy() {}, onDetail() {} });
|
||||
assert.equal([...walk(owned)].find((v) => hasClass(v, 'scard__standin')), undefined, 'owned: no badge');
|
||||
assert.equal([...walk(owned)].find((v) => hasClass(v, 'scard__standin')), undefined, 'owned: no mark');
|
||||
assert.equal([...walk(owned)].find((v) => hasClass(v, 'scard__name')).props.children, '银灰');
|
||||
});
|
||||
|
||||
test('detail card: own piece / card → the stand-in\'s body under the chess\'s name; a unit with standInFor likewise', () => {
|
||||
test('detail card: the stand-in\'s portrait, name, skill and talents, 「银灰的替补」, the chess\'s bonds and 特质; a unit with standInFor likewise', () => {
|
||||
const piece = { uid: 41, kind: 'chess', id: SILVER, golden: false, items: [] };
|
||||
const pieces = new Map([[41, { piece, area: 'hand', idx: 0 }]]);
|
||||
const r = resolveDetail({ kind: 'piece', uid: 41 }, pieces, { priv, backups: BACKUPS });
|
||||
assert.equal(r.standIn?.charId, 'char_609_acguad');
|
||||
assert.equal(r.standIn?.charId, SHARP);
|
||||
assert.equal(resolveDetail({ kind: 'piece', uid: 41 }, pieces, { priv: { standIns: [] }, backups: BACKUPS }).standIn, null);
|
||||
assert.equal(resolveDetail({ kind: 'chess', id: SILVER }, new Map(), { priv, backups: BACKUPS }).standIn?.charId, 'char_609_acguad', 'a shop card');
|
||||
assert.equal(resolveDetail({ kind: 'chess', id: SILVER }, new Map(), { priv, backups: BACKUPS }).standIn?.charId, SHARP, 'a shop card');
|
||||
const u = resolveDetail({ kind: 'unit', unit: { id: 9, uid: 99, side: 'ally', kind: 'op', defId: SARIA, standInFor: getChess(SARIA).charId } }, new Map(), { priv: null, backups: BACKUPS });
|
||||
assert.equal(u.standIn?.charId, 'char_613_acmedc', 'a teammate\'s unit says it itself');
|
||||
// a teammate's bond popup row: its own word (standInFor), never the viewer's list
|
||||
const mate = resolveDetail({ kind: 'chess', id: SILVER, owner: 'p_1' }, new Map(), { priv, backups: BACKUPS });
|
||||
assert.equal(mate.standIn, null, 'the viewer\'s 补位 list is not the teammate\'s');
|
||||
assert.equal(resolveDetail({ kind: 'chess', id: SILVER, owner: 'p_1', standInFor: getChess(SILVER).charId }, new Map(), { priv, backups: BACKUPS }).standIn?.charId, SHARP);
|
||||
assert.equal(resolveDetail({ kind: 'chess', id: SILVER, foreign: true }, new Map(), { priv, backups: BACKUPS }).standIn, null, 'the banned list: the chess');
|
||||
const blocks = ChessDetail({ chess: r.chess, piece, editable: false, bonds: [], loadout: null, standIn: r.standIn });
|
||||
const head = blocks.find((b) => b.key === 'head');
|
||||
const tag = [...walk(head)].find((v) => hasClass(v, 'dtag-standin'));
|
||||
assert.equal(tag?.props.children, '替补:Sharp');
|
||||
assert.equal([...walk(head)].find((v) => hasClass(v, 'dhead__name')).props.children, '银灰');
|
||||
const hn = [...walk(head)];
|
||||
const tag = hn.find((v) => hasClass(v, 'dtag-standin'));
|
||||
assert.equal(tag?.props.children, '替补');
|
||||
assert.equal(hn.find((v) => hasClass(v, 'dhead__name')).props.children, 'Sharp', 'the stand-in\'s name');
|
||||
assert.equal(hn.find((v) => hasClass(v, 'dhead__for')).props.children, '银灰的替补', 'the replaced operator, under the name [ASSUMED]');
|
||||
assert.ok(srcsOf(head).some((x) => x.includes('char_609_acguad_1')), 'the stand-in\'s portrait');
|
||||
assert.ok(textOf(head).includes(sharp().subProfessionName), 'the stand-in\'s class');
|
||||
// the chess's bonds (chips) and 特质 (garrison block) still apply
|
||||
const chips = hn.find((v) => v?.type && Array.isArray(v.props?.bondIds));
|
||||
assert.deepEqual(chips.props.bondIds, getChess(SILVER).bonds);
|
||||
const garrison = blocks.find((b) => b.key === 'garrison');
|
||||
assert.equal(garrison?.props.garrison?.garrisonId ?? garrison?.props.garrison?.id ?? null, getChess(SILVER).garrisonIds[0], 'the chess\'s 特质');
|
||||
const skill = blocks.find((b) => b.key === 'skill');
|
||||
assert.ok(JSON.stringify(skill.props).includes(r.standIn.skill.name), 'the stand-in\'s skill');
|
||||
const talents = blocks.find((b) => b.key === 'talents');
|
||||
for (const t of r.standIn.talents.filter((x) => x.name && !x.hidden)) assert.ok(JSON.stringify(talents.props).includes(t.name), `talent ${t.name}`);
|
||||
assert.ok(!JSON.stringify(blocks).includes(getChess(SILVER).skill.name), 'not the replaced operator\'s skill');
|
||||
// an owned chess: its own name and English name
|
||||
const plain = ChessDetail({ chess: getChess(SILVER), piece, editable: false, bonds: [], loadout: null, standIn: null });
|
||||
const ph = [...walk(plain.find((b) => b.key === 'head'))];
|
||||
assert.equal(ph.find((v) => hasClass(v, 'dhead__name')).props.children, '银灰');
|
||||
assert.equal(ph.find((v) => hasClass(v, 'dhead__for')), undefined);
|
||||
});
|
||||
|
||||
test('own prep pieces with stand-ins get a tag (hand, temp, board); others none', () => {
|
||||
test('own prep pieces with stand-ins get a small 「替补」 tag (hand, temp, board); others none', () => {
|
||||
const p = {
|
||||
standIns: [SILVER],
|
||||
hand: [{ uid: 1, kind: 'chess', id: SILVER }, { uid: 2, kind: 'chess', id: CATHY }, null],
|
||||
temp: [{ uid: 3, kind: 'chess', id: getChess(SILVER).goldenId }],
|
||||
board: [{ uid: 4, kind: 'chess', id: SILVER, row: 10, col: 4 }, { uid: 5, kind: 'token', id: 'x', row: 9, col: 4 }],
|
||||
};
|
||||
assert.deepEqual(standInPieces(p, getChess, BACKUPS).map((x) => [x.uid, x.area, x.label]), [[1, 'hand', '替补:Sharp'], [3, 'temp', '替补:Sharp'], [4, 'board', '替补:Sharp']]);
|
||||
assert.deepEqual(standInPieces(p, getChess, BACKUPS).map((x) => [x.uid, x.area, x.label, x.name]), [[1, 'hand', '替补', 'Sharp'], [3, 'temp', '替补', 'Sharp'], [4, 'board', '替补', 'Sharp']]);
|
||||
assert.match(standInPieces(p, getChess, BACKUPS)[0].tip, /未持有银灰/);
|
||||
assert.deepEqual(standInPieces({ ...p, standIns: [] }, getChess, BACKUPS), []);
|
||||
});
|
||||
|
||||
test('the board model of a dropped chess is the stand-in\'s (render/app.js pieceInfo reads m.private.standIns; the view is rebuilt when its model changes)', () => {
|
||||
test('bond popups: the own not-owned member and a teammate\'s stand-in unit are drawn as the stand-in (mark, name), their rows open the stand-in', () => {
|
||||
const bond = getChess(SILVER).bonds[0]; // 谢拉格
|
||||
const own = { ...priv, board: [{ uid: 4, kind: 'chess', id: SILVER, row: 10, col: 4 }] };
|
||||
assert.equal(G.memberStandIn(own, getChess(SILVER), BACKUPS, getChess)?.charId, SHARP, 'own: by m.private.standIns');
|
||||
assert.equal(G.memberStandIn({ ...own, standIns: [] }, getChess(SILVER), BACKUPS, getChess), null);
|
||||
const seen = [];
|
||||
const pop = expand(BondPopup({ bondId: bond, entry: { count: 1, active: false, tier: 0, layers: 0 }, priv: own, onClose() {}, onMember: (...a) => seen.push(a) }));
|
||||
const row = [...walk(pop)].find((v) => hasClass(v, 'bpop__member') && v.props['data-standin'] === SHARP);
|
||||
assert.ok(row, 'the member row is the stand-in');
|
||||
assert.ok(hasClass(row, 'is-on'), 'on the board');
|
||||
assert.equal(textOf([...walk(row)].find((v) => hasClass(v, 'bpop__mname'))), 'Sharp');
|
||||
assert.ok([...walk(row)].some((v) => hasClass(v, 'uthumb__si')), 'the small 「替补」 mark on the thumbnail');
|
||||
assert.ok(srcsOf(row).some((x) => x.includes('char_609_acguad')), 'the stand-in\'s avatar');
|
||||
row.props.onClick();
|
||||
assert.deepEqual(seen.at(-1), [SILVER, null, getChess(SILVER).charId]);
|
||||
// a watched teammate: their unit says it is a stand-in (UnitInfo standInFor → ownerBoard); the viewer's list does not count
|
||||
const field = { units: [{ kind: 'op', ownerId: 'p_1', defId: SILVER, standInFor: getChess(SILVER).charId }, { kind: 'op', ownerId: 'p_1', defId: SARIA }] };
|
||||
const mate = ownerBoard(field, 'p_1');
|
||||
assert.equal(mate.board[0].standInFor, getChess(SILVER).charId);
|
||||
assert.equal(mate.board[1].standInFor, undefined);
|
||||
assert.equal(G.memberStandIn(mate, getChess(SILVER), BACKUPS, getChess)?.charId, SHARP);
|
||||
assert.equal(G.memberStandIn(mate, getChess(SARIA), BACKUPS, getChess), null, 'a teammate\'s own 塞雷娅 stays 塞雷娅');
|
||||
const mpop = expand(BondPopup({ bondId: bond, entry: { count: 1, active: false, tier: 0, layers: 0 }, priv: mate, owner: 'P1', onClose() {} }));
|
||||
assert.ok([...walk(mpop)].some((v) => hasClass(v, 'bpop__member') && v.props['data-standin'] === SHARP), 'a teammate\'s stand-in row');
|
||||
});
|
||||
|
||||
test('result lineup: a lineup entry with standInFor draws the stand-in with the mark; the elite too; others the chess', () => {
|
||||
const gd = { chess: getChess, backups: BACKUPS };
|
||||
const th = expand(LineupThumb({ u: { id: getChess(SILVER).goldenId, golden: true, tier: 4, standInFor: getChess(SILVER).charId }, gd }));
|
||||
assert.ok([...walk(th)].some((v) => hasClass(v, 'uthumb__si') && v.props['data-standin'] === SHARP), 'the mark');
|
||||
assert.ok(srcsOf(th).some((x) => x.includes('char_609_acguad')), 'the stand-in\'s avatar');
|
||||
assert.equal(th.props.title, 'Sharp(银灰的替补)');
|
||||
const plain = expand(LineupThumb({ u: { id: SILVER, tier: 4 }, gd }));
|
||||
assert.ok(![...walk(plain)].some((v) => hasClass(v, 'uthumb__si')));
|
||||
assert.equal(plain.props.title, '银灰');
|
||||
});
|
||||
|
||||
test('equip-replace dialog: the operator is named and drawn as the stand-in it shows', () => {
|
||||
const items = [{ uid: 11, id: 'a' }, { uid: 12, id: 'b' }];
|
||||
const target = { uid: 1, kind: 'chess', id: SILVER, items };
|
||||
const ctx = { pieces: new Map([[1, { piece: target }], [3, { piece: { uid: 3, kind: 'item', id: 'c' } }]]) };
|
||||
const intent = { t: 'g.equip', confirmReplace: true, fields: { itemUid: 3, targetUid: 1 } };
|
||||
const shown = (id) => G.ownStandIn(getChess(id), priv, BACKUPS) || getChess(id);
|
||||
const req = replaceRequest(ctx, intent, shown, () => null);
|
||||
assert.equal(req.targetName, 'Sharp');
|
||||
assert.equal(req.targetRec?.charId, SHARP);
|
||||
assert.equal(replaceRequest(ctx, intent, getChess, () => null).targetRec, null, 'owned: the data\'s record');
|
||||
});
|
||||
|
||||
test('the model of a dropped chess is the stand-in\'s in the hand, the 临时整备区 and on the board (render/app.js pieceInfo reads m.private.standIns)', () => {
|
||||
const src = readFileSync(path.join(ROOT, 'public/js/render/app.js'), 'utf8');
|
||||
assert.match(src, /area === 'board' && chess && standInList\.includes\(chess\.baseId \|\| chess\.chessId\) \? data\.standIn\(piece\.id\) : null/);
|
||||
assert.match(src, /const si = chess && standInList\.includes\(chess\.baseId \|\| chess\.chessId\) \? data\.standIn\(piece\.id\) : null;/);
|
||||
assert.doesNotMatch(src, /area === 'board' && chess && standInList/, 'not only on the board');
|
||||
assert.match(src, /standInList = Array\.isArray\(src\.standIns\)/);
|
||||
assert.match(src, /const sig = `\$\{info\.kind\}\|\$\{info\.defId\}\|\$\{info\.golden \? 1 : 0\}\|\$\{info\.spine \|\| ''\}`/);
|
||||
assert.match(readFileSync(path.join(ROOT, 'public/js/render/app/info.js'), 'utf8'), /standInFor: typeof u\.standInFor === 'string'/);
|
||||
// the DOM fallback field draws every own piece of it with the stand-in's avatar and name
|
||||
const ff = readFileSync(path.join(ROOT, 'public/js/ui/fallbackField.js'), 'utf8');
|
||||
assert.match(ff, /chessAvatarUrl\(mm, ownSi\(chess\) \|\| ownDiy\(chess\) \|\| chess\)/);
|
||||
assert.match(ff, /\(ownSi\(chess\) \|\| ownDiy\(chess\) \|\| chess\)\?\.name/);
|
||||
});
|
||||
});
|
||||
+45
-24
@@ -1,15 +1,18 @@
|
||||
// Real-server browser E2E of 0.2.0 补位 end to end (the approved plan, owner's decision 2026-10-05): the 干员持有 tab of
|
||||
// the 干员调配 overlay marks 银灰 as not owned (real clicks; persisted in localStorage; room.ownership → session → seat →
|
||||
// the next match's m.private.standIns); in the match the shop card keeps 银灰 (name, price) with 「替补:Sharp」; bought,
|
||||
// the hand piece carries the tag; deployed, the board model is Sharp's; the detail card shows Sharp's skill under
|
||||
// 银灰's name; and in the browser's own battle (client-side combat) the unit is the stand-in — the b.start spec marks it
|
||||
// `standIn: true`, the local sim fields char_609_acguad with its kit, and the battle view draws Sharp's Spine model.
|
||||
// Real-server browser E2E of 0.2.0 补位 end to end (the approved plan, owner's decision 2026-10-05; the display follows
|
||||
// the owner's recall of the official mode, 2026-10-06): the 干员持有 tab of the 干员调配 overlay marks 银灰 as not owned
|
||||
// (real clicks; persisted in localStorage; room.ownership → session → seat → the next match's m.private.standIns); in
|
||||
// the match the shop card shows the stand-in Sharp (name, portrait, a small 「替补」 mark; 银灰's bonds and price);
|
||||
// bought, the hand piece is Sharp's model with the 「替补」 tag; deployed, the board model is Sharp's and the detail
|
||||
// card shows Sharp (name, skill) with 「银灰的替补」; in the browser's own battle (client-side combat) the unit is the
|
||||
// stand-in — the b.start spec marks it `standIn: true`, the local sim fields char_609_acguad with its kit, and the
|
||||
// battle view draws Sharp's Spine model; the match then ends (the fast server's finishAfter hook) and the result
|
||||
// screen's lineup shows Sharp with the 「替补」 mark.
|
||||
//
|
||||
// SP_E2E=1 node --test test/ui/standin.e2e.test.js
|
||||
//
|
||||
// The server is test/e2e/fastServer.mjs with its starter-kit hooks (no starter operators, 银灰 in the first shop slot,
|
||||
// 20 funds); everything else is the real match engine and the real UI driven by real mouse input.
|
||||
// Screenshots: test/e2e/out/standin-*.png.
|
||||
// 20 funds) and SP_FINISH_AFTER 1 (the match ends once round 1 has settled); everything else is the real match engine
|
||||
// and the real UI driven by real mouse input. Screenshots: test/e2e/out/standin-*.png.
|
||||
import { test, describe } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { existsSync } from 'node:fs';
|
||||
@@ -21,8 +24,8 @@ const SILVER = 'chess_char_4_22_a'; // 银灰 (NORMAL) → Sharp (char_609_acgua
|
||||
const SHARP = 'char_609_acguad';
|
||||
|
||||
describe('0.2.0 补位 — a not-owned operator fights as its stand-in (real server)', { skip: !ENABLED && 'set SP_E2E=1 (Chrome + public/assets)' }, () => {
|
||||
test('干员持有: drop 银灰 → buy it (badge) → deploy (Sharp model, Sharp card) → the local battle fields Sharp', { timeout: 6 * 60 * 1000 }, async () => {
|
||||
const srv = await startRealServer({ fast: { timerScale: 0.5, combatSpeed: 2, startRound: 1, kit: 0, shop: [SILVER] } });
|
||||
test('干员持有: drop 银灰 → the shop shows Sharp → buy (the hand shows Sharp) → deploy (Sharp card) → the local battle fields Sharp → the result shows Sharp', { timeout: 6 * 60 * 1000 }, async () => {
|
||||
const srv = await startRealServer({ fast: { timerScale: 0.5, combatSpeed: 2, startRound: 1, kit: 0, shop: [SILVER], finishAfter: 1 } });
|
||||
const P = (await import('puppeteer-core')).default;
|
||||
const c = new Client(P, srv.base, 'standin', { prefix: 'standin' });
|
||||
try {
|
||||
@@ -62,28 +65,34 @@ describe('0.2.0 补位 — a not-owned operator fights as its stand-in (real ser
|
||||
await c.waitFor((s) => s.phase === 'PREP' && !s.ready && s.funds >= 3, 'prep with funds', 60000);
|
||||
await sleep(1800); // camera flight
|
||||
|
||||
// 3) the shop card: 银灰 with 「替补:Sharp」; two taps buy it
|
||||
// 3) the shop card shows the stand-in: Sharp (name, portrait) with a small 「替补」 mark, 银灰's bond; two taps buy it
|
||||
await c.page.waitForSelector('.scard .scard__standin', { visible: true, timeout: 10000 });
|
||||
const shopCard = await c.page.evaluate(() => {
|
||||
const el = document.querySelector('.scard .scard__standin')?.closest('.scard');
|
||||
return el ? { name: el.querySelector('.scard__name')?.textContent, badge: el.querySelector('.scard__standin')?.textContent, standIn: el.querySelector('.scard__standin')?.dataset.standin } : null;
|
||||
return el ? {
|
||||
name: el.querySelector('.scard__name')?.textContent, badge: el.querySelector('.scard__standin')?.textContent,
|
||||
standIn: el.querySelector('.scard__standin')?.dataset.standin, for: el.querySelector('.scard__standin')?.dataset.for,
|
||||
art: el.querySelector('.scard__art')?.getAttribute('src') || '', bonds: [...el.querySelectorAll('.scard__bond > span:last-child')].map((b) => b.textContent),
|
||||
} : null;
|
||||
});
|
||||
assert.deepEqual(shopCard, { name: '银灰', badge: '替补:Sharp', standIn: SHARP });
|
||||
assert.ok(shopCard, 'the stand-in card');
|
||||
assert.deepEqual([shopCard.name, shopCard.badge, shopCard.standIn, shopCard.for], ['Sharp', '替补', SHARP, SILVER]);
|
||||
assert.match(shopCard.art, /char_609_acguad/, 'Sharp\'s portrait');
|
||||
assert.deepEqual(shopCard.bonds, ['谢拉格'], '银灰\'s bond still counts');
|
||||
await c.shot('shop');
|
||||
await c.click('.scard', '银灰');
|
||||
await c.click('.scard', 'Sharp');
|
||||
await sleep(300);
|
||||
await c.click('.scard', '银灰');
|
||||
await c.click('.scard', 'Sharp');
|
||||
await c.waitFor((s) => s.hand > 0, 'bought', 8000);
|
||||
await sleep(900);
|
||||
const [piece] = (await c.handPieces('chess')).filter((p) => p.id === SILVER);
|
||||
assert.ok(piece, '银灰 is in the hand');
|
||||
// the hand piece keeps 银灰's model, tagged 「替补:Sharp」
|
||||
await c.page.waitForFunction((uid) => document.querySelector(`.sitag[data-uid="${uid}"]`)?.textContent === '替补:Sharp', { timeout: 5000 }, piece.uid);
|
||||
const handModel = await c.page.evaluate((uid) => globalThis.__SP_VIEW__?.raw?.debug?.views?.get(`p:${uid}`)?.info?.spine ?? null, piece.uid);
|
||||
assert.notEqual(handModel, SHARP, 'the hand shows the original operator');
|
||||
assert.ok(piece, 'the 银灰 piece is in the hand');
|
||||
// the hand piece is Sharp's model, tagged 「替补」
|
||||
await c.page.waitForFunction((uid) => document.querySelector(`.sitag[data-uid="${uid}"]`)?.textContent === '替补', { timeout: 5000 }, piece.uid);
|
||||
await c.page.waitForFunction((uid, sharp) => globalThis.__SP_VIEW__?.raw?.debug?.views?.get(`p:${uid}`)?.info?.spine === sharp, { timeout: 5000 }, piece.uid, SHARP);
|
||||
await c.shot('hand');
|
||||
|
||||
// 4) deploy it (drag → direction wheel): the board model becomes Sharp's; the card shows Sharp's body
|
||||
// 4) deploy it (drag → direction wheel): the board model stays Sharp's; the card shows Sharp, 「银灰的替补」
|
||||
const tile = await c.freeTileFor(piece.uid);
|
||||
assert.ok(tile, 'a legal tile');
|
||||
await c.drag(await c.piecePoint(piece.uid), await c.tilePoint(tile.row, tile.col));
|
||||
@@ -103,12 +112,13 @@ describe('0.2.0 补位 — a not-owned operator fights as its stand-in (real ser
|
||||
detail = await c.page.waitForSelector('.dpanel .dtag-standin', { timeout: 2500 }).then(() => c.page.evaluate(() => ({
|
||||
tag: document.querySelector('.dpanel .dtag-standin')?.textContent || '',
|
||||
name: document.querySelector('.dpanel .dhead__name')?.textContent || '',
|
||||
for: document.querySelector('.dpanel .dhead__for')?.textContent || '',
|
||||
skill: document.querySelector('.dpanel .dskill')?.dataset.skill || '',
|
||||
})), () => null);
|
||||
if (detail) break;
|
||||
}
|
||||
assert.ok(detail, 'the detail card opens for the deployed piece');
|
||||
assert.deepEqual(detail, { tag: '替补:Sharp', name: '银灰', skill: 'skchr_acguad_2' });
|
||||
assert.deepEqual(detail, { tag: '替补', name: 'Sharp', for: '银灰的替补', skill: 'skchr_acguad_2' });
|
||||
await c.shot('board');
|
||||
await c.page.keyboard.press('Escape');
|
||||
|
||||
@@ -140,8 +150,19 @@ describe('0.2.0 补位 — a not-owned operator fights as its stand-in (real ser
|
||||
assert.match(got.model, /char_609_acguad/, 'the battle view draws Sharp\'s Spine model');
|
||||
await sleep(800);
|
||||
await c.shot('combat');
|
||||
// the battle ends and the round settles normally (the result upload is accepted)
|
||||
await c.waitFor((s) => (s.phase === 'PREP' && s.round >= 2) || !!s.result, 'next prep', 180000);
|
||||
// 6) the battle ends, the round settles (the result upload is accepted) and the match ends (finishAfter 1): the
|
||||
// result screen's lineup shows Sharp with the 「替补」 mark
|
||||
await c.waitFor((s) => !!s.result, 'result', 180000);
|
||||
await c.page.waitForSelector('.rcard.is-self .rcard__lineup .uthumb', { visible: true, timeout: 15000 });
|
||||
const lineup = await c.page.evaluate(() => [...document.querySelectorAll('.rcard.is-self .rcard__lineup .uthumb')].map((el) => ({
|
||||
title: el.getAttribute('title') || '', mark: el.querySelector('.uthumb__si')?.textContent || '',
|
||||
standIn: el.querySelector('.uthumb__si')?.dataset.standin || '', art: el.querySelector('img')?.getAttribute('src') || '',
|
||||
})));
|
||||
assert.equal(lineup.length, 1, 'the one operator fielded');
|
||||
assert.deepEqual([lineup[0].title, lineup[0].mark, lineup[0].standIn], ['Sharp(银灰的替补)', '替补', SHARP]);
|
||||
assert.match(lineup[0].art, /char_609_acguad/, 'Sharp\'s avatar');
|
||||
await sleep(400);
|
||||
await c.shot('result');
|
||||
assert.deepEqual(problemsOf([c]), []);
|
||||
} finally {
|
||||
if (c.problems.length) console.log(c.problems.slice(0, 20).join('\n'));
|
||||
|
||||
@@ -357,8 +357,8 @@ test('§20.15 wiring: the game screen feeds the strip, the popup and the detail
|
||||
assert.match(src, /onOpen=\$\{\(id\) => openBond\(id, strip\.ownerId, 'strip'\)\}/, 'the strip opens its owner\'s bond');
|
||||
assert.match(src, /const detailOwner = detailBondOwner\(detailTarget, \{ pub, myId, stripOwnerId: strip\.ownerId \}\)/);
|
||||
assert.match(src, /onBond=\$\{\(id\) => openBond\(id, detailOwner, 'detail'\)\}/, 'a card\'s chip opens the same owner\'s bond as its chips show');
|
||||
assert.match(src, /onMember=\$\{\(id, items\) => setDetail\(\{ kind: 'chess', id, owner: bondPop\.ownerId, items: items \|\| null \}\)\}/,
|
||||
'a member card keeps the popup\'s player (and a 变形同构体 row\'s items)');
|
||||
assert.match(src, /onMember=\$\{\(id, items, standInFor\) => setDetail\(\{ kind: 'chess', id, owner: bondPop\.ownerId, items: items \|\| null, standInFor: standInFor \|\| null \}\)\}/,
|
||||
'a member card keeps the popup\'s player (and a 变形同构体 row\'s items, a 补位 row\'s standInFor)');
|
||||
assert.match(src, /openId=\$\{bondPop && bondPop\.ownerId === strip\.ownerId \? bondPop\.bondId : null\}/, 'the strip marks only its own owner\'s popup');
|
||||
assert.match(src, /setBondOpen\(\(b\) => \(b && b\.from === 'strip' \? null : b\)\)/, 'a strip popup closes when the strip changes hands');
|
||||
assert.ok(!/stripBonds\.find\(\(b\) => b\.bondId === bondOpen\)/.test(src), 'no popup entry read from the strip regardless of its owner');
|
||||
|
||||
Reference in new issue
Block a user