feedback5 WG: 补位 in the browser — the 干员持有 tab next to 干员调配 (the 53 NORMAL chess by tier, operator → stand-in, a switch each, 全部持有, export / import, localStorage + room.ownership through the shared sync engine), 「替补:X」 on shop / reward cards and own prep pieces, the stand-in's model on the board, its body on the detail card (own pieces, cards, any unit with standInFor), placement and ranges by the stand-in
This commit is contained in:
1 parent
24205581e1
commit
4b57f8120c
23 files changed
+1227
-126
No files matched your search
@@ -90,6 +90,8 @@
|
||||
.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) */
|
||||
.dtag-standin { font-size: .13rem; font-weight: 700; padding: .01rem .08rem; letter-spacing: .04em; 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); }
|
||||
@@ -551,6 +553,12 @@ button.bpop__harmony:hover { border-style: solid; color: #fff; }
|
||||
.tempnote__rule { font-size: .12rem; line-height: 1.45; color: rgba(255, 220, 214, .88); }
|
||||
@media (prefers-reduced-motion: reduce) { .tempnote__frame polygon, .tempnote__label { animation: none; } }
|
||||
.sp-reduced-motion .tempnote__frame polygon, .sp-reduced-motion .tempnote__label { animation: none; }
|
||||
/* 0.2.0 补位: 「替补:X」 under the player's own prep pieces of a chess they do not own (screens/game/standInTags.js) */
|
||||
.sitags { position: fixed; inset: 0; z-index: 2; pointer-events: none; }
|
||||
.sitag { position: absolute; transform: translate(-50%, .02rem); pointer-events: none; white-space: nowrap; font-size: max(.12rem, 9px); font-weight: 700;
|
||||
line-height: 1.35; padding: 0 .05rem; letter-spacing: .03em; background: var(--ice); color: var(--ice-900); box-shadow: 0 1px 3px rgba(0, 0, 0, .7); opacity: .92; }
|
||||
.sitag--board { background: #c2e3ff; }
|
||||
.gm.is-dragging .sitags { display: none; }
|
||||
|
||||
/* ---- underframe: the selection diamond of a tapped own piece (ui/underframe.js): 撤退 / 出售 +N / 销毁 ----
|
||||
It sits above every HUD panel (detail card z 9, bond popup, drawer, shop bar): only its buttons take pointer events,
|
||||
|
||||
@@ -130,6 +130,9 @@
|
||||
.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); }
|
||||
.scard:not(.is-disabled):not(.scard--sold):hover { transform: translateY(-.08rem); border-color: var(--mint-400); box-shadow: 0 0 0 1px var(--mint-400), 0 .1rem .24rem rgba(23, 249, 183, .22); }
|
||||
.scard:not(.is-disabled):not(.scard--sold):active { transform: translateY(-.04rem); }
|
||||
.scard.is-disabled { cursor: not-allowed; }
|
||||
|
||||
@@ -162,6 +162,9 @@
|
||||
position: absolute; right: 0; top: 0; z-index: 1; width: .22rem; height: .22rem;
|
||||
background: linear-gradient(225deg, var(--mint-400) 50%, transparent 50%);
|
||||
}
|
||||
/* 0.2.0 补位: a chess marked 未持有 on the 干员持有 tab (its stand-in fights with a fixed skill) */
|
||||
.lo-card__sub { position: absolute; right: .03rem; top: .26rem; z-index: 1; padding: 0 .04rem; font-size: max(.11rem, 8px); font-weight: 700; line-height: 1.4; color: var(--ice-900); background: var(--ice); }
|
||||
.lo-locknote--standin { color: var(--ice); }
|
||||
.lo-card__name {
|
||||
position: absolute; left: .05rem; right: .05rem; top: calc(100% - max(.64rem, 34px)); z-index: 1;
|
||||
font-size: max(.15rem, 10px); font-weight: 700; line-height: 1.2; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
@@ -326,6 +329,62 @@
|
||||
.lo-locknote { display: flex; align-items: center; gap: .06rem; margin: .16rem 0 0; font-size: max(.14rem, 10px); color: var(--amber); }
|
||||
.lo-locknote .icon { width: .16rem; height: .16rem; }
|
||||
|
||||
/* ---- tabs: 干员调配 / 干员持有 (0.2.0 补位) ---------------------------------------------------------------------------- */
|
||||
.lo-tabs { display: flex; align-items: stretch; gap: .04rem; }
|
||||
.lo-tab {
|
||||
display: inline-flex; align-items: center; gap: .08rem; padding: .02rem .16rem; font: inherit; font-size: max(.24rem, 13px); font-weight: 900;
|
||||
letter-spacing: .12em; line-height: 1.2; color: var(--text-lo); background: transparent; border: 0; border-bottom: 2px solid transparent; cursor: pointer;
|
||||
touch-action: manipulation; white-space: nowrap;
|
||||
}
|
||||
.lo-tab .lo-top__icon { width: max(.24rem, 14px); height: max(.24rem, 14px); }
|
||||
.lo-tab:hover { color: var(--text-hi); }
|
||||
.lo-tab.is-on { color: var(--text-hi); border-bottom-color: var(--mint-500); }
|
||||
.lo-tab__n { display: inline-grid; place-items: center; min-width: 1.5em; height: 1.5em; padding: 0 .3em; font-size: .62em; font-weight: 700; letter-spacing: 0; color: var(--ice-900); background: var(--ice); border-radius: 1em; }
|
||||
|
||||
/* ---- 干员持有 (screens/ownership.js): the NORMAL chess by tier, each operator → its stand-in, a 持有 switch -------------- */
|
||||
.own { flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; gap: .12rem; padding: .16rem .3rem .2rem; }
|
||||
.own__bar { flex: none; display: flex; align-items: center; gap: .2rem; }
|
||||
.own__lead { flex: 1; margin: 0; font-size: max(.15rem, 10px); line-height: 1.5; color: var(--text-md); }
|
||||
.own__lead b { color: var(--ice); font-weight: 700; }
|
||||
.own__list { flex: 1 1 auto; min-height: 0; overflow: auto; -webkit-overflow-scrolling: touch; overscroll-behavior: contain; display: flex; flex-direction: column; gap: .18rem; padding: 0 .06rem .2rem 0;
|
||||
scrollbar-width: thin; scrollbar-color: var(--line-3) transparent; }
|
||||
.own-tier__head { display: flex; align-items: center; gap: .08rem; margin: 0 0 .08rem; font-size: max(.17rem, 12px); font-weight: 700; color: var(--text-md); letter-spacing: .06em; }
|
||||
.own-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(max(4.4rem, 270px), 1fr)); gap: .08rem; }
|
||||
.own-card {
|
||||
display: grid; grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr) auto; align-items: center; gap: .08rem; min-width: 0;
|
||||
padding: .07rem .1rem .07rem .07rem; font: inherit; color: var(--text-hi); text-align: left; cursor: pointer; touch-action: manipulation;
|
||||
background: rgba(255, 255, 255, .03); border: 1px solid var(--line); border-left: 3px solid var(--tc, var(--line-3));
|
||||
transition: background var(--t-fast), border-color var(--t-fast);
|
||||
}
|
||||
.own-card--t1 { --tc: var(--tier-1); } .own-card--t2 { --tc: var(--tier-2); } .own-card--t3 { --tc: var(--tier-3); }
|
||||
.own-card--t4 { --tc: var(--tier-4); } .own-card--t5 { --tc: var(--tier-5); } .own-card--t6 { --tc: var(--tier-6); }
|
||||
.own-card:hover { background: rgba(255, 255, 255, .06); border-color: var(--line-3); }
|
||||
.own-card__op, .own-card__sub { display: flex; align-items: center; gap: .07rem; min-width: 0; }
|
||||
.own-card__ava { flex: none; display: grid; place-items: center; width: max(.46rem, 28px); height: max(.46rem, 28px); overflow: hidden; background: #0a0d0c; border: 1px solid var(--line-2); }
|
||||
.own-card__ava img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.own-card__ava b { font-size: max(.18rem, 12px); color: var(--text-lo); }
|
||||
.own-card__txt { display: flex; flex-direction: column; min-width: 0; }
|
||||
.own-card__name { font-size: max(.16rem, 11px); font-weight: 700; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.own-card__txt small { font-size: max(.12rem, 9px); color: var(--text-lo); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.own-card__arrow .icon { width: max(.18rem, 12px); height: max(.18rem, 12px); color: var(--text-dim); }
|
||||
.own-card__sub { opacity: .55; }
|
||||
.own-card__state { display: inline-flex; flex-direction: column; align-items: center; gap: .03rem; min-width: max(.5rem, 30px); font-size: max(.12rem, 9px); font-weight: 700; color: var(--mint-400); white-space: nowrap; }
|
||||
.own-switch { position: relative; display: inline-block; width: max(.36rem, 24px); height: max(.2rem, 13px); border-radius: 1em; background: var(--mint-700); }
|
||||
.own-switch > i { position: absolute; top: 2px; right: 2px; width: calc(max(.2rem, 13px) - 4px); height: calc(max(.2rem, 13px) - 4px); border-radius: 50%; background: #fff; transition: right var(--t-fast), left var(--t-fast); }
|
||||
/* 未持有: the operator greys out, the stand-in (who is deployed) lights up */
|
||||
.own-card.is-off { background: rgba(159, 212, 255, .07); border-color: rgba(159, 212, 255, .35); }
|
||||
.own-card.is-off .own-card__op { opacity: .5; filter: grayscale(.8); }
|
||||
.own-card.is-off .own-card__sub { opacity: 1; }
|
||||
.own-card.is-off .own-card__sub .own-card__ava { border-color: var(--ice); }
|
||||
.own-card.is-off .own-card__state { color: var(--ice); }
|
||||
.own-card.is-off .own-switch { background: var(--bg-4); }
|
||||
.own-card.is-off .own-switch > i { right: auto; left: 2px; background: var(--ice); }
|
||||
@media (max-width: 760px) {
|
||||
.own { padding: .1rem .16rem .16rem; }
|
||||
.own__lead { display: none; }
|
||||
.lo-tab { padding: .02rem .08rem; letter-spacing: .04em; }
|
||||
}
|
||||
|
||||
/* ---- entry button (lobby / room / briefing) --------------------------------------------------------------------------- */
|
||||
.lo-entry { position: relative; gap: .08rem; }
|
||||
.lo-entry__icon { width: 1.25em; height: 1.25em; object-fit: contain; flex: none; }
|
||||
@@ -333,6 +392,8 @@
|
||||
display: inline-grid; place-items: center; min-width: 1.5em; height: 1.5em; padding: 0 .3em; margin-left: .02rem;
|
||||
font-size: .8em; font-weight: 700; color: var(--text-on-mint); background: var(--mint-500); border-radius: 1em;
|
||||
}
|
||||
/* 0.2.0 补位: operators marked 未持有 on the 干员持有 tab */
|
||||
.lo-entry__own { display: inline-flex; align-items: center; gap: .2em; height: 1.5em; padding: 0 .4em; font-size: .75em; font-weight: 700; color: var(--ice-900); background: var(--ice); border-radius: 1em; white-space: nowrap; }
|
||||
|
||||
/* Untimed phases (user playtest #11 / #4 item 3: the official 独立模拟 shows no countdown outside combat — the server
|
||||
sends deadline 0 for them, Match.soloUntimed): ui/components.js Countdown now renders nothing without a deadline;
|
||||
|
||||
@@ -33,6 +33,8 @@ export const DATA_FILES = Object.freeze({
|
||||
choices: 'choices.json',
|
||||
config: 'config.json',
|
||||
assets: 'assets.json',
|
||||
// 补位 stand-ins (DATA.md §18): the bodies the 干员持有 screen, the cards and the detail card compose (shared/standIn.js)
|
||||
backups: 'backups.json',
|
||||
// Optional art extracted from a local game client (DESIGN §13): { groups: { '<subdir>': { name: { path, w, h } } } }.
|
||||
// The emotes and the 玩法说明 pages are in data/assets.json too (downloaded from the mirror): artUrls().
|
||||
local: 'local-assets.json',
|
||||
|
||||
+4
-2
@@ -20,7 +20,8 @@
|
||||
// Multi-device support (ui/device.js + css/devices.css): feature classes on <html>, no page zoom, safe areas, rotation
|
||||
// re-layout; ui/compat.js polyfills are imported before anything else.
|
||||
// 干员调配 (DESIGN §16): an overlay over any route (<LoadoutHost/>, opened from lobby / room / briefing); its loadout is
|
||||
// kept in sync with the server by installLoadoutSync (room.loadout after every welcome and edit).
|
||||
// kept in sync with the server by installLoadoutSync (room.loadout after every welcome and edit), its 干员持有 tab's
|
||||
// not-owned list (0.2.0 补位) by installOwnershipSync (room.ownership, likewise).
|
||||
// Game data: every text of the game is static data (/data/*.json) downloaded once per page; the in-match files are
|
||||
// warmed in the background as soon as the player is in a room (warmGameData), before the match needs them.
|
||||
|
||||
@@ -44,7 +45,7 @@ import { settingsStore } from './ui/settings.js';
|
||||
import { GuideHost } from './ui/guide.js';
|
||||
import { installDeviceSupport } from './ui/device.js';
|
||||
import { LoadoutHost } from './screens/loadout.js';
|
||||
import { installLoadoutSync } from './ui/loadoutSync.js';
|
||||
import { installLoadoutSync, installOwnershipSync } from './ui/loadoutSync.js';
|
||||
import { startBuildGuard } from './ui/buildGuard.js';
|
||||
|
||||
const RESTORE_GRACE_MS = 1500;
|
||||
@@ -329,6 +330,7 @@ async function boot() {
|
||||
|
||||
wireNet();
|
||||
installLoadoutSync({ net });
|
||||
installOwnershipSync({ net });
|
||||
net.attachBrowserHooks();
|
||||
// Audio: unlock on first gesture, BGM follows the route / match phase (js/audio.js).
|
||||
installAudio({ getManifest: () => data.get('assets'), subscribe: store.subscribe, getState: store.get, selectRoute, settings: settingsStore.get() });
|
||||
|
||||
+15
-5
@@ -275,6 +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 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)
|
||||
let leaderHidden = true; // shown only by the boss-field prep camera (leaderShown)
|
||||
@@ -285,7 +286,9 @@ export async function createFieldView(host, options = {}) {
|
||||
cam: () => cam, heightAt,
|
||||
animRate: () => (mode === 'battle' ? interp.rate : 1),
|
||||
timeScale: () => (mode === 'battle' ? interp.rate : 1),
|
||||
lookupDef: (info) => (info.side === 'enemy' ? data.enemy(info.defId) : data.chess(info.defId) || data.token(info.defId)),
|
||||
// (a chess fighting as its 补位 stand-in — `standInFor` — reads the stand-in's record: its attack interval)
|
||||
lookupDef: (info) => (info.side === 'enemy' ? data.enemy(info.defId)
|
||||
: (info.standInFor && data.standIn(info.defId)) || data.chess(info.defId) || data.token(info.defId)),
|
||||
crowded: () => views.size > 90,
|
||||
renderer: app.renderer,
|
||||
frameNo: () => frameNo,
|
||||
@@ -301,7 +304,7 @@ export async function createFieldView(host, options = {}) {
|
||||
timeScale: () => ctx.timeScale(),
|
||||
loadLevel: () => loadLevel,
|
||||
fieldRect: () => (mode === 'battle' && battleMeta ? battleMeta.rect : null),
|
||||
subProfOf: (defId) => data.chess(defId)?.subProfessionId || null,
|
||||
subProfOf: (defId, info = null) => ((info && info.standInFor && data.standIn(defId)) || data.chess(defId))?.subProfessionId || null,
|
||||
view: (id) => views.get(id) || null,
|
||||
screenSize: size,
|
||||
fieldTop: () => {
|
||||
@@ -626,11 +629,16 @@ export async function createFieldView(host, options = {}) {
|
||||
const rec = data.token(piece.id);
|
||||
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 rec = data.chess(piece.id);
|
||||
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;
|
||||
const rec = si || chess;
|
||||
return {
|
||||
kind: 'op', side: 'ally', defId: piece.id,
|
||||
spine: rec?.assets?.spine || rec?.charId || null, avatar: rec?.assets?.avatar || rec?.charId || null,
|
||||
tier: rec?.tier || piece.tier || 1, golden: !!(piece.golden || rec?.isGolden), dir,
|
||||
tier: chess?.tier || piece.tier || 1, golden: !!(piece.golden || chess?.isGolden), dir,
|
||||
...(si ? { standInFor: si.standInFor } : null),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -678,6 +686,7 @@ export async function createFieldView(host, options = {}) {
|
||||
});
|
||||
};
|
||||
const src = ps && typeof ps === 'object' ? ps : {};
|
||||
standInList = Array.isArray(src.standIns) ? src.standIns.filter((x) => typeof x === 'string') : [];
|
||||
ownPen = Array.isArray(src.nextEnemies) ? src.nextEnemies : null;
|
||||
setPenList(ownPen);
|
||||
addList(src.hand, 'hand');
|
||||
@@ -705,7 +714,8 @@ export async function createFieldView(host, options = {}) {
|
||||
const key = 'p:' + e.uid;
|
||||
e.key = key;
|
||||
const info = pieceInfo(e.piece, e.area);
|
||||
const sig = `${info.kind}|${info.defId}|${info.golden ? 1 : 0}`;
|
||||
// (the model is part of it: a 补位 chess changes body between the bench and the board — pieceInfo)
|
||||
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; }
|
||||
const w = slotWorld(e);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// public/js/render/app/host.js — asset lookup, data lookup and GL teardown for one field view.
|
||||
|
||||
import { createAssets, assets as defaultAssets } from '../../assets.js';
|
||||
import { standInRecord } from '../../../../shared/standIn.js';
|
||||
|
||||
function withTimeout(p, ms) {
|
||||
return Promise.race([p, new Promise((resolve) => setTimeout(resolve, ms))]);
|
||||
@@ -24,9 +25,25 @@ function makeData(src) {
|
||||
} catch { /* ignore */ }
|
||||
return null;
|
||||
};
|
||||
// 0.2.0 补位: the composed stand-in record of a chess (shared/standIn.js over data/backups.json), cached per chess
|
||||
const standIns = new Map();
|
||||
const backups = () => {
|
||||
try { return (src && typeof src.get === 'function' ? src.get('backups') : src && src.backups) || null; } catch { return null; }
|
||||
};
|
||||
const standIn = (id) => {
|
||||
const c = look('chess', id);
|
||||
const b = backups();
|
||||
if (!c || !b) return null;
|
||||
if (standIns.has(c)) return standIns.get(c);
|
||||
let rec;
|
||||
try { rec = standInRecord(c, b); } catch { rec = null; }
|
||||
standIns.set(c, rec);
|
||||
return rec;
|
||||
};
|
||||
return {
|
||||
chess: (id) => look('chess', id), token: (id) => look('tokens', id), item: (id) => look('items', id),
|
||||
enemy: (id) => look('enemies', id), stage: (id) => look('stages', id), bond: (id) => look('bonds', id),
|
||||
standIn,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -42,5 +42,8 @@ export function renderInfo(u) {
|
||||
// unit (resolveDetail `unitItems` → the read-only 装备 section and the 变形同构体 pairing chips); the owner's own
|
||||
// unit takes its items from the piece instead, so only other players' boards ever read this field
|
||||
items: Array.isArray(u.items) ? u.items.filter((x) => typeof x === 'string') : undefined,
|
||||
// 0.2.0 补位: the replaced operator's charId of a chess fighting as its stand-in (UnitInfo.standInFor; spine / avatar /
|
||||
// name are already the stand-in's) — the detail card and the view's data lookups (attack interval, splash FX) follow it
|
||||
standInFor: typeof u.standInFor === 'string' && u.standInFor ? u.standInFor : undefined,
|
||||
};
|
||||
}
|
||||
@@ -59,7 +59,7 @@ export class FxNumbers {
|
||||
this.particle('glow', px, py, { tint, life: 0.18, s0: (s / 128) * (big ? 1.0 : 0.6), s1: (s / 128) * (big ? 1.5 : 0.9), a0: 0.9, a1: 0 });
|
||||
if (melee) { this._slashAt = null; this._slash(view, srcView, px, py, s, style, big); }
|
||||
this.burst(px, py, s, big ? 8 : melee ? 6 : 4, tint, { speed: melee ? 2.8 : 2.4, size: big ? 0.6 : 0.46 });
|
||||
if (srcView && this.ctx.subProfOf && SPLASH_SUBS.has(this.ctx.subProfOf(srcView.info?.defId))) {
|
||||
if (srcView && this.ctx.subProfOf && SPLASH_SUBS.has(this.ctx.subProfOf(srcView.info?.defId, srcView.info))) {
|
||||
if (!this._lastRing || this.time - this._lastRing > 0.08) {
|
||||
this._lastRing = this.time;
|
||||
this.ring(view.x, view.y, view.z || 0, 0.1, 1.1, tint, 0.3);
|
||||
|
||||
@@ -86,6 +86,7 @@ import { openGuide } from '../ui/guide.js';
|
||||
import { actions } from '../ui/gameActions.js';
|
||||
import { FacingWheel, holdPiece, setPieceDir, syncPieceDirs, showRange, useTileScreen } from '../ui/facingWheel.js';
|
||||
import { Underframe, underframeRect, TempRowNotice } from '../ui/underframe.js';
|
||||
import { StandInTags } from './game/standInTags.js';
|
||||
import { needsFacing, facingIntent, previewGrid, pieceDir, underframeActions, retreatSlot, itemDestroyable } from '../ui/facing.js';
|
||||
import { EquipReplaceDialog, replaceRequest, replaceIntent } from '../ui/equipReplace.js';
|
||||
import { pauseAvailable, isPaused, frozenNow } from '../ui/matchStatus.js';
|
||||
@@ -94,7 +95,7 @@ import {
|
||||
phaseMode, phaseBanner, isCombatPhase, showDeadPill, isBossPhase, placementContext, canPlace, boardTargets, dropIntent,
|
||||
snapHud, activeBubbles, shortcutFor, shortcutBlocked, closesOnFieldPress, phaseTotalSeconds, homeFieldId, ownFieldId, normalizeSp, sortedPlayers,
|
||||
countdownState, shopBlockReason, stageOverrides, effectiveStage, watchTarget, dropFailureReason,
|
||||
previewEnemyKey, prepCamera, prepCameraFor, foldCamera, deployFieldOf, panelSide, panelSlots, bondPopupPlace, chessLoadout, unitLoadout,
|
||||
previewEnemyKey, prepCamera, prepCameraFor, foldCamera, deployFieldOf, panelSide, panelSlots, bondPopupPlace, unitLoadout, deployedRecord,
|
||||
mergeTarget, modeOffBonds, readyFundsPrompt, ownerBandId,
|
||||
} from '../ui/gameLogic.js';
|
||||
import { toast } from '../ui/toasts.js';
|
||||
@@ -237,7 +238,7 @@ function MatchScreen() {
|
||||
const deployField = deployFieldOf(pub, myId);
|
||||
const placeCtx = useMemo(() => placementContext({
|
||||
priv, stage: gd.stage(pub?.stageId), editable, field: deployField,
|
||||
getChess: gd.chess, getToken: gd.token, getItem: gd.item, getEffect: gd.effect,
|
||||
getChess: gd.chess, getToken: gd.token, getItem: gd.item, getEffect: gd.effect, backups: gd.backups,
|
||||
}), [priv, pub?.stageId, editable, gd.ready, deployField]);
|
||||
live.current = { pub, priv, field, editable, placeCtx, watching, watchWho, home, myId, detail, drawer, bondOpen, emoteOpen, settingsOpen, exitOpen, drag, facing, sel, selBusy, pen, collapsedNow: collapsed, localDone: false, canPause: false, paused };
|
||||
|
||||
@@ -718,7 +719,7 @@ function MatchScreen() {
|
||||
// the drop target is the tile under the pointer (render/drag.js; an item dropped on a unit's tile equips that unit —
|
||||
// user playtest #4 item 1); `tile` = the last target tile (tileHover), `released` = the pointer went up (not a cancel)
|
||||
const ptr = { released: false, tile: null };
|
||||
const lookups = { getChess: gd.chess, getToken: gd.token, getItem: gd.item, chessRecord: (rec) => chessLoadout(rec, live.current.priv?.loadout ?? null, gd.chess)?.record };
|
||||
const lookups = { getChess: gd.chess, getToken: gd.token, getItem: gd.item, chessRecord: (rec) => deployedRecord(rec, live.current.priv, gd.chess, gd.backups) };
|
||||
const runIntent = async (intent) => {
|
||||
const L = live.current;
|
||||
if (intent.confirmReplace) {
|
||||
@@ -886,9 +887,10 @@ function MatchScreen() {
|
||||
}, []);
|
||||
|
||||
// ---- direction step (research 09 §1.2) and the selected piece's underframe ------------------------------------
|
||||
// DESIGN §16: previews show the range the unit fights with under the player's loadout (an elite's module grid)
|
||||
// DESIGN §16: previews show the range the unit fights with under the player's loadout (an elite's module grid); a
|
||||
// chess the player does not own the range of its stand-in (0.2.0 补位, gameLogic deployedRecord)
|
||||
const lookups = useMemo(() => ({ getChess: gd.chess, getToken: gd.token, getItem: gd.item,
|
||||
chessRecord: (rec) => chessLoadout(rec, live.current.priv?.loadout ?? null, gd.chess)?.record }), [gd.ready]);
|
||||
chessRecord: (rec) => deployedRecord(rec, live.current.priv, gd.chess, gd.backups) }), [gd.ready]);
|
||||
const heldRef = useRef(new Map()); // uid → { row, col, t } committed placements awaiting m.private
|
||||
const releaseHold = useCallback((uid) => {
|
||||
heldRef.current.delete(uid);
|
||||
@@ -984,7 +986,7 @@ function MatchScreen() {
|
||||
const u = (Array.isArray(field?.units) ? field.units : []).find((x) => x && x.id === detail.unitId);
|
||||
return u ? { ...detail, unit: u } : detail;
|
||||
}, [detail, field]);
|
||||
const resolved = useMemo(() => resolveDetail(detailTarget, placeCtx.pieces), [detailTarget, placeCtx]);
|
||||
const resolved = useMemo(() => resolveDetail(detailTarget, placeCtx.pieces, { priv, backups: gd.backups }), [detailTarget, placeCtx, gd.ready]);
|
||||
useEffect(() => { if (detail && !resolved && detail.kind === 'piece') setDetail(null); }, [resolved]);
|
||||
const snapHp = (() => {
|
||||
const id = resolved?.unitId;
|
||||
@@ -1198,6 +1200,7 @@ function MatchScreen() {
|
||||
<div class="gm__vignette" aria-hidden="true"></div>
|
||||
${tempNotice ? html`<${TempRowNotice} view=${view} count=${temp.count} items=${temp.items} label=${!drag && !facing}
|
||||
ready=${phase === PHASE.PREP && !!priv?.ready} />` : null}
|
||||
${showPrep && view && viewKind !== 'loading' && priv && !pen ? html`<${StandInTags} view=${view} priv=${priv} getChess=${gd.chess} backups=${gd.backups} />` : null}
|
||||
|
||||
<div class="gm__hud" ref=${hudElRef}>
|
||||
<${TopBar} pub=${pub} priv=${priv} conn=${conn} hud=${hud} total=${total} drawer=${drawer}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
// 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. 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 } from '../../ui/gameLogic.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).
|
||||
* @param {any} priv m.private @param {(id: string) => any} getChess @param {any} backups data/backups.json
|
||||
*/
|
||||
export function standInPieces(priv, getChess, backups) {
|
||||
const out = [];
|
||||
if (!priv || !Array.isArray(priv.standIns) || !priv.standIns.length) return out;
|
||||
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 });
|
||||
};
|
||||
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');
|
||||
for (const p of Array.isArray(priv.board) ? priv.board : []) add(p, 'board');
|
||||
return out;
|
||||
}
|
||||
|
||||
/** @param {{ view: any, priv: any, getChess: (id: string) => any, backups: any }} props */
|
||||
export function StandInTags({ view, priv, getChess, backups }) {
|
||||
const items = standInPieces(priv, getChess, backups);
|
||||
const [pos, setPos] = useState({});
|
||||
const last = useRef('');
|
||||
const key = items.map((it) => it.uid).join(',');
|
||||
useEffect(() => {
|
||||
const raw = rawOf(view);
|
||||
if (!raw || typeof raw.pieceScreenRect !== 'function' || !key) { last.current = ''; setPos({}); return undefined; }
|
||||
const uids = key.split(',').map(Number);
|
||||
let raf = 0;
|
||||
let alive = true;
|
||||
const tick = () => {
|
||||
if (!alive) return;
|
||||
const next = {};
|
||||
let sig = '';
|
||||
for (const uid of uids) {
|
||||
let r;
|
||||
try { r = raw.pieceScreenRect(uid); } catch { r = null; }
|
||||
if (!r || !(r.width > 0)) continue;
|
||||
// under the feet: the bottom centre of the drawn body
|
||||
next[uid] = { x: (r.left + r.right) / 2, y: r.bottom };
|
||||
sig += `${uid}:${next[uid].x.toFixed(1)},${next[uid].y.toFixed(1)};`;
|
||||
}
|
||||
if (sig !== last.current) { last.current = sig; setPos(next); }
|
||||
raf = requestAnimationFrame(tick);
|
||||
};
|
||||
tick();
|
||||
return () => { alive = false; cancelAnimationFrame(raf); };
|
||||
}, [view, key]);
|
||||
if (!items.length) return 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}`} data-uid=${it.uid} title=${`未持有:由 ${it.name} 上场`}
|
||||
style=${`left:${p.x.toFixed(1)}px;top:${p.y.toFixed(1)}px`}>${it.label}</span>` : null;
|
||||
})}
|
||||
</div>`;
|
||||
}
|
||||
@@ -12,7 +12,9 @@
|
||||
// card's own block and pure functions, GitHub issue #64) and the elite's modules (不装备 / X / Y … with the stat bonus,
|
||||
// the trait upgrade and the talent changes), 恢复默认; 全部恢复默认 in the top bar.
|
||||
// The loadout lives in ui/loadoutSync.js (localStorage + room.loadout); the model is ui/loadoutModel.js.
|
||||
// Keyboard: Esc closes, ←/→ move through the (filtered) roster when focus is not in the search field.
|
||||
// The second tab, 干员持有 (0.2.0 补位, screens/ownership.js): which NORMAL chess the player owns — a chess marked 未持有 is
|
||||
// deployed as its official stand-in (room.ownership; ui/ownershipModel.js) — with its own 导出 / 导入 / 全部持有.
|
||||
// Keyboard: Esc closes, ←/→ move through the (filtered) roster when focus is not in the search field (干员调配 tab).
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from '../../vendor/hooks.module.js';
|
||||
import { html, Icon, MicroLabel, Button, TierChip, TextField, Countdown, Spinner, confirmDialog, hasDeadline, Modal, Fragment } from '../ui/components.js';
|
||||
@@ -27,7 +29,9 @@ import {
|
||||
MODULE_NONE, PROF_ORDER, PROF_NAME, rosterOf, filterRoster, recordsOf, chessOptions, effectiveChoice, setChoice, resetChoice,
|
||||
changedCount, skillLabel, moduleBadge, attrRows, skillTags, serializeExport, parseImport, LOADOUT_IMPORT_MAX_BYTES,
|
||||
} from '../ui/loadoutModel.js';
|
||||
import { loadoutStore, openLoadout, closeLoadout, setEntries, applyLoadoutEntries } from '../ui/loadoutSync.js';
|
||||
import { loadoutStore, openLoadout, closeLoadout, setEntries, applyLoadoutEntries, setNotOwned, applyOwnershipImport } from '../ui/loadoutSync.js';
|
||||
import { setOwned, notOwnedCount, serializeOwnership, parseOwnershipImport, OWNERSHIP_IMPORT_MAX_BYTES } from '../ui/ownershipModel.js';
|
||||
import { OwnershipPanel, useOwnershipRoster } from './ownership.js';
|
||||
import { copyText } from '../ui/clipboard.js';
|
||||
import { toast } from '../ui/toasts.js';
|
||||
|
||||
@@ -67,10 +71,10 @@ function readFileText(file) {
|
||||
});
|
||||
}
|
||||
|
||||
/** `stronghold-loadout-20261003-1245.json` */
|
||||
function exportFilename(now = new Date()) {
|
||||
/** `stronghold-loadout-20261003-1245.json` (`stronghold-ownership-…` for the 干员持有 list) */
|
||||
function exportFilename(now = new Date(), what = 'loadout') {
|
||||
const p = (n) => String(n).padStart(2, '0');
|
||||
return `stronghold-loadout-${now.getFullYear()}${p(now.getMonth() + 1)}${p(now.getDate())}-${p(now.getHours())}${p(now.getMinutes())}.json`;
|
||||
return `stronghold-${what}-${now.getFullYear()}${p(now.getMonth() + 1)}${p(now.getDate())}-${p(now.getHours())}${p(now.getMinutes())}.json`;
|
||||
}
|
||||
|
||||
/** Square profession glyph (manifest prof.large: black glyph on white, drawn as a white glyph by loadout.css). */
|
||||
@@ -133,7 +137,7 @@ export function ModuleGlyph({ m, rec, id, size = 'md' }) {
|
||||
|
||||
// ---- roster card ---------------------------------------------------------------------------------------------------
|
||||
|
||||
function RosterCard({ m, chess, golden, entries, selected, onPick }) {
|
||||
function RosterCard({ m, chess, golden, entries, selected, onPick, notOwned = false }) {
|
||||
const choice = effectiveChoice(entries, chess, golden);
|
||||
const opt = chessOptions(chess, golden);
|
||||
const skillRec = opt.skillOptions.find((s) => s.index === choice.skill)?.normal || chess.skill;
|
||||
@@ -147,6 +151,7 @@ function RosterCard({ m, chess, golden, entries, selected, onPick }) {
|
||||
</span>
|
||||
<${TierChip} tier=${chess.tier} size="sm" class="lo-card__tier" />
|
||||
${choice.changed ? html`<span class="lo-card__flag" aria-label="已调整"></span>` : null}
|
||||
${notOwned ? html`<span class="lo-card__sub" title="未持有(干员持有):由替补干员上场" aria-label="未持有">替补</span>` : null}
|
||||
<span class="lo-card__name">${chess.name}</span>
|
||||
<span class="lo-card__kit">
|
||||
<span class=${cx('lo-card__sk', choice.skill !== opt.defaultSkill && 'is-alt')}>
|
||||
@@ -216,6 +221,8 @@ function ModuleInfo({ m, golden, opt }) {
|
||||
}
|
||||
|
||||
const getChessRec = (id) => data.lookup('chess', id);
|
||||
/** The name of a chess's 补位 stand-in (data/backups.json), or null. */
|
||||
const standInName = (chess) => data.get('backups')?.units?.[chess?.backup?.charId]?.name ?? null;
|
||||
|
||||
/**
|
||||
* What 局内数值 shows (GitHub issue #64): the chess variant — the 精锐 record when asked for and the chess has one, else
|
||||
@@ -266,7 +273,7 @@ export function LoadoutStats({ base, golden, entries, level, onLevel, getChess =
|
||||
</section>`;
|
||||
}
|
||||
|
||||
function Detail({ m, chess, golden, entries, onChange, onReset, locked }) {
|
||||
function Detail({ m, chess, golden, entries, onChange, onReset, locked, notOwned = false }) {
|
||||
const [level, setLevel] = useState('normal');
|
||||
const [statLevel, setStatLevel] = useState('elite'); // 局内数值: the 精锐 shows the chosen module's effect
|
||||
const bodyRef = useRef(null);
|
||||
@@ -329,6 +336,7 @@ function Detail({ m, chess, golden, entries, onChange, onReset, locked }) {
|
||||
</div>
|
||||
${modOpt ? html`<${ModuleInfo} m=${m} golden=${golden} opt=${modOpt} />` : null}
|
||||
</section>` : null}
|
||||
${notOwned ? html`<p class="lo-locknote lo-locknote--standin" data-testid="loadout-standin-note"><${Icon} name="info" />干员持有中标记为未持有:此棋子由替补干员${standInName(chess) ? ` ${standInName(chess)} ` : ''}上场,技能与模组固定(补位干员技能不可更改);这里的调配在改回「持有」后生效</p>` : null}
|
||||
${locked ? html`<p class="lo-locknote"><${Icon} name="info" />本局的调配已锁定,修改将在下一局生效</p>` : null}
|
||||
</div>
|
||||
</aside>`;
|
||||
@@ -373,6 +381,8 @@ const SYNC_TEXT = {
|
||||
idle: ['', ''], pending: ['保存中…', 'is-busy'], sending: ['同步中…', 'is-busy'], synced: ['已同步', 'is-ok'],
|
||||
locked: ['本局已锁定 · 下一局生效', 'is-warn'], error: ['同步失败', 'is-bad'],
|
||||
};
|
||||
/** The 干员持有 tab's status line: the setting never applies to a running match. */
|
||||
const OWN_SYNC_TEXT = { ...SYNC_TEXT, locked: ['下一局生效', 'is-warn'] };
|
||||
|
||||
/** The overlay screen. */
|
||||
function LoadoutScreen({ st }) {
|
||||
@@ -401,6 +411,16 @@ function LoadoutScreen({ st }) {
|
||||
const [narrowDetail, setNarrowDetail] = useState(false); // phones: the detail slides over the roster
|
||||
const [io, setIo] = useState(null); // 导出 / 导入 dialog: { mode, text } | null
|
||||
|
||||
const tab = st.tab === 'ownership' ? 'ownership' : 'loadout';
|
||||
const ownRoster = useOwnershipRoster(ready);
|
||||
const nNotOwned = notOwnedCount(st.notOwned, ownRoster);
|
||||
const setTab = (t) => loadoutStore.set({ tab: t });
|
||||
const toggleOwned = (id, owned) => setNotOwned(setOwned(loadoutStore.get().notOwned, id, owned));
|
||||
const ownAll = async () => {
|
||||
if (!nNotOwned) return;
|
||||
const ok = await confirmDialog({ title: '全部持有', text: `将 ${nNotOwned} 名未持有的干员恢复为持有(由本人上场)?`, okText: '全部持有' });
|
||||
if (ok) setNotOwned([]);
|
||||
};
|
||||
const pick = (id) => { loadoutStore.set({ sel: id }); setNarrowDetail(true); };
|
||||
const change = (patch) => { if (base) setEntries(setChoice(loadoutStore.get().entries, base, golden, patch)); };
|
||||
const resetOne = () => { if (base) setEntries(resetChoice(loadoutStore.get().entries, base.chessId)); };
|
||||
@@ -410,27 +430,39 @@ function LoadoutScreen({ st }) {
|
||||
if (ok) setEntries({});
|
||||
};
|
||||
|
||||
// 导出 / 导入 the loadout as the versioned payload (a downloaded file, the clipboard, or the textarea)
|
||||
// 导出 / 导入 the loadout (or, on the 干员持有 tab, the not-owned list) as the versioned payload (a downloaded file,
|
||||
// the clipboard, or the textarea); `io.kind` says which
|
||||
const ioText = io?.text ?? '';
|
||||
const openExport = () => setIo({ mode: 'export', text: serializeExport(loadoutStore.get().entries) });
|
||||
const openImport = () => setIo({ mode: 'import', text: '' });
|
||||
const ioOwn = io?.kind === 'ownership';
|
||||
const openExport = () => setIo(tab === 'ownership'
|
||||
? { mode: 'export', kind: 'ownership', text: serializeOwnership(loadoutStore.get().notOwned) }
|
||||
: { mode: 'export', kind: 'loadout', text: serializeExport(loadoutStore.get().entries) });
|
||||
const openImport = () => setIo({ mode: 'import', kind: tab, text: '' });
|
||||
const ioCopy = async () => {
|
||||
const ok = await copyText(ioText);
|
||||
toast(ok ? '已复制到剪贴板' : '复制失败,请在文本框中手动全选复制', ok ? 'success' : 'warn');
|
||||
};
|
||||
const ioDownload = () => downloadText(exportFilename(), ioText);
|
||||
const ioDownload = () => downloadText(exportFilename(new Date(), ioOwn ? 'ownership' : 'loadout'), ioText);
|
||||
const ioPick = () => fileRef.current?.click();
|
||||
const ioFile = async (e) => {
|
||||
const f = e.currentTarget.files && e.currentTarget.files[0];
|
||||
e.currentTarget.value = ''; // picking the same file twice must fire again
|
||||
if (!f) return;
|
||||
// refuse a huge pick before reading it into memory (a real payload is a few KB)
|
||||
if (f.size > LOADOUT_IMPORT_MAX_BYTES) { toast('文件过大,请选择「导出」下载的调配文件', 'error'); return; }
|
||||
try { setIo({ mode: 'import', text: await readFileText(f) }); } catch { toast('读取文件失败', 'error'); }
|
||||
if (f.size > (ioOwn ? OWNERSHIP_IMPORT_MAX_BYTES : LOADOUT_IMPORT_MAX_BYTES)) { toast(`文件过大,请选择「导出」下载的${ioOwn ? '干员持有' : '调配'}文件`, 'error'); return; }
|
||||
try { setIo({ ...io, mode: 'import', text: await readFileText(f) }); } catch { toast('读取文件失败', 'error'); }
|
||||
};
|
||||
const ioApply = () => {
|
||||
// an import before chess.json is loaded would sanitise every entry away — refuse instead of wiping the loadout
|
||||
if (!ready) { toast('干员数据仍在载入,请稍候再导入', 'warn'); return; }
|
||||
if (ioOwn) {
|
||||
const r = parseOwnershipImport(ioText);
|
||||
if (!r.ok) { toast(`导入失败:${r.error}`, 'error'); return; }
|
||||
const { applied, dropped } = applyOwnershipImport(r.notOwned, getChess);
|
||||
setIo(null);
|
||||
toast(applied ? `已导入:${applied} 名干员未持有${dropped ? `(另有 ${dropped} 项无效,未导入)` : ''}` : '已导入:全部持有', dropped ? 'warn' : 'success');
|
||||
return;
|
||||
}
|
||||
const res = parseImport(ioText);
|
||||
if (!res.ok) { toast(`导入失败:${res.error}`, 'error'); return; }
|
||||
const { applied, dropped } = applyLoadoutEntries(res.entries, getChess);
|
||||
@@ -450,7 +482,7 @@ function LoadoutScreen({ st }) {
|
||||
const typing = e.target && /^(INPUT|SELECT|TEXTAREA)$/.test(e.target.tagName);
|
||||
if (e.key === 'Escape') { e.preventDefault(); e.stopImmediatePropagation(); closeLoadout(); return; }
|
||||
if (typing) return;
|
||||
if (e.key === 'ArrowRight' || e.key === 'ArrowLeft') {
|
||||
if ((e.key === 'ArrowRight' || e.key === 'ArrowLeft') && loadoutStore.get().tab !== 'ownership') {
|
||||
const ids = filterRoster(rosterOf(data.list('chess')), loadoutStore.get().filters, loadoutStore.get().entries, getChess, getBond).map((c) => c.chessId);
|
||||
if (!ids.length) return;
|
||||
const cur = Math.max(0, ids.indexOf(loadoutStore.get().sel));
|
||||
@@ -471,8 +503,12 @@ function LoadoutScreen({ st }) {
|
||||
if (el && typeof el.scrollIntoView === 'function') el.scrollIntoView({ block: 'nearest' });
|
||||
}, [selId]);
|
||||
|
||||
const [syncText, syncCls] = SYNC_TEXT[st.sync] || SYNC_TEXT.idle;
|
||||
const [syncText, syncCls] = tab === 'ownership' ? OWN_SYNC_TEXT[st.ownSync] || OWN_SYNC_TEXT.idle : SYNC_TEXT[st.sync] || SYNC_TEXT.idle;
|
||||
const fromText = st.from === 'briefing' ? '确认本局信息阶段结束前可调整本局配置' : '开始模拟前可调整干员携带的技能与模组,干员等级不可调整';
|
||||
// 干员持有 is out of match: a running match keeps the list its seat had at its start
|
||||
const ownLocked = inMatch && !!phase && phase !== PHASE.LOBBY;
|
||||
const ownText = ownLocked ? '干员持有是局外设置:本局按开局时的设置进行,修改将在下一局生效'
|
||||
: '局外设置,下一局生效 · 联机时只影响你自己的棋子 · 默认全部持有';
|
||||
|
||||
return html`<${Fragment}>
|
||||
<div class="lo" role="dialog" aria-modal="true" aria-label="干员调配">
|
||||
@@ -482,35 +518,51 @@ function LoadoutScreen({ st }) {
|
||||
<${Button} variant="ghost" size="md" icon="chevronLeft" class="lo-back" onClick=${closeLoadout} aria-label="返回" title="返回 (Esc)">返回<//>
|
||||
</div>
|
||||
<div class="lo-top__center">
|
||||
<${MicroLabel} tone="mint">OPERATOR LOADOUT<//>
|
||||
<h1 class="lo-top__title"><${Img} src=${localAsset('ui/outer', 'operator_preset')} class="lo-top__icon" fallback=${html`<${Icon} name="edit" class="lo-top__icon" />`} />干员调配</h1>
|
||||
<${MicroLabel} tone="mint">${tab === 'ownership' ? 'OPERATOR ROSTER' : 'OPERATOR LOADOUT'}<//>
|
||||
<div class="lo-tabs" role="tablist" aria-label="干员调配 / 干员持有">
|
||||
<button type="button" role="tab" aria-selected=${tab === 'loadout' ? 'true' : 'false'} data-tab="loadout" class=${cx('lo-tab', tab === 'loadout' && 'is-on')} onClick=${() => setTab('loadout')}>
|
||||
<${Img} src=${localAsset('ui/outer', 'operator_preset')} class="lo-top__icon" fallback=${html`<${Icon} name="edit" class="lo-top__icon" />`} />干员调配</button>
|
||||
<button type="button" role="tab" aria-selected=${tab === 'ownership' ? 'true' : 'false'} data-tab="ownership" class=${cx('lo-tab', tab === 'ownership' && 'is-on')} onClick=${() => setTab('ownership')}
|
||||
title="标记未持有的干员:由官方指定的替补干员上场">干员持有${nNotOwned ? html`<span class="lo-tab__n num" aria-label=${`${nNotOwned} 名未持有`}>${nNotOwned}</span>` : null}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="lo-top__right">
|
||||
${tab === 'ownership' ? html`<div class="lo-top__right">
|
||||
${syncText ? html`<span class=${cx('lo-sync', syncCls)} role="status" data-testid="ownership-sync">${syncText}</span>` : null}
|
||||
<span class="lo-count">未持有 <b class="num">${nNotOwned}</b><span class="num t-dim">/${ownRoster.length}</span></span>
|
||||
<${Button} variant="ghost" size="sm" data-testid="ownership-export" disabled=${!nNotOwned} onClick=${openExport} title="导出干员持有(可复制或下载)">导出<//>
|
||||
<${Button} variant="ghost" size="sm" data-testid="ownership-import" disabled=${!ready} onClick=${openImport} title="导入干员持有(粘贴或选择文件)">导入<//>
|
||||
<${Button} variant="secondary" size="sm" icon="refresh" data-testid="ownership-reset" disabled=${!nNotOwned} onClick=${ownAll}>全部持有<//>
|
||||
</div>` : html`<div class="lo-top__right">
|
||||
${inMatch && hasDeadline(infoDeadline) ? html`<${Countdown} deadline=${infoDeadline} size="sm" gauge=${false} label="调配截止" class="lo-deadline" />` : null}
|
||||
${syncText ? html`<span class=${cx('lo-sync', syncCls)} role="status">${syncText}</span>` : null}
|
||||
<span class="lo-count">已调整 <b class="num">${nChanged}</b><span class="num t-dim">/${roster.length}</span></span>
|
||||
<${Button} variant="ghost" size="sm" data-testid="loadout-export" disabled=${!nChanged} onClick=${openExport} title="导出当前调配(可复制或下载)">导出<//>
|
||||
<${Button} variant="ghost" size="sm" data-testid="loadout-import" disabled=${!ready} onClick=${openImport} title="导入调配(粘贴或选择文件)">导入<//>
|
||||
<${Button} variant="secondary" size="sm" icon="refresh" disabled=${!nChanged} onClick=${resetAll}>全部恢复默认<//>
|
||||
</div>
|
||||
</div>`}
|
||||
</header>
|
||||
<p class=${cx('lo-note', locked && 'is-locked')}><${Icon} name="info" />${locked ? '本局的调配已锁定(确认本局信息后无法修改),修改将在下一局生效' : fromText}</p>
|
||||
${!ready ? html`<div class="lo-loading"><${Spinner} size="sm" />正在载入干员数据(打开页面后仅载入一次)…</div>` : html`<main class=${cx('lo-body', narrowDetail && 'is-detail')}>
|
||||
${tab === 'ownership'
|
||||
? html`<p class=${cx('lo-note', ownLocked && 'is-locked')}><${Icon} name="info" />${ownText}</p>`
|
||||
: html`<p class=${cx('lo-note', locked && 'is-locked')}><${Icon} name="info" />${locked ? '本局的调配已锁定(确认本局信息后无法修改),修改将在下一局生效' : fromText}</p>`}
|
||||
${!ready ? html`<div class="lo-loading"><${Spinner} size="sm" />正在载入干员数据(打开页面后仅载入一次)…</div>` : tab === 'ownership'
|
||||
? html`<${OwnershipPanel} m=${m} roster=${ownRoster} notOwned=${st.notOwned} onToggle=${toggleOwned} />`
|
||||
: html`<main class=${cx('lo-body', narrowDetail && 'is-detail')}>
|
||||
<section class="lo-roster">
|
||||
<${Filters} m=${m} filters=${st.filters} bonds=${bonds} onFilters=${(filters) => loadoutStore.set({ filters })} />
|
||||
<div class="lo-grid" role="listbox" aria-label="干员列表" ref=${gridRef}>
|
||||
${list.length ? list.map((c) => html`<${RosterCard} key=${c.chessId} m=${m} chess=${c} golden=${c.goldenId ? getChess(c.goldenId) : null}
|
||||
entries=${st.entries} selected=${c.chessId === selId} onPick=${pick} />`) : html`<p class="lo-empty t-dim">没有符合条件的干员</p>`}
|
||||
entries=${st.entries} selected=${c.chessId === selId} onPick=${pick} notOwned=${(st.notOwned || []).includes(c.chessId)} />`) : html`<p class="lo-empty t-dim">没有符合条件的干员</p>`}
|
||||
</div>
|
||||
</section>
|
||||
<div class="lo-detail-wrap">
|
||||
<button type="button" class="lo-detail-back tapx" onClick=${() => setNarrowDetail(false)}><${Icon} name="chevronLeft" />干员列表</button>
|
||||
<${Detail} m=${m} chess=${base} golden=${golden} entries=${st.entries} onChange=${change} onReset=${resetOne} locked=${locked} />
|
||||
<${Detail} m=${m} chess=${base} golden=${golden} entries=${st.entries} onChange=${change} onReset=${resetOne} locked=${locked}
|
||||
notOwned=${!!base && (st.notOwned || []).includes(base.chessId)} />
|
||||
</div>
|
||||
</main>`}
|
||||
</div>
|
||||
${io ? html`<${Modal} open=${true} onClose=${() => setIo(null)}
|
||||
title=${io.mode === 'export' ? '导出干员调配' : '导入干员调配'} micro="OPERATOR LOADOUT"
|
||||
title=${`${io.mode === 'export' ? '导出' : '导入'}${ioOwn ? '干员持有' : '干员调配'}`} micro=${ioOwn ? 'OPERATOR ROSTER' : 'OPERATOR LOADOUT'}
|
||||
actions=${io.mode === 'export'
|
||||
? html`<${Button} variant="ghost" onClick=${() => setIo(null)}>关闭<//>
|
||||
<${Button} variant="secondary" icon="copy" data-testid="loadout-io-copy" onClick=${ioCopy}>复制<//>
|
||||
@@ -518,12 +570,16 @@ function LoadoutScreen({ st }) {
|
||||
: html`<${Button} variant="ghost" onClick=${() => setIo(null)}>取消<//>
|
||||
<${Button} variant="secondary" data-testid="loadout-io-pick" onClick=${ioPick}>选择文件<//>
|
||||
<${Button} variant="primary" icon="check" data-testid="loadout-io-apply" disabled=${!ioText.trim() || !ready} onClick=${ioApply}>导入<//>`}>
|
||||
<p class="lo-io__hint">${io.mode === 'export'
|
||||
? html`共 <b class="num">${nChanged}</b> 名干员已调整。复制或下载这份数据,即可在别的设备或浏览器上导入。`
|
||||
: html`把导出的内容粘贴到下方,或点「选择文件」。${nChanged ? html`导入会<strong>覆盖</strong>当前的 ${nChanged} 名干员调配。` : null}`}</p>
|
||||
<p class="lo-io__hint">${ioOwn
|
||||
? (io.mode === 'export'
|
||||
? html`共 <b class="num">${nNotOwned}</b> 名干员未持有。复制或下载这份数据,即可在别的设备或浏览器上导入。`
|
||||
: html`把导出的干员持有数据粘贴到下方,或点「选择文件」。导入会<strong>覆盖</strong>当前的干员持有设置。`)
|
||||
: io.mode === 'export'
|
||||
? html`共 <b class="num">${nChanged}</b> 名干员已调整。复制或下载这份数据,即可在别的设备或浏览器上导入。`
|
||||
: html`把导出的内容粘贴到下方,或点「选择文件」。${nChanged ? html`导入会<strong>覆盖</strong>当前的 ${nChanged} 名干员调配。` : null}`}</p>
|
||||
<textarea class="lo-io__text" data-testid="loadout-io-text" spellcheck=${false} readOnly=${io.mode === 'export'} value=${ioText}
|
||||
placeholder=${io.mode === 'export' ? '' : '在此粘贴导出的调配内容…'}
|
||||
onInput=${(e) => setIo({ mode: io.mode, text: e.currentTarget.value })}></textarea>
|
||||
placeholder=${io.mode === 'export' ? '' : ioOwn ? '在此粘贴导出的干员持有内容…' : '在此粘贴导出的调配内容…'}
|
||||
onInput=${(e) => setIo({ ...io, text: e.currentTarget.value })}></textarea>
|
||||
<input type="file" accept=".json,application/json,text/plain" class="lo-io__file" ref=${fileRef} onChange=${ioFile} />
|
||||
<//>` : null}
|
||||
<//>`;
|
||||
@@ -575,11 +631,15 @@ export function badgeCount(entries, getChess) {
|
||||
export function LoadoutButton({ from, size = 'md', variant = 'secondary', class: cls, label = '干员调配' }) {
|
||||
useData('local'); // the official preset icon (re-render once the local-art manifest arrives)
|
||||
const entries = useStore((s) => s.entries, Object.is, loadoutStore);
|
||||
const notOwned = useStore((s) => s.notOwned, Object.is, loadoutStore);
|
||||
const n = badgeCount(entries, data.status('chess') === 'ready' ? (id) => data.lookup('chess', id) : null);
|
||||
// 0.2.0 补位: how many operators the player marked as not owned (the 干员持有 tab) — easy to forget between sessions
|
||||
const off = Array.isArray(notOwned) ? notOwned.length : 0;
|
||||
return html`<button type="button" class=${cx('btn', `btn--${variant}`, `btn--${size}`, 'lo-entry', cls)} data-testid="loadout-open"
|
||||
onClick=${() => openLoadout(from)} title="调整干员携带的技能与模组">
|
||||
onClick=${() => openLoadout(from)} title="调整干员携带的技能与模组 · 干员持有">
|
||||
<${Img} src=${localAsset('ui/outer', 'operator_preset')} class="lo-entry__icon" fallback=${html`<${Icon} name="edit" class="btn__icon" />`} />
|
||||
<span class="btn__label">${label}</span>
|
||||
${n ? html`<span class="lo-entry__n num" aria-label=${`${n} 名干员已调整`}>${n}</span>` : null}
|
||||
${off ? html`<span class="lo-entry__own" aria-label=${`${off} 名干员未持有,由替补干员上场`} title=${`${off} 名干员未持有(由替补干员上场)`}>替补 <b class="num">${off}</b></span>` : null}
|
||||
</button>`;
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
// 干员持有 (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.
|
||||
// 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-*).
|
||||
|
||||
import { useMemo, useState } from '../../vendor/hooks.module.js';
|
||||
import { html, TierChip, Icon } from '../ui/components.js';
|
||||
import { Img } from '../ui/gameComponents.js';
|
||||
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';
|
||||
|
||||
const cx = (...p) => p.flat().filter(Boolean).join(' ');
|
||||
const ROMAN = ['', 'I', 'II', 'III', 'IV', 'V', 'VI'];
|
||||
|
||||
/** The roster of the tab (data.list('chess'), memoised on the data being ready). */
|
||||
export function useOwnershipRoster(ready) {
|
||||
return useMemo(() => ownershipRoster(data.list('chess')), [ready]);
|
||||
}
|
||||
|
||||
/** One chess: the operator → its stand-in, and the 持有 switch (the whole card is the switch). */
|
||||
export function OwnCard({ m, chess, backups, owned, onToggle }) {
|
||||
const si = standInSummary(chess, backups);
|
||||
const skill = si?.skill;
|
||||
const slot = skill && Number.isInteger(skill.index) ? `S${skill.index + 1}` : '';
|
||||
const label = `${chess.name}:${owned ? '持有' : `未持有,由 ${si?.name || '替补干员'} 上场`}`;
|
||||
return html`<button type="button" role="switch" aria-checked=${owned ? 'true' : 'false'} aria-label=${label} data-chess=${chess.chessId}
|
||||
class=${cx('own-card', `own-card--t${chess.tier}`, !owned && 'is-off')} onClick=${() => onToggle(chess.chessId, !owned)}
|
||||
title=${owned ? `点击标记为未持有:由 ${si?.name || '替补干员'} 上场` : '点击恢复为持有'}>
|
||||
<span class="own-card__op">
|
||||
<span class="own-card__ava"><${Img} src=${chessAvatarUrl(m, chess)} fallback=${html`<b>${[...(chess.name || '?')][0]}</b>`} /></span>
|
||||
<span class="own-card__txt">
|
||||
<b class="own-card__name">${chess.name}</b>
|
||||
<small>${PROF_NAME[chess.profession] || ''}${chess.subProfessionName ? ` · ${chess.subProfessionName}` : ''}</small>
|
||||
</span>
|
||||
</span>
|
||||
<span class="own-card__arrow" aria-hidden="true"><${Icon} name="chevronRight" /></span>
|
||||
<span class="own-card__sub" data-standin=${si?.charId || ''}>
|
||||
<span class="own-card__ava own-card__ava--sub">${si ? html`<${Img} src=${chessAvatarUrl(m, si.record)} fallback=${html`<b>${[...(si.name || '?')][0]}</b>`} />` : null}</span>
|
||||
<span class="own-card__txt">
|
||||
<b class="own-card__name">${si ? si.name : '—'}</b>
|
||||
<small>${si ? `${PROF_NAME[si.profession] || ''}${slot ? ` · ${slot} ${skill.name || ''}` : ''}` : '无替补数据'}</small>
|
||||
</span>
|
||||
</span>
|
||||
<span class="own-card__state"><i class="own-switch" aria-hidden="true"><i></i></i>${owned ? '持有' : '未持有'}</span>
|
||||
</button>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The tab's body.
|
||||
* @param {{ m: any, roster: any[], notOwned: string[], onToggle: (chessId: string, owned: boolean) => void }} props
|
||||
*/
|
||||
export function OwnershipPanel({ m, roster, notOwned, onToggle }) {
|
||||
const [offOnly, setOffOnly] = useState(false);
|
||||
const backups = data.get('backups');
|
||||
const list = offOnly ? roster.filter((c) => !isOwned(notOwned, c.chessId)) : roster;
|
||||
const groups = rosterByTier(list);
|
||||
return html`<main class="own" data-testid="ownership">
|
||||
<div class="own__bar">
|
||||
<p class="own__lead">未持有的干员由官方指定的<b>替补干员</b>上场:棋子的名字、盟约、特质、阶级与价格不变,上场的是替补干员(模型、技能、天赋、攻击范围)。预设干员无论是否持有都由本人上场,不在此列。</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>
|
||||
<div class="own__list">
|
||||
${groups.length ? groups.map((g) => html`<section key=${g.tier} class="own-tier" data-tier=${g.tier} aria-label=${`${g.tier}阶`}>
|
||||
<h3 class="own-tier__head"><${TierChip} tier=${g.tier} size="sm" /><span class="num">${ROMAN[g.tier]}</span><span>阶</span><span class="num t-dim">${g.list.length}</span></h3>
|
||||
<div class="own-grid">
|
||||
${g.list.map((c) => html`<${OwnCard} key=${c.chessId} m=${m} chess=${c} backups=${backups} owned=${isOwned(notOwned, c.chessId)} onToggle=${onToggle} />`)}
|
||||
</div>
|
||||
</section>`) : html`<p class="lo-empty t-dim">${offOnly ? '全部持有:没有由替补干员上场的棋子' : '没有可下掉的干员'}</p>`}
|
||||
</div>
|
||||
</main>`;
|
||||
}
|
||||
+38
-21
@@ -33,7 +33,7 @@
|
||||
|
||||
import { html, Icon, TierChip, MicroLabel, Button, confirmDialog, useTicker } from './components.js';
|
||||
import { Img, RichText, UnitThumb, BondGlyph, GIcon } from './gameComponents.js';
|
||||
import { attackInterval, rangeGridBox, fmtNum, tileKey, chessLoadout, nextThreshold, bondTier, briefingBondTip, pieceBondIds, grantedBonds, morphPairings } from './gameLogic.js';
|
||||
import { attackInterval, rangeGridBox, fmtNum, tileKey, chessLoadout, nextThreshold, bondTier, briefingBondTip, pieceBondIds, grantedBonds, morphPairings, fieldsStandIn, standInOf, standInLoadout, standInLabel } 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';
|
||||
@@ -354,17 +354,22 @@ export function chessStatsBlock({ rec, chess, live = null }) {
|
||||
</div>`;
|
||||
}
|
||||
|
||||
export function ChessDetail({ chess, piece, unit, snapHp, editable, onSell, bonds, offBonds = null, loadout, onBond, live = null, hint = null, unitItems = null }) {
|
||||
export function ChessDetail({ chess, piece, unit, snapHp, editable, onSell, bonds, offBonds = null, loadout, onBond, live = null, hint = null, unitItems = null, standIn = null }) {
|
||||
const m = data.get('assets');
|
||||
const hp = hpOf(live, snapHp);
|
||||
const lo = chessLoadout(chess, loadout, (id) => data.lookup('chess', id));
|
||||
const getChess = (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)
|
||||
const si = standIn && standIn.standInFor ? standIn : null;
|
||||
const body = si || chess;
|
||||
const lo = si ? standInLoadout(si, getChess, data.get('backups')) : chessLoadout(chess, loadout, getChess);
|
||||
const c = chess;
|
||||
// stats / talents the unit fights with: the chosen module's (or none — statsBase) for an elite (DESIGN §16)
|
||||
const fr = lo?.record || c;
|
||||
const fr = lo?.record || body;
|
||||
const golden = !!(c.isGolden || piece?.golden);
|
||||
const sk = lo?.skill || c.skill || null;
|
||||
const sk = lo?.skill || body.skill || null;
|
||||
// a chosen skill the manifest has no icon for (only the default skills' icons are fetched): its slot letter
|
||||
const skIcon = sk && lo && !lo.defaultSkill ? skillRecordIconUrl(m, sk, { empty: false }) : skillIconUrl(m, c);
|
||||
const skIcon = sk && lo && !lo.defaultSkill ? skillRecordIconUrl(m, sk, { empty: false }) : skillIconUrl(m, body);
|
||||
const skSlot = sk && Number.isInteger(sk.index) ? `S${sk.index + 1}` : null;
|
||||
const garrison = Array.isArray(c.garrisonIds) && c.garrisonIds[0] ? data.lookup('garrisons', c.garrisonIds[0]) : null;
|
||||
const items = Array.isArray(piece?.items) ? piece.items : [];
|
||||
@@ -379,36 +384,38 @@ 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, c)} 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" />`} />
|
||||
</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}
|
||||
</div>
|
||||
<h3 class="dhead__name">${c.name}</h3>
|
||||
<span class="dhead__en">${c.appellation || ''}</span>
|
||||
<span class="dhead__en">${si ? `${si.name}${si.appellation && si.appellation !== si.name ? ` · ${si.appellation}` : ''}` : c.appellation || ''}</span>
|
||||
<div class="dhead__class">
|
||||
<${Img} src=${profIconUrl(m, c.profession)} class="dhead__prof" />
|
||||
<span>${PROF_NAME[c.profession] || c.profession || ''}</span>
|
||||
<${Img} src=${profIconUrl(m, body.profession)} class="dhead__prof" />
|
||||
<span>${PROF_NAME[body.profession] || body.profession || ''}</span>
|
||||
<i class="sep"></i>
|
||||
<${Img} src=${subProfIconUrl(m, c)} class="dhead__sub" />
|
||||
<span>${c.subProfessionName || ''}</span>
|
||||
<span class="dhead__pos">${c.position === 'MELEE' ? '近战位' : '远程位'}</span>
|
||||
<${Img} src=${subProfIconUrl(m, body)} class="dhead__sub" />
|
||||
<span>${body.subProfessionName || ''}</span>
|
||||
<span class="dhead__pos">${body.position === 'MELEE' ? '近战位' : '远程位'}</span>
|
||||
</div>
|
||||
${hp ? html`<div class="dhp"><i style=${`width:${Math.max(0, Math.min(100, (hp.hp / Math.max(1, hp.max)) * 100))}%`}></i><span class="num">${fmtNum(hp.hp)} / ${fmtNum(hp.max)}</span></div>` : null}
|
||||
<${BondChips} bondIds=${bondIds} bonds=${bonds} off=${offBonds} onBond=${onBond} granted=${grantedIds} />
|
||||
</div>
|
||||
</div>`;
|
||||
blocks.garrison = garrison ? html`<${GarrisonBlock} key="garrison" garrison=${garrison} m=${m} />` : null;
|
||||
blocks.trait = c.trait?.desc ? html`<p key="trait" class="dtrait"><${Icon} name="info" /><${RichText} text=${traitText(c, golden, lo)} /></p>` : null;
|
||||
blocks.trait = body.trait?.desc ? html`<p key="trait" class="dtrait"><${Icon} name="info" /><${RichText} text=${traitText(body, golden, lo)} /></p>` : null;
|
||||
// (`fr` is the body the unit fights with — a stand-in's record for a 补位 chess; `chess` is only the range's fallback)
|
||||
blocks.stats = chessStatsBlock({ rec: fr, chess: c, live });
|
||||
blocks.skill = sk ? html`<${Section} key="skill" title="技能" micro="SKILL" class="dsec--skill">
|
||||
<div class="dskill" data-skill=${sk.skillId || ''}>
|
||||
<${Img} src=${skIcon} class="dskill__icon" fallback=${html`<span class="dskill__icon dskill__icon--empty">${skSlot ? html`<b class="num">${skSlot}</b>` : null}</span>`} />
|
||||
<div class="dskill__meta">
|
||||
<b class="dskill__name">${skSlot && (lo?.choices || 0) > 1 ? html`<span class="dskill__slot num" title=${`技能 ${skSlot}`}>${skSlot}</span>` : null}${sk.name}${lo && !lo.defaultSkill ? html`<span class="dtag-loadout" title="干员调配中选择的技能">已调配</span>` : null}</b>
|
||||
<b class="dskill__name">${skSlot && (lo?.choices || 0) > 1 ? html`<span class="dskill__slot num" title=${`技能 ${skSlot}`}>${skSlot}</span>` : null}${sk.name}${lo && !lo.defaultSkill && !si ? html`<span class="dtag-loadout" title="干员调配中选择的技能">已调配</span>` : null}</b>
|
||||
<div class="dskill__tags">
|
||||
<span class="dsp dsp--${sk.spType === 'INCREASE_WHEN_ATTACK' ? 'atk' : sk.spType === 'INCREASE_WHEN_TAKEN_DAMAGE' ? 'def' : 'time'}">${SP_TYPE[sk.spType] || '技力'}</span>
|
||||
<span class="dsp dsp--trig">${SKILL_TYPE[sk.skillType] || '自动触发'}</span>
|
||||
@@ -426,7 +433,7 @@ export function ChessDetail({ chess, piece, unit, snapHp, editable, onSell, bond
|
||||
<${Img} src=${moduleTypeIconUrl(data.get('local'), lo.module.typeName)} fallback=${html`<b class="num">${moduleBadge(lo.module)}</b>`} /></span>` : null}
|
||||
<b class="dmodule__name">${lo.module.name}</b>
|
||||
${lo.module.typeName ? html`<span class="dmodule__type">${lo.module.typeName}</span>` : null}
|
||||
${!lo.defaultModule ? html`<span class="dtag-loadout" title="干员调配中选择的模组">已调配</span>` : null}
|
||||
${!lo.defaultModule && !si ? html`<span class="dtag-loadout" title="干员调配中选择的模组">已调配</span>` : null}
|
||||
</div>
|
||||
<//>` : null;
|
||||
// (a 变形同构体 / bond item row shows its pairing against what this operator carries: ItemRow `carried`)
|
||||
@@ -604,9 +611,13 @@ export function TokenDetail({ token, piece, ownerId = null, snapHp = null, live
|
||||
* Resolve what a detail target shows.
|
||||
* @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)
|
||||
*/
|
||||
export function resolveDetail(target, pieces) {
|
||||
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);
|
||||
if (target.kind === 'piece') {
|
||||
const e = pieces?.get(target.uid);
|
||||
if (!e) return null;
|
||||
@@ -614,13 +625,13 @@ export function resolveDetail(target, pieces) {
|
||||
if (p.kind === 'item') { const it = data.lookup('items', p.id); return it ? { type: 'item', item: it, piece: p } : null; }
|
||||
if (p.kind === 'token') { const t = data.lookup('tokens', p.id); return t ? { type: 'token', token: t, piece: p, ownerId: tokenOwnerId(p, pieces) } : null; }
|
||||
const c = data.lookup('chess', p.id);
|
||||
return c ? { type: 'chess', chess: c, piece: p } : null;
|
||||
return c ? { type: 'chess', chess: c, piece: p, standIn: ownStandIn(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') : [];
|
||||
return c ? { type: 'chess', chess: c, hint: target.hint || null, ...(items.length ? { unitItems: items } : {}) } : null;
|
||||
return c ? { type: 'chess', chess: c, hint: target.hint || null, standIn: target.foreign ? null : ownStandIn(c), ...(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; }
|
||||
@@ -632,7 +643,12 @@ export function resolveDetail(target, pieces) {
|
||||
// a hand item on a scouted prep board (m.field units, kind 'item'): the item's own card
|
||||
if (u.kind === 'item') { const it = data.lookup('items', u.defId); return it ? { type: 'item', item: it } : null; }
|
||||
const c = data.lookup('chess', u.defId);
|
||||
if (c) return { type: 'chess', chess: c, piece: own?.piece || null, unitId: u.id, unitItems: Array.isArray(u.items) ? u.items : null };
|
||||
// a unit says itself whether it is a stand-in (UnitInfo standInFor: the sim's, prep scouting's); an own piece's unit
|
||||
// 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);
|
||||
if (c) return { type: 'chess', chess: c, piece: own?.piece || null, unitId: u.id, unitItems: Array.isArray(u.items) ? u.items : null, standIn: si };
|
||||
const t = data.lookup('tokens', u.defId);
|
||||
if (t) return { type: 'token', token: t, unitId: u.id, ownerId: tokenOwnerId(own?.piece, pieces) };
|
||||
const en = data.lookup('enemies', u.defId);
|
||||
@@ -675,7 +691,8 @@ export function DetailPanel({ detail, editable, snapHp, onClose, onSell, onDestr
|
||||
<button type="button" class="dpanel__close" aria-label="关闭" onClick=${onClose}><${Icon} name="close" /></button>
|
||||
<div class="dpanel__scroll">
|
||||
${detail.type === 'chess' ? html`<${ChessDetail} chess=${detail.chess} piece=${detail.piece} snapHp=${snapHp} editable=${editable} onSell=${sellIt}
|
||||
bonds=${bonds} offBonds=${offBonds} loadout=${loadout} onBond=${onBond} live=${liveNow} hint=${detail.hint || null} unitItems=${detail.unitItems || null} />` : null}
|
||||
bonds=${bonds} offBonds=${offBonds} loadout=${loadout} onBond=${onBond} live=${liveNow} hint=${detail.hint || null} unitItems=${detail.unitItems || null}
|
||||
standIn=${detail.standIn || null} />` : null}
|
||||
${detail.type === 'item' ? html`<${ItemDetail} item=${detail.item} piece=${detail.piece} editable=${editable} onDestroy=${destroyIt} offBonds=${offBonds} />` : null}
|
||||
${detail.type === 'enemy' ? html`<${EnemyDetail} enemy=${detail.enemy} snapHp=${snapHp} count=${detail.count} live=${liveNow} />` : null}
|
||||
${detail.type === 'token' ? html`<${TokenDetail} token=${detail.token} piece=${detail.piece} ownerId=${detail.ownerId ?? null} snapHp=${snapHp} live=${liveNow} />` : null}
|
||||
|
||||
@@ -11,15 +11,15 @@ import {
|
||||
|
||||
const cx = (...p) => p.flat().filter(Boolean).join(' ');
|
||||
|
||||
/** Data files the in-match screens use. */
|
||||
/** Data files the in-match screens use (`backups`: the 补位 stand-ins' bodies — cards, the board model, the detail card). */
|
||||
export const GAME_FILES = ['config', 'assets', 'chess', 'bonds', 'items', 'bands', 'enemies', 'bosses', 'stages', 'tokens',
|
||||
'choices', 'effects', 'garrisons', 'factions', 'local'];
|
||||
'choices', 'effects', 'garrisons', 'factions', 'local', 'backups'];
|
||||
|
||||
/**
|
||||
* Load every in-match data file; returns lookups (sync, null until loaded).
|
||||
* @returns {{ ready: boolean, m: any, config: any, chess: (id:string)=>any, bond: (id:string)=>any, item: (id:string)=>any,
|
||||
* band: (id:string)=>any, enemy: (k:string)=>any, boss: (id:string)=>any, stage: (id:string)=>any, token: (id:string)=>any,
|
||||
* effect: (id:string)=>any, garrison: (id:string)=>any, factions: any, choices: any, list: (name:string)=>any[] }}
|
||||
* effect: (id:string)=>any, garrison: (id:string)=>any, factions: any, choices: any, backups: any, list: (name:string)=>any[] }}
|
||||
*/
|
||||
export function useGameData() {
|
||||
const ready = useData(...GAME_FILES);
|
||||
@@ -44,6 +44,7 @@ export function makeLookups(ready = true) {
|
||||
garrison: (id) => data.lookup('garrisons', id),
|
||||
factions: data.get('factions'),
|
||||
choices: data.get('choices'),
|
||||
backups: data.get('backups'),
|
||||
list: (name) => data.list(name),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -41,4 +41,5 @@ 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 { panelSide, panelSlots, PANEL_RIGHT_GAP, PANEL_RIGHT_BOTTOM, PANEL_RIGHT_BOTTOM_SHOP, BPOP, bondPopupPlace } from './gameLogic/panel.js';
|
||||
@@ -1,13 +1,12 @@
|
||||
// ui/gameLogic/placement.js — deploy maps and the canPlace mirror of the server rules. Re-exported from ../gameLogic.js.
|
||||
|
||||
import { GEO } from '../../../../shared/constants.js';
|
||||
import { resolveLoadout } from '../../../../shared/protocol.js';
|
||||
import { attackRangeGrid } from '../../../../shared/loadoutRecord.js';
|
||||
import { meleeOnHighGround } from '../../../../shared/highGround.js';
|
||||
import { pieceDir, rangeTiles } from '../facing.js';
|
||||
import { isObj, tileKey } from './shared.js';
|
||||
import { boardTileOf, fieldTile } from './camera.js';
|
||||
import { chessLoadout } from './loadout.js';
|
||||
import { deployedRecord, deployedModuleId, fieldsStandIn, standInOf } from './standIn.js';
|
||||
|
||||
|
||||
// ---- placement (canPlace mirror) ------------------------------------------------------------------------
|
||||
@@ -194,7 +193,7 @@ export function indexPieces(priv) {
|
||||
* @param {{ priv:any, stage:any, editable:boolean, field?:'normal'|'bossL'|'bossR', getChess?:(id:string)=>any,
|
||||
* getToken?:(id:string)=>any, getItem?:(id:string)=>any, getEffect?:(id:string)=>any }} o
|
||||
*/
|
||||
export function placementContext({ priv, stage, editable, field = 'normal', getChess = () => null, getToken = () => null, getItem = () => null, getEffect = () => null }) {
|
||||
export function placementContext({ priv, stage, editable, field = 'normal', getChess = () => null, getToken = () => null, getItem = () => null, getEffect = () => null, backups = null }) {
|
||||
const pieces = indexPieces(priv);
|
||||
const boardAt = new Map();
|
||||
for (const e of pieces.values()) if (e.area === 'board') boardAt.set(tileKey(e.row, e.col), e);
|
||||
@@ -210,7 +209,7 @@ export function placementContext({ priv, stage, editable, field = 'normal', getC
|
||||
const deploy = field === 'bossL' || field === 'bossR'
|
||||
? deploySets(stage, field, ov)
|
||||
: deploySets(effectiveStage(stage, ov));
|
||||
return { priv, pieces, boardAt, handAt, deploy, cap, count, field, editable: !!editable, getChess, getToken, getItem };
|
||||
return { priv, pieces, boardAt, handAt, deploy, cap, count, field, editable: !!editable, getChess, getToken, getItem, backups };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -221,9 +220,10 @@ export function placementContext({ priv, stage, editable, field = 'normal', getC
|
||||
export function piecePosition(ctx, piece) {
|
||||
if (!isObj(piece)) return null;
|
||||
if (piece.kind === 'chess') {
|
||||
const rec = ctx.getChess(piece.id);
|
||||
let moduleId = null;
|
||||
try { moduleId = resolveLoadout(ctx.priv?.loadout ?? null, rec, ctx.getChess)?.moduleId ?? null; } catch { moduleId = null; }
|
||||
const chess = ctx.getChess(piece.id);
|
||||
// 0.2.0 补位: a chess the player fields as its stand-in is placed by the stand-in's position (server placeClass)
|
||||
const rec = (fieldsStandIn(ctx.priv, chess) && standInOf(chess, ctx.backups)) || chess;
|
||||
const moduleId = deployedModuleId(chess, ctx.priv, ctx.getChess, ctx.backups);
|
||||
if (meleeOnHighGround(rec, moduleId)) return 'ALL';
|
||||
return rec?.position === 'MELEE' ? 'MELEE' : 'RANGED';
|
||||
}
|
||||
@@ -256,7 +256,7 @@ export function summonRange(ctx, piece, owner = null) {
|
||||
const rec = at && ctx.getChess(at.piece.id);
|
||||
if (!rec) return null;
|
||||
let grid = null;
|
||||
try { grid = attackRangeGrid(chessLoadout(rec, ctx.priv?.loadout ?? null, ctx.getChess)?.record || rec); } catch { /* the data grid */ }
|
||||
try { grid = attackRangeGrid(deployedRecord(rec, ctx.priv, ctx.getChess, ctx.backups) || rec); } catch { /* the data grid */ }
|
||||
return new Set(rangeTiles(grid || rec.rangeGrid, at.row, at.col, pieceDir(at.piece)).map(([r, c]) => tileKey(r, c)));
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
// 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.
|
||||
|
||||
import { standInRecord } from '../../../../shared/standIn.js';
|
||||
import { resolveLoadout } from '../../../../shared/protocol.js';
|
||||
import { chessLoadout } from './loadout.js';
|
||||
import { isObj } from './shared.js';
|
||||
|
||||
/** Base chess ids the player fields as stand-ins in this match (m.private.standIns; [] when absent). */
|
||||
export function standInIds(priv) {
|
||||
const list = isObj(priv) && Array.isArray(priv.standIns) ? priv.standIns : [];
|
||||
return list.filter((x) => typeof x === 'string');
|
||||
}
|
||||
|
||||
/** Whether the player's own piece / card of chess record `chess` (normal or elite) fields its stand-in. */
|
||||
export function fieldsStandIn(priv, chess) {
|
||||
if (!isObj(chess) || typeof chess.chessId !== 'string') return false;
|
||||
const ids = standInIds(priv);
|
||||
return ids.length > 0 && ids.includes(chess.baseId || chess.chessId);
|
||||
}
|
||||
|
||||
/** Per backups object: chess record → composed stand-in record (or null). */
|
||||
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.
|
||||
* @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;
|
||||
let m = CACHE.get(backups);
|
||||
if (!m) { m = new WeakMap(); CACHE.set(backups, m); }
|
||||
if (m.has(chess)) return m.get(chess);
|
||||
let rec;
|
||||
try { rec = standInRecord(chess, backups); } catch { rec = null; }
|
||||
m.set(chess, rec);
|
||||
return rec;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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).
|
||||
* @param {any} chess @param {{ priv?: any, unit?: any, backups?: any }} o
|
||||
*/
|
||||
export function cardStandIn(chess, { priv = null, unit = null, backups = null } = {}) {
|
||||
if (!isObj(chess)) return null;
|
||||
const mine = unit ? typeof unit.standInFor === 'string' && !!unit.standInFor : fieldsStandIn(priv, chess);
|
||||
return mine ? standInOf(chess, backups) : 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) => {
|
||||
const c = getChess(id);
|
||||
return standInOf(c, backups) || c;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The skill / module / record a stand-in fights with (chessLoadout of the composed record, no player loadout —
|
||||
* "对于补位干员其技能不可更改"): its backup skill, the elite's backup module (or none).
|
||||
* @param {any} rec standInOf(...) @param {(id: string) => any} getChess @param {any} backups
|
||||
*/
|
||||
export function standInLoadout(rec, getChess, backups) {
|
||||
return rec ? chessLoadout(rec, null, standInGetter(getChess, backups)) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The record the player's own piece of `chess` is deployed with — the stand-in record when the player fields its
|
||||
* stand-in, else the chess as the player's loadout makes it (chessLoadout `.record`): its range (board overlay, deploy
|
||||
* wheel), its position (legal tiles), the board model.
|
||||
* @param {any} chess @param {any} priv m.private @param {(id: string) => any} getChess @param {any} backups
|
||||
*/
|
||||
export function deployedRecord(chess, priv, getChess, backups) {
|
||||
if (!isObj(chess)) return chess;
|
||||
if (fieldsStandIn(priv, chess)) {
|
||||
const si = standInOf(chess, backups);
|
||||
if (si) return standInLoadout(si, getChess, backups)?.record || si;
|
||||
}
|
||||
return chessLoadout(chess, priv?.loadout ?? null, getChess)?.record || chess;
|
||||
}
|
||||
|
||||
/**
|
||||
* The module id a deployed piece carries (the placement rule of elite 歌蕾蒂娅's HOK-Y reads it): a stand-in's backup
|
||||
* module, else the player's loadout.
|
||||
*/
|
||||
export function deployedModuleId(chess, priv, getChess, backups) {
|
||||
if (!isObj(chess)) return null;
|
||||
try {
|
||||
if (fieldsStandIn(priv, chess)) {
|
||||
const si = standInOf(chess, backups);
|
||||
if (si) return resolveLoadout(null, si, standInGetter(getChess, backups))?.moduleId ?? null;
|
||||
}
|
||||
return resolveLoadout(priv?.loadout ?? null, chess, getChess)?.moduleId ?? null;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
/** The 「替补:X」 badge text of a stand-in record (null without one). */
|
||||
export function standInLabel(rec) {
|
||||
return isObj(rec) && rec.standInFor && rec.name ? `替补:${rec.name}` : null;
|
||||
}
|
||||
+107
-43
@@ -1,19 +1,22 @@
|
||||
// Operator loadout state + server sync (DESIGN §16).
|
||||
// Operator loadout + ownership state and server sync (DESIGN §16; 干员持有 — 0.2.0 补位).
|
||||
//
|
||||
// `loadoutStore` holds the per-browser loadout (`entries`, persisted in localStorage through store.js savePref) and the
|
||||
// 干员调配 screen state (open / origin / selection / filters). `installLoadoutSync()` (called once by main.js) keeps the
|
||||
// server's copy current: after every `welcome` (new or resumed session — the server keeps it on the session and on the
|
||||
// seat, so joining a room needs no resend) and after every edit (debounced; a pending edit goes out at once when the
|
||||
// overlay closes), it sends `room.loadout { entries }` with
|
||||
// the entries sanitised against the loaded data/chess.json (ui/loadoutModel.js sanitizeEntries: a stale entry is
|
||||
// dropped, never the whole loadout). Replies: RATE → retried later; WRONG_PHASE / ROOM_STARTED → the running match has
|
||||
// locked its loadout (it applies to the next match, the server stored it) — not an error for the player; anything else
|
||||
// is logged. `sync.state` ∈ 'idle' | 'pending' | 'sending' | 'synced' | 'locked' | 'error' is mirrored into the store
|
||||
// for the screen's status line.
|
||||
// `loadoutStore` holds the per-browser loadout (`entries`, persisted in localStorage through store.js savePref), the
|
||||
// per-browser not-owned list of the 干员持有 tab (`notOwned`, likewise) and the overlay's screen state (open / origin /
|
||||
// tab / selection / filters). `installLoadoutSync()` / `installOwnershipSync()` (called once by main.js) keep the
|
||||
// server's copies current — one sync engine (installPrefSync), two settings: after every `welcome` (new or resumed
|
||||
// session — the server keeps them on the session and on the seat, so joining a room needs no resend) and after every
|
||||
// edit (debounced; a pending edit goes out at once when the overlay closes), they send `room.loadout { entries }`
|
||||
// (sanitised against the loaded data/chess.json: ui/loadoutModel.js sanitizeEntries — a stale entry is dropped, never
|
||||
// the whole loadout) and `room.ownership { notOwned }` (sent as stored: the server keeps the droppable chess and drops
|
||||
// the rest, so no data is needed). Replies: RATE → retried later; WRONG_PHASE / ROOM_STARTED → the running match keeps
|
||||
// what it took (the loadout locks when INFO_CHECK ends; the ownership never changes during a match) — stored for the
|
||||
// next match, not an error for the player; anything else is logged. `sync` / `ownSync` ∈ 'idle' | 'pending' |
|
||||
// 'sending' | 'synced' | 'locked' | 'error' are mirrored into the store for the screen's status line.
|
||||
|
||||
import { createStore, loadPref, savePref } from '../store.js';
|
||||
import { data } from '../data.js';
|
||||
import { LOADOUT_PREF, parseStored, toStored, sanitizeEntries } from './loadoutModel.js';
|
||||
import { OWNERSHIP_PREF, parseStoredOwnership, toStoredOwnership, cleanIds, sanitizeNotOwned } from './ownershipModel.js';
|
||||
import { toast } from './toasts.js';
|
||||
|
||||
export const SYNC_DEBOUNCE_MS = 500;
|
||||
@@ -22,15 +25,21 @@ export const RETRY_MS = 1500;
|
||||
function readStored() {
|
||||
try { return parseStored(loadPref(LOADOUT_PREF, null)); } catch { return {}; }
|
||||
}
|
||||
function readStoredOwnership() {
|
||||
try { return parseStoredOwnership(loadPref(OWNERSHIP_PREF, null)); } catch { return []; }
|
||||
}
|
||||
|
||||
/** Loadout + screen state (separate from the app store: it must survive room / match resets). */
|
||||
/** Loadout + ownership + screen state (separate from the app store: it must survive room / match resets). */
|
||||
export const loadoutStore = createStore({
|
||||
entries: readStored(),
|
||||
notOwned: readStoredOwnership(), // 干员持有: base chess ids marked 未持有 (sorted; [] = every operator owned)
|
||||
open: false,
|
||||
from: null, // 'lobby' | 'room' | 'briefing'
|
||||
tab: 'loadout', // 'loadout' (干员调配) | 'ownership' (干员持有)
|
||||
sel: null, // selected base chess id
|
||||
filters: { tier: null, prof: null, bond: null, query: '', changedOnly: false },
|
||||
sync: 'idle',
|
||||
ownSync: 'idle',
|
||||
});
|
||||
|
||||
/** Replace the stored entries (persisted at once; the sync picks the change up). */
|
||||
@@ -57,35 +66,56 @@ export function applyLoadoutEntries(entries, lookup) {
|
||||
return { applied, dropped: Math.max(0, asked - applied) };
|
||||
}
|
||||
|
||||
/** Open the 干員调配 screen. @param {'lobby'|'room'|'briefing'} from @param {string|null} [sel] */
|
||||
export function openLoadout(from = 'lobby', sel = null) {
|
||||
/** Replace the stored not-owned list (干员持有; persisted at once, the sync picks the change up). */
|
||||
export function setNotOwned(list) {
|
||||
const next = cleanIds(list);
|
||||
savePref(OWNERSHIP_PREF, toStoredOwnership(next));
|
||||
loadoutStore.set({ notOwned: next });
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply an imported not-owned list (parseOwnershipImport(...).notOwned), sanitised against the loaded data (droppable
|
||||
* chess only), persisted and synced like an edit. An empty list is a valid import (= every operator owned).
|
||||
* @param {string[]} list @param {(id: string) => any} lookup chess lookup
|
||||
* @returns {{ applied: number, dropped: number }}
|
||||
*/
|
||||
export function applyOwnershipImport(list, lookup) {
|
||||
const asked = cleanIds(list).length;
|
||||
const clean = sanitizeNotOwned(list, lookup);
|
||||
setNotOwned(clean);
|
||||
return { applied: clean.length, dropped: Math.max(0, asked - clean.length) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the 干员调配 overlay. @param {'lobby'|'room'|'briefing'} from @param {string|null} [sel]
|
||||
* @param {'loadout'|'ownership'|null} [tab] the tab to show (default: the last one)
|
||||
*/
|
||||
export function openLoadout(from = 'lobby', sel = null, tab = null) {
|
||||
data.load('chess');
|
||||
data.load('bonds');
|
||||
data.load('assets');
|
||||
data.load('local');
|
||||
loadoutStore.set({ open: true, from, ...(sel ? { sel } : {}) });
|
||||
data.load('backups');
|
||||
loadoutStore.set({ open: true, from, ...(sel ? { sel } : {}), ...(tab === 'loadout' || tab === 'ownership' ? { tab } : {}) });
|
||||
}
|
||||
export const closeLoadout = () => loadoutStore.set({ open: false });
|
||||
|
||||
/**
|
||||
* Wire the sync once. Dependencies are injectable for tests.
|
||||
* @param {{ net: any, getChessReady?: () => Promise<any>, lookupChess?: (id: string) => any,
|
||||
* timers?: { setTimeout: Function, clearTimeout: Function }, target?: ReturnType<typeof createStore> }} deps
|
||||
* @returns {{ flush: () => Promise<void>, dispose: () => void }}
|
||||
* One setting kept in sync with the server (see the header): `key` = the store field, `stateKey` = its sync state field,
|
||||
* `msgType` / `field` = the C2S message, `prepare()` → the payload to send (may await data; null = the data is missing:
|
||||
* nothing is sent, state 'error'), `lockedText` = what a refused edit tells the player.
|
||||
*/
|
||||
export function installLoadoutSync({ net, getChessReady, lookupChess, timers, target = loadoutStore, notify } = {}) {
|
||||
function installPrefSync({ net, timers, target, notify, key, stateKey, msgType, field, prepare, lockedText, tag }) {
|
||||
const T = timers || { setTimeout: (fn, ms) => globalThis.setTimeout(fn, ms), clearTimeout: (id) => globalThis.clearTimeout(id) };
|
||||
const ready = getChessReady || (() => data.load('chess'));
|
||||
const lookup = lookupChess || ((id) => data.lookup('chess', id));
|
||||
const tell = notify || ((text) => toast(text, 'warn'));
|
||||
let timer = null;
|
||||
let seq = 0; // room.loadout requests sent (the reply of an older one never overrides a newer one's state)
|
||||
let seq = 0; // requests sent (the reply of an older one never overrides a newer one's state)
|
||||
let pendingJson = null; // JSON of the newest request still awaiting its reply
|
||||
let lastSent = null; // JSON of the last entries the server accepted (on this session)
|
||||
let lastSent = null; // JSON of the last payload the server accepted (on this session)
|
||||
let edited = false; // an edit is waiting to be sent (a lock refusal is then worth telling the player)
|
||||
let disposed = false;
|
||||
|
||||
const setState = (sync) => { if (target.get().sync !== sync) target.set({ sync }); };
|
||||
const setState = (sync) => { if (target.get()[stateKey] !== sync) target.set({ [stateKey]: sync }); };
|
||||
|
||||
const schedule = (ms = SYNC_DEBOUNCE_MS) => {
|
||||
if (disposed) return;
|
||||
@@ -95,22 +125,17 @@ export function installLoadoutSync({ net, getChessReady, lookupChess, timers, ta
|
||||
};
|
||||
|
||||
// Review fix: a send is never held back behind one still in flight. The socket is ordered and the server applies
|
||||
// room.loadout frames in order, so the newest entries always win; holding the edit until the previous reply arrived let
|
||||
// a click right after closing the overlay (准备就绪 → INFO_CHECK ends) overtake it, and the edit silently missed the match.
|
||||
// the frames in order, so the newest payload always wins; holding the edit until the previous reply arrived let a
|
||||
// click right after closing the overlay (准备就绪 → INFO_CHECK ends) overtake it, and the edit silently missed the match.
|
||||
async function flush() {
|
||||
if (disposed) return;
|
||||
if (net.status !== 'online') { setState('idle'); return; } // the next welcome resends
|
||||
try {
|
||||
const current = target.get().entries;
|
||||
// an empty loadout needs no data (nothing to sanitise): a player who never opened 干员调配 does not download
|
||||
// chess.json in the lobby just for this
|
||||
const empty = !current || Object.keys(current).length === 0;
|
||||
const loaded = empty ? true : await ready();
|
||||
const payload = await prepare();
|
||||
if (disposed) return;
|
||||
// never sanitise against missing data: every entry would be dropped and the server's copy cleared
|
||||
if (loaded == null) { setState('error'); return; }
|
||||
const entries = empty ? {} : sanitizeEntries(target.get().entries, lookup);
|
||||
const json = JSON.stringify(entries);
|
||||
if (payload == null) { setState('error'); return; }
|
||||
const json = JSON.stringify(payload);
|
||||
if (json === pendingJson) return; // the same content is already on its way
|
||||
if (json === lastSent && pendingJson == null) { edited = false; setState('synced'); return; }
|
||||
const my = ++seq;
|
||||
@@ -119,7 +144,7 @@ export function installLoadoutSync({ net, getChessReady, lookupChess, timers, ta
|
||||
pendingJson = json;
|
||||
setState('sending');
|
||||
try {
|
||||
await net.request('room.loadout', { entries });
|
||||
await net.request(msgType, { [field]: payload });
|
||||
if (my !== seq) return;
|
||||
pendingJson = null;
|
||||
lastSent = json;
|
||||
@@ -129,29 +154,29 @@ export function installLoadoutSync({ net, getChessReady, lookupChess, timers, ta
|
||||
pendingJson = null;
|
||||
const code = err && err.code;
|
||||
if (code === 'WRONG_PHASE' || code === 'ROOM_STARTED') {
|
||||
// the server stored it for the next match; the running one keeps the loadout it locked
|
||||
// the server stored it for the next match; the running one keeps what it took
|
||||
lastSent = json;
|
||||
setState('locked');
|
||||
if (wasEdit) tell('本局的干员调配已锁定,修改将在下一局生效');
|
||||
if (wasEdit) tell(lockedText);
|
||||
} else if (code === 'RATE' || code === 'TIMEOUT' || code === 'OFFLINE') { edited = edited || wasEdit; schedule(RETRY_MS); }
|
||||
else { console.warn('[loadout] room.loadout refused', code, err && err.detail); setState('error'); }
|
||||
else { console.warn(`[${tag}] ${msgType} refused`, code, err && err.detail); setState('error'); }
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[loadout] sync failed', e);
|
||||
console.warn(`[${tag}] sync failed`, e);
|
||||
setState('error');
|
||||
}
|
||||
}
|
||||
|
||||
const offWelcome = net.on('welcome', () => { lastSent = null; pendingJson = null; seq++; schedule(50); });
|
||||
const offStore = target.subscribe((s, prev) => {
|
||||
if (s.entries !== prev.entries) { edited = true; schedule(); }
|
||||
if (s[key] !== prev[key]) { edited = true; schedule(); }
|
||||
// closing the overlay sends a pending edit at once (review fix): the player's next click — 准备就绪 in the solo
|
||||
// briefing, 开始模拟 in the room — must not overtake the debounced room.loadout (the match locks its loadout when
|
||||
// briefing, 开始模拟 in the room — must not overtake the debounced message (the match locks its loadout when
|
||||
// INFO_CHECK ends, so a late edit would silently only apply to the next match). Same socket ⇒ ordered.
|
||||
if (prev.open && !s.open && timer != null) { T.clearTimeout(timer); timer = null; void flush(); }
|
||||
});
|
||||
// a match leaving INFO_CHECK locks the loadout; a new match (the room back in LOBBY / a new INFO_CHECK) accepts it again
|
||||
const offRoom = net.on('room.state', (msg) => { if (msg && !msg.inMatch && target.get().sync === 'locked') { lastSent = null; schedule(); } });
|
||||
const offRoom = net.on('room.state', (msg) => { if (msg && !msg.inMatch && target.get()[stateKey] === 'locked') { lastSent = null; schedule(); } });
|
||||
|
||||
return {
|
||||
flush,
|
||||
@@ -164,3 +189,42 @@ export function installLoadoutSync({ net, getChessReady, lookupChess, timers, ta
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Wire the loadout sync once. Dependencies are injectable for tests.
|
||||
* @param {{ net: any, getChessReady?: () => Promise<any>, lookupChess?: (id: string) => any,
|
||||
* timers?: { setTimeout: Function, clearTimeout: Function }, target?: ReturnType<typeof createStore> }} deps
|
||||
* @returns {{ flush: () => Promise<void>, dispose: () => void }}
|
||||
*/
|
||||
export function installLoadoutSync({ net, getChessReady, lookupChess, timers, target = loadoutStore, notify } = {}) {
|
||||
const ready = getChessReady || (() => data.load('chess'));
|
||||
const lookup = lookupChess || ((id) => data.lookup('chess', id));
|
||||
return installPrefSync({
|
||||
net, timers, target, notify, key: 'entries', stateKey: 'sync', msgType: 'room.loadout', field: 'entries', tag: 'loadout',
|
||||
lockedText: '本局的干员调配已锁定,修改将在下一局生效',
|
||||
async prepare() {
|
||||
const current = target.get().entries;
|
||||
// an empty loadout needs no data (nothing to sanitise): a player who never opened 干员调配 does not download
|
||||
// chess.json in the lobby just for this
|
||||
if (!current || Object.keys(current).length === 0) return {};
|
||||
const loaded = await ready();
|
||||
if (loaded == null) return null;
|
||||
return sanitizeEntries(target.get().entries, lookup);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Wire the 干员持有 sync once (0.2.0 补位): `room.ownership { notOwned }` after every welcome and edit. The list goes as
|
||||
* stored (structurally clean); the server keeps its droppable chess. During a match the server stores it for the next
|
||||
* one (ROOM_STARTED → 'locked'): the setting is out of match.
|
||||
* @param {{ net: any, timers?: { setTimeout: Function, clearTimeout: Function }, target?: ReturnType<typeof createStore>,
|
||||
* notify?: (text: string) => void }} deps
|
||||
*/
|
||||
export function installOwnershipSync({ net, timers, target = loadoutStore, notify } = {}) {
|
||||
return installPrefSync({
|
||||
net, timers, target, notify, key: 'notOwned', stateKey: 'ownSync', msgType: 'room.ownership', field: 'notOwned', tag: 'ownership',
|
||||
lockedText: '干员持有是局外设置,修改将在下一局生效',
|
||||
prepare: async () => cleanIds(target.get().notOwned),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
// 干员持有 (operator ownership, 0.2.0 补位) — pure logic of the 干员持有 tab of the 干员调配 overlay (screens/ownership.js),
|
||||
// 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).
|
||||
//
|
||||
// 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
|
||||
// rest — shared/protocol.js checkNotOwned — so a stale list never gets refused). 导出 / 导入 use a versioned envelope
|
||||
// like the loadout presets.
|
||||
|
||||
import { checkNotOwned, OWNERSHIP_LIMITS } from '../../../shared/protocol.js';
|
||||
import { isDroppableChess, standInRecord } from '../../../shared/standIn.js';
|
||||
|
||||
/** localStorage key (store.js loadPref/savePref prefix `sp.pref.`) and format version. */
|
||||
export const OWNERSHIP_PREF = 'ownership';
|
||||
export const OWNERSHIP_VERSION = 1;
|
||||
/** `kind` of an exported ownership envelope. */
|
||||
export const OWNERSHIP_EXPORT_KIND = 'stronghold.ownership';
|
||||
/** A picked file / pasted payload longer than this is refused before parsing (a real payload is under 4 KB). */
|
||||
export const OWNERSHIP_IMPORT_MAX_BYTES = 64 * 1024;
|
||||
|
||||
const ID_RE = /^[A-Za-z0-9_\-.:]{1,64}$/;
|
||||
const UNSAFE_IDS = new Set(['__proto__', 'constructor', 'prototype']);
|
||||
const isObj = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
|
||||
|
||||
/**
|
||||
* Structurally valid, deduplicated, sorted ids of a raw list (junk dropped; at most OWNERSHIP_LIMITS.notOwned).
|
||||
* @param {any} list
|
||||
* @returns {string[]}
|
||||
*/
|
||||
export function cleanIds(list) {
|
||||
const out = new Set();
|
||||
for (const id of Array.isArray(list) ? list : []) {
|
||||
if (out.size >= OWNERSHIP_LIMITS.notOwned) break;
|
||||
if (typeof id === 'string' && ID_RE.test(id) && !UNSAFE_IDS.has(id)) out.add(id);
|
||||
}
|
||||
return [...out].sort();
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a stored setting (any junk → []): `{ v, notOwned }` or a bare array from an older build.
|
||||
* @param {any} raw
|
||||
* @returns {string[]}
|
||||
*/
|
||||
export function parseStoredOwnership(raw) {
|
||||
if (Array.isArray(raw)) return cleanIds(raw);
|
||||
return isObj(raw) ? cleanIds(raw.notOwned) : [];
|
||||
}
|
||||
|
||||
/** Serialised form for localStorage. */
|
||||
export const toStoredOwnership = (notOwned) => ({ v: OWNERSHIP_VERSION, notOwned: cleanIds(notOwned) });
|
||||
|
||||
/**
|
||||
* The ids to keep / send against the loaded data: droppable chess only (checkNotOwned), sorted.
|
||||
* @param {any} notOwned @param {(id: string) => any} getChess
|
||||
* @returns {string[]}
|
||||
*/
|
||||
export function sanitizeNotOwned(notOwned, getChess) {
|
||||
const res = checkNotOwned(cleanIds(notOwned), getChess);
|
||||
return res && res.ok ? res.notOwned : [];
|
||||
}
|
||||
|
||||
/** Whether a chess is owned under a not-owned list (its base id is absent). */
|
||||
export function isOwned(notOwned, chessId) {
|
||||
return !(Array.isArray(notOwned) && notOwned.includes(chessId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle one chess: owned ↔ not owned. Returns a new sorted list (the input is not changed).
|
||||
* @param {string[]} notOwned @param {string} chessId base chess id
|
||||
* @param {boolean} [owned] the wanted state (default: the other one)
|
||||
*/
|
||||
export function setOwned(notOwned, chessId, owned = !isOwned(notOwned, chessId)) {
|
||||
const set = new Set(cleanIds(notOwned));
|
||||
if (owned) set.delete(chessId); else set.add(chessId);
|
||||
return [...set].sort();
|
||||
}
|
||||
|
||||
// ---- roster -------------------------------------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The chess the screen lists: droppable chess in this season's shop (visible), in shop order (tier, shopSortId).
|
||||
* @param {any[]} list data.list('chess')
|
||||
*/
|
||||
export function ownershipRoster(list) {
|
||||
return (Array.isArray(list) ? list : [])
|
||||
.filter((c) => isDroppableChess(c) && c.visible !== false && !c.isHidden)
|
||||
.sort((a, b) => (a.tier ?? 0) - (b.tier ?? 0) || (a.shopSortId ?? 0) - (b.shopSortId ?? 0) || String(a.chessId).localeCompare(String(b.chessId)));
|
||||
}
|
||||
|
||||
/** The roster grouped by tier: [{ tier, list }] in tier order. */
|
||||
export function rosterByTier(roster) {
|
||||
const by = new Map();
|
||||
for (const c of roster) {
|
||||
if (!by.has(c.tier)) by.set(c.tier, []);
|
||||
by.get(c.tier).push(c);
|
||||
}
|
||||
return [...by.entries()].sort((a, b) => a[0] - b[0]).map(([tier, list]) => ({ tier, list }));
|
||||
}
|
||||
|
||||
/**
|
||||
* What the screen shows of a chess's stand-in: the composed record (shared/standIn.js — the stand-in at the chess's
|
||||
* normal status with its backup skill) reduced to the card's fields; null without the data.
|
||||
* @param {any} chess normal chess record @param {any} backups data/backups.json
|
||||
* @returns {{ charId: string, name: string, appellation: string, profession: string, subProfessionName: string,
|
||||
* rarity: number, skill: any, record: any } | null}
|
||||
*/
|
||||
export function standInSummary(chess, backups) {
|
||||
let rec;
|
||||
try { rec = standInRecord(chess, backups); } catch { rec = null; }
|
||||
if (!rec) return null;
|
||||
return {
|
||||
charId: rec.charId, name: rec.name, appellation: rec.appellation || '', profession: rec.profession,
|
||||
subProfessionName: rec.subProfessionName || '', rarity: rec.rarity, skill: rec.skill, record: rec,
|
||||
};
|
||||
}
|
||||
|
||||
/** Number of listed chess marked as not owned (ids of retired / unknown chess do not count). */
|
||||
export function notOwnedCount(notOwned, roster) {
|
||||
const ids = new Set(Array.isArray(notOwned) ? notOwned : []);
|
||||
let n = 0;
|
||||
for (const c of roster || []) if (ids.has(c.chessId)) n++;
|
||||
return n;
|
||||
}
|
||||
|
||||
// ---- export / import -------------------------------------------------------------------------------------------------
|
||||
|
||||
/** Portable payload of a not-owned list, as downloaded / copied by 导出. */
|
||||
export function exportOwnership(notOwned, { now = Date.now() } = {}) {
|
||||
const list = cleanIds(notOwned);
|
||||
return {
|
||||
kind: OWNERSHIP_EXPORT_KIND,
|
||||
v: OWNERSHIP_VERSION,
|
||||
exportedAt: new Date(Number.isFinite(now) ? now : Date.now()).toISOString(),
|
||||
count: list.length,
|
||||
notOwned: list,
|
||||
};
|
||||
}
|
||||
|
||||
/** Pretty JSON of `exportOwnership`. */
|
||||
export const serializeOwnership = (notOwned, opts) => JSON.stringify(exportOwnership(notOwned, opts), null, 2);
|
||||
|
||||
/**
|
||||
* Parse an imported list: the envelope, the stored `{ v, notOwned }` form, a bare array of ids, or the text of any of
|
||||
* them. STRUCTURAL only — the caller sanitises against the loaded data (sanitizeNotOwned). An envelope may hold an empty
|
||||
* list (= every operator owned).
|
||||
* @param {any} input payload or text
|
||||
* @returns {{ ok: true, notOwned: string[] } | { ok: false, error: string }}
|
||||
*/
|
||||
export function parseOwnershipImport(input) {
|
||||
let raw = input;
|
||||
if (typeof raw === 'string') {
|
||||
if (raw.length > OWNERSHIP_IMPORT_MAX_BYTES) return { ok: false, error: '内容过长,无法导入' };
|
||||
const text = raw.trim();
|
||||
if (!text) return { ok: false, error: '没有可导入的内容' };
|
||||
try { raw = JSON.parse(text); } catch { return { ok: false, error: '无法识别的内容' }; }
|
||||
}
|
||||
if (Array.isArray(raw)) return { ok: true, notOwned: cleanIds(raw) };
|
||||
if (!isObj(raw)) return { ok: false, error: '无法识别的格式' };
|
||||
const v = Number.isInteger(raw.v) ? raw.v : null;
|
||||
if (v != null && v > OWNERSHIP_VERSION) return { ok: false, error: `这份数据来自更新的版本(v${v}),请先更新游戏` };
|
||||
const kind = typeof raw.kind === 'string' ? raw.kind : null;
|
||||
if (kind && kind !== OWNERSHIP_EXPORT_KIND) return { ok: false, error: '这不是干员持有的数据' };
|
||||
if (!Array.isArray(raw.notOwned)) return { ok: false, error: '里面没有干员持有的数据' };
|
||||
return { ok: true, notOwned: cleanIds(raw.notOwned) };
|
||||
}
|
||||
+12
-7
@@ -17,12 +17,14 @@
|
||||
// item slot drawn as an item card (player report #6 after 0.1.0).
|
||||
// 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).
|
||||
// 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).
|
||||
|
||||
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 } from './gameLogic.js';
|
||||
import { priceTone, mergeProgress, mergeTarget, shopBlockReason, chessLoadout, offerHeader, briefingBondTip, fieldsStandIn, standInOf, standInLoadout, standInLabel } from './gameLogic.js';
|
||||
import { chessPortraitUrl, itemIconUrl, profIconUrl, uiUrl, skillIconUrl, skillRecordIconUrl, moduleTypeIconUrl } from './assetUrls.js';
|
||||
import { data } from '../data.js';
|
||||
|
||||
@@ -70,7 +72,9 @@ 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;
|
||||
const lo = c ? chessLoadout(c, priv?.loadout, LOOKUPS.getChess) : null;
|
||||
// 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, 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')}
|
||||
onClick=${tap} onContextMenu=${(e) => { e.preventDefault(); onDetail(slot.id, 'chess', hint); }}
|
||||
@@ -85,8 +89,9 @@ 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}
|
||||
<span class="scard__body">
|
||||
${lo?.skill ? html`<${SkillBadge} chess=${c} lo=${lo} />` : null}
|
||||
${lo?.skill ? html`<${SkillBadge} chess=${si || 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) => {
|
||||
@@ -108,14 +113,14 @@ export function ChessCard({ slot, idx, priv, frozen = false, reason = null, free
|
||||
}
|
||||
|
||||
/** The loadout's skill (icon; name + 已调配 in the tooltip) and an elite's module type, on an operator card. */
|
||||
function SkillBadge({ chess, lo }) {
|
||||
function SkillBadge({ chess, lo, standIn = false }) {
|
||||
const m = data.get('assets');
|
||||
const custom = !lo.defaultSkill;
|
||||
const custom = !lo.defaultSkill && !standIn;
|
||||
// a chosen skill without an icon in the manifest: its slot letter (S1–S3) instead of the blank skill sprite
|
||||
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 = `技能${slot ? ` ${slot}` : ''}:${lo.skill.name || ''}${custom ? '(已调配)' : ''}${mod ? ` · 模组:${mod.name}` : lo.module?.none ? ' · 未装备模组' : ''}`;
|
||||
const tip = `${standIn ? `替补 ${chess.name} · ` : ''}技能${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}
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
// 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`.
|
||||
import { test, describe } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
|
||||
globalThis.fetch = async (url) => {
|
||||
const name = String(url).split('/').pop();
|
||||
try {
|
||||
const body = readFileSync(path.join(ROOT, 'data', name), 'utf8');
|
||||
return { ok: true, status: 200, json: async () => JSON.parse(body) };
|
||||
} catch {
|
||||
return { ok: false, status: 404, json: async () => ({}) };
|
||||
}
|
||||
};
|
||||
|
||||
const M = await import('../../public/js/ui/ownershipModel.js');
|
||||
const { installOwnershipSync, SYNC_DEBOUNCE_MS } = await import('../../public/js/ui/loadoutSync.js');
|
||||
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 { 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');
|
||||
const { DATA } = await import('../match/harness.js');
|
||||
|
||||
await data.loadAll('chess', 'bonds', 'assets', 'garrisons', 'items', 'backups');
|
||||
|
||||
const getChess = (id) => (Object.hasOwn(DATA.chess, id) ? DATA.chess[id] : null);
|
||||
const BACKUPS = DATA.backups;
|
||||
const SILVER = 'chess_char_4_22_a'; // 银灰 → Sharp (S2 亮剑)
|
||||
const MLYSS = 'chess_char_6_11_a'; // 缪尔赛思 (RANGED) → 郁金香 (MELEE)
|
||||
const SARIA = 'chess_char_5_11_a'; // 塞雷娅 (MELEE) → Touch (RANGED)
|
||||
const CATHY = 'chess_char_4_11_a'; // 凯瑟琳 (PRESET)
|
||||
|
||||
function* walk(v) {
|
||||
if (Array.isArray(v)) { for (const x of v) yield* walk(x); return; }
|
||||
if (!v || typeof v !== 'object') return;
|
||||
yield v;
|
||||
yield* walk(v.props?.children);
|
||||
}
|
||||
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('');
|
||||
|
||||
describe('干员持有 model (ui/ownershipModel.js)', () => {
|
||||
test('storage: tolerant parse, clean sorted ids, round trip; hostile keys dropped', () => {
|
||||
assert.deepEqual(M.parseStoredOwnership(null), []);
|
||||
assert.deepEqual(M.parseStoredOwnership({ v: 1, notOwned: [SILVER, 'bad id', SILVER, '__proto__', 7, SARIA] }), [SILVER, SARIA].sort());
|
||||
assert.deepEqual(M.parseStoredOwnership([MLYSS]), [MLYSS], 'a bare array of an older build');
|
||||
assert.deepEqual(M.toStoredOwnership([SARIA, SILVER]), { v: M.OWNERSHIP_VERSION, notOwned: [SILVER, SARIA].sort() });
|
||||
assert.deepEqual(M.sanitizeNotOwned([SILVER, CATHY, 'chess_char_9_99_a', getChess(SILVER).goldenId], getChess), [SILVER], 'droppable chess only');
|
||||
});
|
||||
test('toggles: setOwned / isOwned return new sorted lists', () => {
|
||||
let list = [];
|
||||
list = M.setOwned(list, SARIA, false);
|
||||
list = M.setOwned(list, SILVER);
|
||||
assert.deepEqual(list, [SILVER, SARIA].sort());
|
||||
assert.equal(M.isOwned(list, SILVER), false);
|
||||
assert.equal(M.isOwned(list, MLYSS), true);
|
||||
assert.deepEqual(M.setOwned(list, SILVER, true), [SARIA]);
|
||||
assert.deepEqual(list, [SILVER, SARIA].sort(), 'input unchanged');
|
||||
});
|
||||
test('roster: the 53 NORMAL chess of the shop by tier (6 / 13 / 17 / 17); each has its stand-in summary', () => {
|
||||
const roster = M.ownershipRoster(data.list('chess'));
|
||||
assert.equal(roster.length, 53);
|
||||
assert.ok(!roster.some((c) => c.chessType !== 'NORMAL' || c.isGolden || !c.visible));
|
||||
assert.deepEqual(M.rosterByTier(roster).map((g) => [g.tier, g.list.length]), [[3, 6], [4, 13], [5, 17], [6, 17]]);
|
||||
for (const c of roster) {
|
||||
const si = M.standInSummary(c, BACKUPS);
|
||||
assert.ok(si && si.charId === c.backup.charId && si.skill.index === c.backup.skillIndex, c.chessId);
|
||||
}
|
||||
assert.equal(M.standInSummary(getChess(SILVER), BACKUPS).name, 'Sharp');
|
||||
assert.equal(M.notOwnedCount([SILVER, CATHY, 'chess_char_6_10_a'], roster), 1, 'PRESET / retired ids do not count');
|
||||
});
|
||||
test('export / import: versioned envelope, bare arrays and text; junk, newer versions and other kinds refused', () => {
|
||||
const text = M.serializeOwnership([SARIA, SILVER], { now: 0 });
|
||||
const env = JSON.parse(text);
|
||||
assert.deepEqual([env.kind, env.v, env.count, env.notOwned], [M.OWNERSHIP_EXPORT_KIND, 1, 2, [SILVER, SARIA].sort()]);
|
||||
assert.deepEqual(M.parseOwnershipImport(text), { ok: true, notOwned: [SILVER, SARIA].sort() });
|
||||
assert.deepEqual(M.parseOwnershipImport([MLYSS]), { ok: true, notOwned: [MLYSS] });
|
||||
assert.deepEqual(M.parseOwnershipImport({ v: 1, notOwned: [] }), { ok: true, notOwned: [] }, 'an empty list = everything owned');
|
||||
assert.equal(M.parseOwnershipImport('').ok, false);
|
||||
assert.equal(M.parseOwnershipImport('{nope').ok, false);
|
||||
assert.equal(M.parseOwnershipImport({ v: 9, notOwned: [] }).ok, false);
|
||||
assert.equal(M.parseOwnershipImport({ kind: 'stronghold.loadout', v: 1, entries: {} }).ok, false);
|
||||
assert.equal(M.parseOwnershipImport('x'.repeat(M.OWNERSHIP_IMPORT_MAX_BYTES + 1)).ok, false);
|
||||
});
|
||||
test('the 干员持有 card is a switch: operator → stand-in; a tap asks for the other state', () => {
|
||||
const calls = [];
|
||||
const card = OwnCard({ m: data.get('assets'), chess: getChess(SILVER), backups: BACKUPS, owned: true, onToggle: (id, owned) => calls.push([id, owned]) });
|
||||
assert.equal(card.props.role, 'switch');
|
||||
assert.equal(card.props['aria-checked'], 'true');
|
||||
assert.ok(textOf(card).includes('银灰') && textOf(card).includes('Sharp') && textOf(card).includes('S2'));
|
||||
card.props.onClick();
|
||||
assert.deepEqual(calls, [[SILVER, false]]);
|
||||
const off = OwnCard({ m: null, chess: getChess(SILVER), backups: BACKUPS, owned: false, onToggle() {} });
|
||||
assert.ok(hasClass(off, 'is-off'));
|
||||
assert.equal(off.props['aria-checked'], 'false');
|
||||
});
|
||||
});
|
||||
|
||||
// ---- sync -------------------------------------------------------------------------------------------------------------
|
||||
|
||||
function fakeNet() {
|
||||
const listeners = new Map();
|
||||
const net = {
|
||||
status: 'online', sent: [], replies: [],
|
||||
on(t, fn) { if (!listeners.has(t)) listeners.set(t, new Set()); listeners.get(t).add(fn); return () => listeners.get(t).delete(fn); },
|
||||
emit(t, msg) { for (const fn of listeners.get(t) || []) fn(msg); },
|
||||
request(t, fields) {
|
||||
net.sent.push({ t, ...fields });
|
||||
const r = net.replies.shift();
|
||||
return r && r.error ? Promise.reject(Object.assign(new Error(r.error), { code: r.error })) : Promise.resolve({ t: 'ok' });
|
||||
},
|
||||
};
|
||||
return net;
|
||||
}
|
||||
function fakeTimers() {
|
||||
let now = 0;
|
||||
let seq = 0;
|
||||
const q = new Map();
|
||||
return {
|
||||
setTimeout: (fn, ms) => { const id = ++seq; q.set(id, { at: now + ms, fn }); return id; },
|
||||
clearTimeout: (id) => q.delete(id),
|
||||
async advance(ms) {
|
||||
const end = now + ms;
|
||||
for (;;) {
|
||||
const next = [...q.entries()].filter(([, t]) => t.at <= end).sort((a, b) => a[1].at - b[1].at)[0];
|
||||
if (!next) break;
|
||||
q.delete(next[0]);
|
||||
now = next[1].at;
|
||||
next[1].fn();
|
||||
for (let i = 0; i < 5; i++) await Promise.resolve();
|
||||
}
|
||||
now = end;
|
||||
for (let i = 0; i < 5; i++) await Promise.resolve();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('ownership sync: room.ownership after welcome and after edits (debounced); in a match stored for the next one (told once)', async () => {
|
||||
const net = fakeNet();
|
||||
const T = fakeTimers();
|
||||
const told = [];
|
||||
const target = createStore({ notOwned: [SILVER], open: false, ownSync: 'idle' });
|
||||
const s = installOwnershipSync({ net, timers: T, target, notify: (t) => told.push(t) });
|
||||
net.emit('welcome', {});
|
||||
await T.advance(100);
|
||||
assert.deepEqual(net.sent, [{ t: 'room.ownership', notOwned: [SILVER] }]);
|
||||
assert.equal(target.get().ownSync, 'synced');
|
||||
target.set({ notOwned: [SILVER, SARIA].sort() });
|
||||
await T.advance(SYNC_DEBOUNCE_MS + 10);
|
||||
assert.deepEqual(net.sent[1], { t: 'room.ownership', notOwned: [SILVER, SARIA].sort() });
|
||||
// a running match: ROOM_STARTED = stored for the next match, not an error; the edit is told once
|
||||
net.replies.push({ error: 'ROOM_STARTED' });
|
||||
target.set({ notOwned: [] });
|
||||
await T.advance(SYNC_DEBOUNCE_MS + 10);
|
||||
assert.equal(target.get().ownSync, 'locked');
|
||||
assert.equal(told.length, 1);
|
||||
assert.match(told[0], /下一局生效/);
|
||||
// back in the lobby: sent again (accepted)
|
||||
net.emit('room.state', { inMatch: false });
|
||||
await T.advance(SYNC_DEBOUNCE_MS + 10);
|
||||
assert.deepEqual(net.sent.at(-1), { t: 'room.ownership', notOwned: [] });
|
||||
assert.equal(target.get().ownSync, 'synced');
|
||||
s.dispose();
|
||||
});
|
||||
|
||||
// ---- match UI -------------------------------------------------------------------------------------------------------------
|
||||
|
||||
describe('match UI of a not-owned chess', () => {
|
||||
const priv = { standIns: [SILVER, MLYSS, SARIA], loadout: {}, board: [], hand: [], temp: [] };
|
||||
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);
|
||||
assert.equal(G.fieldsStandIn(priv, getChess(getChess(SILVER).goldenId)), true, 'the elite too');
|
||||
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.equal(G.standInOf(getChess(SILVER), BACKUPS), si, 'cached');
|
||||
assert.equal(G.standInOf(getChess(CATHY), BACKUPS), null, 'PRESET');
|
||||
assert.equal(G.standInLabel(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), { 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)');
|
||||
});
|
||||
|
||||
test('placement: a dropped chess is placed by its stand-in\'s position (缪尔赛思 → 郁金香 melee, 塞雷娅 → Touch ranged)', () => {
|
||||
const stage = null; // (the deploy tiles are not needed for the placement class)
|
||||
const ctx = G.placementContext({ priv, stage, editable: true, getChess, backups: BACKUPS });
|
||||
assert.equal(G.piecePosition(ctx, { kind: 'chess', id: MLYSS }), 'MELEE');
|
||||
assert.equal(G.piecePosition(ctx, { kind: 'chess', id: SARIA }), 'RANGED');
|
||||
const own = G.placementContext({ priv: { standIns: [] }, stage, editable: true, getChess, backups: BACKUPS });
|
||||
assert.equal(G.piecePosition(own, { kind: 'chess', id: MLYSS }), 'RANGED');
|
||||
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', () => {
|
||||
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');
|
||||
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');
|
||||
});
|
||||
|
||||
test('detail card: own piece / card → the stand-in\'s body under the chess\'s name; 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(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');
|
||||
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');
|
||||
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 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');
|
||||
});
|
||||
|
||||
test('own prep pieces with stand-ins get a 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, 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)', () => {
|
||||
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, /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'/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,152 @@
|
||||
// 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.
|
||||
//
|
||||
// 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.
|
||||
import { test, describe } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { existsSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { Client, ROOT, sleep, hasChrome, startRealServer, problemsOf } from '../e2e/client.mjs';
|
||||
|
||||
const ENABLED = process.env.SP_E2E === '1' && hasChrome() && existsSync(path.join(ROOT, 'public/assets'));
|
||||
const SILVER = 'chess_char_4_22_a'; // 银灰 (NORMAL) → Sharp (char_609_acguad), S2 亮剑
|
||||
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] } });
|
||||
const P = (await import('puppeteer-core')).default;
|
||||
const c = new Client(P, srv.base, 'standin', { prefix: 'standin' });
|
||||
try {
|
||||
await c.open();
|
||||
await c.enter('替补');
|
||||
// 1) 干员调配 → 干员持有: mark 银灰 未持有 (real clicks), synced with the server, persisted in the browser
|
||||
await c.click('.lobby-screen [data-testid="loadout-open"]');
|
||||
await c.page.waitForSelector('.lo .lo-tab[data-tab="ownership"]', { visible: true, timeout: 15000 });
|
||||
await c.click('.lo .lo-tab[data-tab="ownership"]');
|
||||
await c.page.waitForSelector(`.own-card[data-chess="${SILVER}"]`, { visible: true, timeout: 15000 });
|
||||
const cards = await c.page.evaluate(() => [...document.querySelectorAll('.own-card')].length);
|
||||
assert.equal(cards, 53, 'the 53 NORMAL chess of the shop');
|
||||
await c.click(`.own-card[data-chess="${SILVER}"]`);
|
||||
await c.page.waitForFunction((id) => document.querySelector(`.own-card[data-chess="${id}"]`)?.getAttribute('aria-checked') === 'false', { timeout: 3000 }, SILVER);
|
||||
await c.page.waitForFunction(() => /已同步/.test(document.querySelector('[data-testid="ownership-sync"]')?.textContent || ''), { timeout: 8000 });
|
||||
const card = await c.page.evaluate((id) => document.querySelector(`.own-card[data-chess="${id}"]`)?.textContent || '', SILVER);
|
||||
assert.ok(card.includes('银灰') && card.includes('Sharp') && card.includes('未持有'), card);
|
||||
const stored = await c.page.evaluate(() => JSON.parse(localStorage.getItem('sp.pref.ownership') || 'null'));
|
||||
assert.deepEqual(stored, { v: 1, notOwned: [SILVER] });
|
||||
await c.shot('ownership');
|
||||
await c.page.keyboard.press('Escape');
|
||||
await c.page.waitForFunction(() => !document.querySelector('.lo'), { timeout: 3000 });
|
||||
|
||||
// 2) a solo 标准 match: the briefing's m.private carries the list
|
||||
await c.click('.mode-card', '独立模拟');
|
||||
await c.click('.diff-card', '标准模拟');
|
||||
await c.click('.create-box button', '开始独立模拟');
|
||||
await c.waitFor((s) => !!s.room, 'solo room');
|
||||
if (!(await c.st()).phase) await c.click('.room-bar__right button', '开始模拟', { timeout: 20000 });
|
||||
await c.waitFor((s) => s.phase === 'INFO_CHECK', 'briefing', 30000);
|
||||
const standIns = await c.page.evaluate(() => globalThis.__SP__.store.get().match.private?.standIns ?? null);
|
||||
assert.deepEqual(standIns, [SILVER], 'the match received the not-owned list');
|
||||
await c.click('.brief__foot .btn--primary', '准备就绪');
|
||||
await c.waitFor((s) => s.phase === 'BAND_DRAFT', 'band draft', 30000);
|
||||
await c.click('.dband', null, { nth: 1 });
|
||||
await c.click('.draft-detail__btns .btn--primary', '确认选择');
|
||||
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
|
||||
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;
|
||||
});
|
||||
assert.deepEqual(shopCard, { name: '银灰', badge: '替补:Sharp', standIn: SHARP });
|
||||
await c.shot('shop');
|
||||
await c.click('.scard', '银灰');
|
||||
await sleep(300);
|
||||
await c.click('.scard', '银灰');
|
||||
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');
|
||||
await c.shot('hand');
|
||||
|
||||
// 4) deploy it (drag → direction wheel): the board model becomes Sharp's; the card shows Sharp's body
|
||||
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));
|
||||
await c.page.waitForSelector('.fwheel__dia', { timeout: 4000 });
|
||||
await c.swipe('RIGHT');
|
||||
await c.waitFor((s) => s.board > 0, 'placed', 8000);
|
||||
await c.page.waitForFunction((uid, sharp) => {
|
||||
const v = globalThis.__SP_VIEW__?.raw?.debug?.views?.get(`p:${uid}`);
|
||||
return v && v.info?.spine === sharp && v.info?.standInFor;
|
||||
}, { timeout: 8000 }, piece.uid, SHARP);
|
||||
await sleep(600);
|
||||
let detail = null;
|
||||
for (const at of [0.72, 0.4, 0.88]) {
|
||||
const p = await c.piecePoint(piece.uid, at);
|
||||
if (!p) continue;
|
||||
await c.page.mouse.click(p.x, p.y, { button: 'right' });
|
||||
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 || '',
|
||||
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' });
|
||||
await c.shot('board');
|
||||
await c.page.keyboard.press('Escape');
|
||||
|
||||
// 5) ready → the local battle fields Sharp (spec standIn: true; the sim's def; the view's Spine model)
|
||||
await c.click('.readybtn');
|
||||
await c.waitFor((s) => s.phase === 'COMBAT', 'combat', 60000);
|
||||
const got = await c.page.waitForFunction((uid, sharp) => {
|
||||
const r = globalThis.__SP_RUNNER__;
|
||||
const e = r && [...r._entries.values()].find((x) => x.own && x.battle);
|
||||
if (!e) return false;
|
||||
const u = e.battle.allyUnits.find((x) => x.uid === uid);
|
||||
if (!u) return false;
|
||||
const views = globalThis.__SP_VIEW__?.raw?.debug?.views;
|
||||
const v = views ? [...views.values()].find((x) => x.info?.uid === uid && !String(x.id).startsWith('p:')) : null;
|
||||
if (!v || !v.spineReady) return false;
|
||||
const entry = (e.spec.players || []).flatMap((p) => p.units || []).find((x) => x.uid === uid) || null;
|
||||
return {
|
||||
authoritative: e.authoritative, spec: entry && { chessId: entry.chessId, standIn: entry.standIn ?? null, skillIndex: entry.skillIndex ?? null },
|
||||
charId: u.def?.charId, standInFor: u.def?.standInFor, skill: u.skill?.id ?? null, bonds: u.def?.bonds,
|
||||
viewSpine: v.info.spine, viewStandInFor: v.info.standInFor ?? null, model: String(v._actorEntry?.skel || ''),
|
||||
};
|
||||
}, { timeout: 30000, polling: 150 }, piece.uid, SHARP).then((h) => h.jsonValue());
|
||||
assert.equal(got.authoritative, true, 'the own normal battle is simulated by this browser');
|
||||
assert.deepEqual(got.spec, { chessId: SILVER, standIn: true, skillIndex: null }, 'b.start spec marks the stand-in');
|
||||
assert.equal(got.charId, SHARP, 'the local sim fields Sharp');
|
||||
assert.equal(got.skill, 'skchr_acguad_2', 'with the chess\'s backup skill (S2 亮剑)');
|
||||
assert.equal(got.viewSpine, SHARP);
|
||||
assert.ok(got.viewStandInFor, 'the view knows it is a stand-in');
|
||||
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);
|
||||
assert.deepEqual(problemsOf([c]), []);
|
||||
} finally {
|
||||
if (c.problems.length) console.log(c.problems.slice(0, 20).join('\n'));
|
||||
await c.close();
|
||||
await srv.stop();
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user