feedback5 WK: 自选编队 in the browser — the 干员调配 overlay's third tab 自选编队 (5阶 ×2, 6阶 ×2 slots; the slot picker lists the kitted picks of the tier with avatar, name, class, derived bonds and a 原型 / 已持有 tag; an owned pick chooses its skill and module — 集成战略 modules left out —, a prototype shows its locked skill; sp.pref.diy, export / import, 全部清空, synced as room.diy through the shared engine, welcome.diyKitted kept), the operator on shop / reward cards, own pieces (「自选」 tag, its model on bench and board), the detail card (pick's skill and module), placement and range previews, bond popups (own and a teammate's 自选 member), the result screen; new UI strings through t() with English; the e2e fast server's SP_START_LEVEL hook
Bonds of 自选 pieces checked everywhere membership is decided (the owner's reminder of 2026-10-05): the match's counts through the player's data view (煌 in 炎 and 维多利亚, one member per slot, 绝技 elites), the sim's bond effects through def.bonds (叙拉古 on deployment, 协防 ×1.2 / elite ×1.4), the client's strip, popups, cards and detail card. Golden unchanged. Tests: test/ui/diy-ui.test.js (15), test/match/diy-bonds.test.js (4), test/i18n.test.js pins screens/diy.js; test/ui/diy.e2e.test.js (SP_E2E=1, run once: pass).
This commit is contained in:
1 parent
e480b1cb26
commit
0def7babfd
31 files changed
+1637
-78
No files matched your search
@@ -91,6 +91,7 @@
|
||||
.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-diy { font-size: .13rem; font-weight: 700; padding: .01rem .08rem; letter-spacing: .08em; background: var(--mint-500); color: var(--text-on-mint); }
|
||||
.dtag-standin { font-size: .13rem; font-weight: 700; padding: .01rem .08rem; letter-spacing: .04em; background: var(--ice); color: var(--ice-900); }
|
||||
.dtag-kind { border: 1px solid var(--line-2); color: var(--text-lo); font-weight: 500; }
|
||||
.drank { font-size: .13rem; font-weight: 700; padding: .01rem .08rem; }
|
||||
@@ -558,6 +559,7 @@ button.bpop__harmony:hover { border-style: solid; color: #fff; }
|
||||
.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; }
|
||||
.sitag--diy { background: var(--mint-500); color: var(--text-on-mint); }
|
||||
.gm.is-dragging .sitags { display: none; }
|
||||
|
||||
/* ---- underframe: the selection diamond of a tapped own piece (ui/underframe.js): 撤退 / 出售 +N / 销毁 ----
|
||||
|
||||
@@ -133,6 +133,9 @@
|
||||
/* 0.2.0 补位: a chess the player does not own — its card stays, the stand-in fights (ui/shopBar.js ChessCard) */
|
||||
.scard__standin { position: absolute; z-index: 3; left: 0; top: .38rem; max-width: calc(100% - .1rem); font-size: .12rem; font-weight: 700; padding: .01rem .06rem;
|
||||
background: var(--ice); color: var(--ice-900); letter-spacing: .04em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; box-shadow: 0 1px 3px rgba(0, 0, 0, .6); }
|
||||
/* 0.2.0 自选编队: a DIY slot the player filled — the card is its operator, only in this player's shop (ui/shopBar.js) */
|
||||
.scard__diy { position: absolute; z-index: 3; left: 0; top: .38rem; font-size: .12rem; font-weight: 700; padding: .01rem .06rem;
|
||||
background: var(--mint-500); color: var(--text-on-mint); letter-spacing: .08em; white-space: nowrap; box-shadow: 0 1px 3px rgba(0, 0, 0, .6); }
|
||||
.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; }
|
||||
|
||||
@@ -385,6 +385,104 @@
|
||||
.lo-tab { padding: .02rem .08rem; letter-spacing: .04em; }
|
||||
}
|
||||
|
||||
/* ---- 自选编队 (screens/diy.js): four DIY slots by tier, the slot picker (operator list + skill / module) --------------- */
|
||||
.diy { position: relative; flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; gap: .12rem; padding: .16rem .3rem .2rem; }
|
||||
.diy__body { 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; }
|
||||
.diy-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(max(5.2rem, 300px), 1fr)); gap: .1rem; }
|
||||
.diy-slot {
|
||||
position: relative; display: grid; grid-template-columns: auto minmax(0, 1fr) auto; gap: .1rem; align-items: start; min-width: 0;
|
||||
padding: .08rem; background: rgba(255, 255, 255, .03); border: 1px solid var(--line); border-left: 3px solid var(--tc, var(--line-3));
|
||||
}
|
||||
.diy-slot--t5 { --tc: var(--tier-5); } .diy-slot--t6 { --tc: var(--tier-6); }
|
||||
.diy-slot.is-empty { display: flex; flex-direction: column; padding: 0; border-style: dashed; border-left-style: solid; }
|
||||
.diy-slot__fill {
|
||||
display: flex; flex-direction: column; align-items: center; justify-content: center; gap: .05rem; width: 100%; min-height: max(1.3rem, 84px);
|
||||
padding: .1rem; font: inherit; color: var(--text-md); background: none; border: 0; cursor: pointer; touch-action: manipulation;
|
||||
}
|
||||
.diy-slot__fill:hover { background: rgba(255, 255, 255, .05); color: var(--text-hi); }
|
||||
.diy-slot__fill b { font-size: max(.16rem, 11px); }
|
||||
.diy-slot__fill small { font-size: max(.12rem, 9px); color: var(--text-lo); }
|
||||
.diy-slot__plus { display: grid; place-items: center; width: max(.4rem, 26px); height: max(.4rem, 26px); border: 1px solid var(--line-3); border-radius: 50%; }
|
||||
.diy-slot__plus .icon { width: 60%; height: 60%; }
|
||||
.diy-slot__art { display: grid; place-items: center; width: max(.9rem, 56px); height: max(1.3rem, 84px); overflow: hidden; background: #0a0d0c; border: 1px solid var(--line-2); }
|
||||
.diy-slot__art img { width: 100%; height: 100%; object-fit: cover; object-position: top; }
|
||||
.diy-slot__art b { font-size: max(.3rem, 18px); color: var(--text-lo); }
|
||||
.diy-slot__body { display: flex; flex-direction: column; gap: .03rem; min-width: 0; }
|
||||
.diy-slot__head { display: flex; align-items: center; gap: .05rem; }
|
||||
.diy-slot__label { font-size: max(.12rem, 9px); color: var(--text-lo); letter-spacing: .06em; }
|
||||
.diy-slot__name { font-size: max(.17rem, 12px); font-weight: 700; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.diy-slot__class { font-size: max(.12rem, 9px); color: var(--text-lo); }
|
||||
.diy-slot__kit { display: flex; align-items: center; gap: .05rem; min-width: 0; font-size: max(.13rem, 9px); color: var(--text-md); }
|
||||
.diy-slot__kit > span { min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.diy-slot__sicon, .diy-slot__micon { flex: none; display: inline-grid; place-items: center; width: max(.24rem, 16px); height: max(.24rem, 16px); object-fit: contain; font-size: .8em; color: var(--text-lo); }
|
||||
.diy-slot__micon--none { border: 1px solid var(--line-2); }
|
||||
.diy-slot__acts { display: flex; flex-direction: column; gap: .04rem; }
|
||||
.diy-slot__bad { display: flex; align-items: center; gap: .04rem; margin: .02rem 0 0; font-size: max(.12rem, 9px); color: var(--amber); }
|
||||
.diy-slot.is-bad { border-color: rgba(255, 196, 0, .45); }
|
||||
.diy-tag { display: inline-flex; align-items: center; height: 1.5em; padding: 0 .4em; font-size: max(.11rem, 8px); font-weight: 700; border-radius: 1em; white-space: nowrap; }
|
||||
.diy-tag--proto { color: var(--ice-900); background: var(--ice); }
|
||||
.diy-tag--own { color: var(--text-on-mint); background: var(--mint-500); }
|
||||
.diy-bonds { display: flex; flex-wrap: wrap; gap: .03rem .06rem; }
|
||||
.diy-bond { display: inline-flex; align-items: center; gap: .03rem; font-size: max(.12rem, 9px); color: var(--text-md); }
|
||||
.diy-bond .bglyph, .diy-bond img { width: max(.16rem, 11px); height: max(.16rem, 11px); }
|
||||
/* the picker covers the tab: the list of the slot's tier on the left, the chosen operator's skill / module on the right */
|
||||
.diy-pick { position: absolute; inset: 0; z-index: 3; display: flex; flex-direction: column; gap: .08rem; padding: .12rem .3rem .14rem; background: var(--bg-1, #0d1110); }
|
||||
.diy-pick__head { flex: none; display: flex; align-items: center; gap: .08rem; font-size: max(.16rem, 11px); }
|
||||
.diy-pick__note { flex: 1; min-width: 0; font-size: max(.12rem, 9px); color: var(--text-lo); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.diy-pick__x { display: grid; place-items: center; width: max(.4rem, 28px); height: max(.4rem, 28px); color: var(--text-md); background: none; border: 1px solid var(--line-2); cursor: pointer; }
|
||||
.diy-pick__bar { flex: none; display: flex; align-items: center; gap: .1rem; flex-wrap: wrap; }
|
||||
.diy-pick__search { flex: 1 1 2rem; min-width: 120px; height: max(.36rem, 26px); padding: 0 .08rem; font: inherit; font-size: max(.13rem, 10px); color: var(--text-hi); background: rgba(255, 255, 255, .04); border: 1px solid var(--line-2); }
|
||||
.diy-pick__main { flex: 1 1 auto; min-height: 0; display: grid; grid-template-columns: minmax(0, 1.2fr) minmax(0, 1fr); gap: .12rem; }
|
||||
.diy-pick__list, .diy-pick__detail { min-height: 0; overflow: auto; -webkit-overflow-scrolling: touch; overscroll-behavior: contain; scrollbar-width: thin; scrollbar-color: var(--line-3) transparent; }
|
||||
.diy-pick__list { display: grid; grid-template-columns: repeat(auto-fill, minmax(max(3.2rem, 200px), 1fr)); align-content: start; gap: .06rem; }
|
||||
.diy-opt {
|
||||
position: relative; display: flex; align-items: center; gap: .07rem; min-width: 0; padding: .06rem; font: inherit; color: var(--text-hi); text-align: left;
|
||||
background: rgba(255, 255, 255, .03); border: 1px solid var(--line); cursor: pointer; touch-action: manipulation;
|
||||
}
|
||||
.diy-opt:hover { background: rgba(255, 255, 255, .06); }
|
||||
.diy-opt.is-sel { border-color: var(--mint-500); background: rgba(52, 211, 153, .08); }
|
||||
.diy-opt.is-taken { opacity: .45; cursor: not-allowed; }
|
||||
.diy-opt__ava { flex: none; display: grid; place-items: center; width: max(.5rem, 32px); height: max(.5rem, 32px); overflow: hidden; background: #0a0d0c; border: 1px solid var(--line-2); }
|
||||
.diy-opt__ava img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.diy-opt__txt { display: flex; flex-direction: column; gap: .02rem; min-width: 0; }
|
||||
.diy-opt__top { display: flex; align-items: center; gap: .04rem; min-width: 0; }
|
||||
.diy-opt__name { font-size: max(.15rem, 11px); font-weight: 700; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.diy-opt__class { display: inline-flex; align-items: center; gap: .03rem; font-size: max(.11rem, 8px); color: var(--text-lo); }
|
||||
.diy-opt__prof { width: max(.14rem, 10px); height: max(.14rem, 10px); }
|
||||
.diy-opt__taken { position: absolute; right: .04rem; bottom: .03rem; font-size: max(.1rem, 8px); color: var(--amber); }
|
||||
.diy-pick__detail { display: flex; flex-direction: column; gap: .06rem; padding: .06rem; background: rgba(255, 255, 255, .02); border: 1px solid var(--line); }
|
||||
.diy-pick__who { display: flex; align-items: center; gap: .08rem; }
|
||||
.diy-pick__ava { display: grid; place-items: center; width: max(.6rem, 40px); height: max(.6rem, 40px); overflow: hidden; background: #0a0d0c; border: 1px solid var(--line-2); }
|
||||
.diy-pick__ava img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.diy-pick__wtxt { display: flex; flex-direction: column; flex: 1; min-width: 0; }
|
||||
.diy-pick__wtxt b { font-size: max(.17rem, 12px); }
|
||||
.diy-pick__wtxt small { font-size: max(.12rem, 9px); color: var(--text-lo); }
|
||||
.diy-pick__sec { display: flex; align-items: baseline; gap: .06rem; margin: .04rem 0 0; font-size: max(.14rem, 10px); color: var(--text-md); }
|
||||
.diy-pick__sec small { font-size: max(.11rem, 8px); color: var(--text-lo); font-weight: 400; }
|
||||
.diy-pick__choices { display: flex; flex-direction: column; gap: .04rem; }
|
||||
.diy-choice {
|
||||
display: flex; align-items: flex-start; gap: .07rem; padding: .05rem; font: inherit; color: var(--text-hi); text-align: left;
|
||||
background: rgba(255, 255, 255, .03); border: 1px solid var(--line); cursor: pointer; touch-action: manipulation;
|
||||
}
|
||||
.diy-choice:disabled { cursor: default; }
|
||||
.diy-choice.is-on { border-color: var(--mint-500); background: rgba(52, 211, 153, .08); }
|
||||
.diy-choice__icon { flex: none; display: grid; place-items: center; width: max(.36rem, 24px); height: max(.36rem, 24px); color: var(--text-lo); }
|
||||
.diy-choice__icon img { width: 100%; height: 100%; object-fit: contain; }
|
||||
.diy-choice__body { display: flex; flex-direction: column; gap: .02rem; min-width: 0; }
|
||||
.diy-choice__head { display: flex; align-items: center; gap: .05rem; flex-wrap: wrap; font-size: max(.14rem, 10px); }
|
||||
.diy-choice__desc { font-size: max(.12rem, 9px); line-height: 1.45; color: var(--text-md); }
|
||||
.diy-pick__foot { flex: none; display: flex; justify-content: flex-end; gap: .08rem; }
|
||||
/* 0.2.0 自选编队: the slots the player filled (the entry button's chip) */
|
||||
.lo-entry__diy { color: var(--text-on-mint); background: var(--mint-500); }
|
||||
@media (max-width: 760px) {
|
||||
.diy { padding: .1rem .16rem .16rem; }
|
||||
.diy-pick { padding: .08rem .12rem .1rem; }
|
||||
.diy-pick__main { grid-template-columns: minmax(0, 1fr); grid-template-rows: minmax(0, 1fr) minmax(0, 1.1fr); }
|
||||
.diy-slot { grid-template-columns: auto minmax(0, 1fr); }
|
||||
.diy-slot__acts { grid-column: 1 / -1; flex-direction: row; justify-content: flex-end; }
|
||||
}
|
||||
|
||||
/* ---- 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; }
|
||||
|
||||
+55
-1
@@ -845,5 +845,59 @@
|
||||
"【奇迹】下次刷新不消耗资金": "[Marvel] Your next refresh is free",
|
||||
"【维多利亚】获得{n}件维式重锤": "[Victoria] Obtained {n} {n|Victorian Hammer|Victorian Hammers}",
|
||||
"【远见】所有干员购买价格永久降低": "[Foresight] Every Operator's purchase price is permanently reduced",
|
||||
"【远见】远见干员购买价格永久降低": "[Foresight] [Foresight] Operators' purchase price is permanently reduced"
|
||||
"【远见】远见干员购买价格永久降低": "[Foresight] [Foresight] Operators' purchase price is permanently reduced",
|
||||
"原型": "Prototype",
|
||||
"已持有": "Owned",
|
||||
"{tier}阶 自选": "Tier {tier} Custom",
|
||||
"{slot}:选择干员": "{slot}: choose an Operator",
|
||||
"选择干员": "Choose an Operator",
|
||||
"调度中心等级 {level} 起出现在商店": "In the shop from Dispatch Center level {level}",
|
||||
"这项自选在当前版本不可用,开局时会被移除": "This pick is not available in this version; it is dropped when the match starts",
|
||||
"(锁定)": "(locked)",
|
||||
"(精锐时生效)": "(as an Elite)",
|
||||
"更换": "Change",
|
||||
"清空": "Clear",
|
||||
"已在{tier}阶自选中": "Already in a Tier {tier} Custom slot",
|
||||
"锁定": "Locked",
|
||||
"6★ 原型干员与持有的 6★ 干员,另有 4★ 原型干员(先锋、特种除外)": "6★ prototype Operators and the 6★ Operators you own, plus 4★ Reserve Operators (no Vanguards or Specialists)",
|
||||
"6★ 原型干员与持有的 6★ 干员": "6★ prototype Operators and the 6★ Operators you own",
|
||||
"筛选": "Filter",
|
||||
"可选干员": "Operators to pick",
|
||||
"连接服务器后才能选择自选干员": "Connect to the server to pick Custom Squad Operators",
|
||||
"原型干员的技能与补位时一致,不可更改": "A prototype carries the skill it uses as a stand-in; it cannot be changed",
|
||||
"精锐时生效 · 模组等级 {stage}": "As an Elite · module stage {stage}",
|
||||
"从左侧选择一名干员": "Choose an Operator on the left",
|
||||
"5阶与6阶各有两个自选名额:选择持有的 6★ 干员(任选技能与模组)或原型干员(技能锁定)。自选干员只出现在你自己的商店里,调度中心达到该阶等级后才会刷新,价格与同阶干员相同;盟约按干员实际所属阵营分配,没有特质。": "Tiers 5 and 6 have two Custom Squad slots each: pick a 6★ Operator you own (any skill and module) or a prototype Operator (skill locked). A Custom Squad Operator appears only in your own shop, once the Dispatch Center reaches its tier, at the price of any Operator of that tier; its Alliances follow its actual factions, and it has no Attribute.",
|
||||
"{tier}阶": "Tier {tier}",
|
||||
"阶": "Tier",
|
||||
"自选编队:{name}(只在你的商店出现)": "Custom Squad: {name} (only in your shop)",
|
||||
"自选": "Custom",
|
||||
"自选编队:所选技能与模组,没有特质,盟约按所属阵营分配": "Custom Squad: the chosen skill and module, no Attribute, Alliances by its factions",
|
||||
"自选编队:{name}": "Custom Squad: {name}",
|
||||
"全部清空": "Clear All",
|
||||
"清空全部 {n} 个自选名额?": "Clear all {n} Custom Squad {n|slot|slots}?",
|
||||
"文件过大,请选择「导出」下载的自选编队文件": "The file is too large. Choose a Custom Squad file downloaded with \"Export\".",
|
||||
"导入失败:{error}": "Import failed: {error}",
|
||||
"已导入 {n} 个自选名额": "Imported {n} Custom Squad {n|slot|slots}",
|
||||
"(另有 {n} 项不可用,未导入)": " ({n} unavailable {n|entry was|entries were} not imported)",
|
||||
"已导入:全部名额为空": "Imported: every slot empty",
|
||||
"自选编队是局外设置:本局按开局时的设置进行,修改将在下一局生效": "The Custom Squad is set outside matches: this match keeps the picks it started with; changes apply from the next match",
|
||||
"局外设置,下一局生效 · 联机时只进入你自己的商店": "Set outside matches, applies from the next match · in team play only in your own shop",
|
||||
"干员调配 / 干员持有 / 自选编队": "Operator Loadout / Operator Roster / Custom Squad",
|
||||
"5阶、6阶的自选名额:持有的 6★ 干员或原型干员": "The Custom Squad slots of tiers 5 and 6: 6★ Operators you own or prototype Operators",
|
||||
"自选编队": "Custom Squad",
|
||||
"{n} 个自选名额已选择": "{n} Custom Squad {n|slot|slots} filled",
|
||||
"已选": "Filled",
|
||||
"导出自选编队(可复制或下载)": "Export the Custom Squad (copy or download)",
|
||||
"导入自选编队(粘贴或选择文件)": "Import a Custom Squad (paste or choose a file)",
|
||||
"共 {n} 个自选名额已选择。复制或下载这份数据,即可在别的设备或浏览器上导入。": "{n} Custom Squad {n|slot|slots} filled. Copy or download this data to import it on another device or browser.",
|
||||
"把导出的自选编队数据粘贴到下方,或点「选择文件」。导入会覆盖当前的自选编队。": "Paste exported Custom Squad data below, or click \"Choose File\". Importing replaces the current Custom Squad.",
|
||||
"在此粘贴导出的自选编队内容…": "Paste the exported Custom Squad here…",
|
||||
"导出自选编队": "Export Custom Squad",
|
||||
"导入自选编队": "Import Custom Squad",
|
||||
"自选编队是局外设置,修改将在下一局生效": "The Custom Squad is set outside matches; changes apply from the next match",
|
||||
"下一局生效": "Applies next match",
|
||||
"这份数据来自更新的版本(v{v}),请先更新游戏": "This data comes from a newer version (v{v}). Update the game first.",
|
||||
"这不是自选编队的数据": "This is not Custom Squad data",
|
||||
"里面没有自选编队的数据": "It holds no Custom Squad data"
|
||||
}
|
||||
+4
-2
@@ -21,7 +21,8 @@
|
||||
// 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), its 干员持有 tab's
|
||||
// not-owned list (0.2.0 补位) by installOwnershipSync (room.ownership, likewise).
|
||||
// not-owned list (0.2.0 补位) by installOwnershipSync (room.ownership, likewise), the 自选编队 picks (0.2.0 DIY) by
|
||||
// installDiySync (room.diy, likewise; it also keeps welcome.diyKitted for the picker).
|
||||
// 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.
|
||||
// Language (ui/lang.js, docs/I18N.md): chosen before the first render (initLang); App re-renders on a switch (useLang).
|
||||
@@ -48,7 +49,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, installOwnershipSync } from './ui/loadoutSync.js';
|
||||
import { installLoadoutSync, installOwnershipSync, installDiySync } from './ui/loadoutSync.js';
|
||||
import { startBuildGuard } from './ui/buildGuard.js';
|
||||
import { initLang, useLang, tickerText } from './ui/lang.js';
|
||||
import { t, N_, translateWire } from '../../shared/i18n.js';
|
||||
@@ -340,6 +341,7 @@ async function boot() {
|
||||
wireNet();
|
||||
installLoadoutSync({ net });
|
||||
installOwnershipSync({ net });
|
||||
installDiySync({ 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() });
|
||||
|
||||
@@ -276,6 +276,7 @@ export async function createFieldView(host, options = {}) {
|
||||
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 diyPicks = {}; // the own m.private.diy (0.2.0 自选编队): pieces of these DIY slots draw the operator
|
||||
let camBeforePen = null; // { kind, opts } the camera the pen returns to
|
||||
let leader = null; // { key, view, stand, area } the round leader standing on the boss field in the prep (setLeader)
|
||||
let leaderHidden = true; // shown only by the boss-field prep camera (leaderShown)
|
||||
@@ -288,7 +289,7 @@ export async function createFieldView(host, options = {}) {
|
||||
timeScale: () => (mode === 'battle' ? interp.rate : 1),
|
||||
// (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)),
|
||||
: (info.standInFor && data.standIn(info.defId)) || (info.diy && data.diy(info.defId, info.diy)) || data.chess(info.defId) || data.token(info.defId)),
|
||||
crowded: () => views.size > 90,
|
||||
renderer: app.renderer,
|
||||
frameNo: () => frameNo,
|
||||
@@ -304,7 +305,7 @@ export async function createFieldView(host, options = {}) {
|
||||
timeScale: () => ctx.timeScale(),
|
||||
loadLevel: () => loadLevel,
|
||||
fieldRect: () => (mode === 'battle' && battleMeta ? battleMeta.rect : null),
|
||||
subProfOf: (defId, info = null) => ((info && info.standInFor && data.standIn(defId)) || data.chess(defId))?.subProfessionId || null,
|
||||
subProfOf: (defId, info = null) => ((info && info.standInFor && data.standIn(defId)) || (info && info.diy && data.diy(defId, info.diy)) || data.chess(defId))?.subProfessionId || null,
|
||||
view: (id) => views.get(id) || null,
|
||||
screenSize: size,
|
||||
fieldTop: () => {
|
||||
@@ -633,12 +634,16 @@ export async function createFieldView(host, options = {}) {
|
||||
// 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;
|
||||
// 0.2.0 自选编队: a DIY slot the player filled is its operator, on the board and on the bench alike
|
||||
const pick = chess && chess.isDiy ? diyPicks[chess.baseId || chess.chessId] : null;
|
||||
const dr = pick ? data.diy(piece.id, pick) : null;
|
||||
const rec = si || dr || chess;
|
||||
return {
|
||||
kind: 'op', side: 'ally', defId: piece.id,
|
||||
spine: rec?.assets?.spine || rec?.charId || null, avatar: rec?.assets?.avatar || rec?.charId || null,
|
||||
tier: chess?.tier || piece.tier || 1, golden: !!(piece.golden || chess?.isGolden), dir,
|
||||
...(si ? { standInFor: si.standInFor } : null),
|
||||
...(dr ? { diy: { charId: pick.charId, skillIndex: pick.skillIndex ?? null, uniEquipId: pick.uniEquipId ?? null } } : null),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -687,6 +692,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') : [];
|
||||
diyPicks = src.diy && typeof src.diy === 'object' ? src.diy : {};
|
||||
ownPen = Array.isArray(src.nextEnemies) ? src.nextEnemies : null;
|
||||
setPenList(ownPen);
|
||||
addList(src.hand, 'hand');
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { createAssets, assets as defaultAssets } from '../../assets.js';
|
||||
import { standInRecord } from '../../../../shared/standIn.js';
|
||||
import { diyRecordOf } from '../../../../shared/diy.js';
|
||||
|
||||
function withTimeout(p, ms) {
|
||||
return Promise.race([p, new Promise((resolve) => setTimeout(resolve, ms))]);
|
||||
@@ -40,10 +41,26 @@ function makeData(src) {
|
||||
standIns.set(c, rec);
|
||||
return rec;
|
||||
};
|
||||
// 0.2.0 自选编队: the composed 自选 record of a DIY slot filled with a pick (shared/diy.js over chess.json + backups.json)
|
||||
const diys = new Map();
|
||||
const chessMap = () => {
|
||||
try { return (src && typeof src.get === 'function' ? src.get('chess') : src && src.chess) || null; } catch { return null; }
|
||||
};
|
||||
const diy = (id, pick) => {
|
||||
const c = look('chess', id);
|
||||
const b = backups();
|
||||
if (!c || !c.isDiy || !b || !pick || typeof pick.charId !== 'string') return null;
|
||||
const key = `${id}|${pick.charId}|${pick.skillIndex ?? ''}|${pick.uniEquipId ?? ''}`;
|
||||
if (diys.has(key)) return diys.get(key);
|
||||
let rec;
|
||||
try { rec = diyRecordOf(c, pick, { chess: chessMap(), backups: b }); } catch { rec = null; }
|
||||
diys.set(key, 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,
|
||||
standIn, diy,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -45,5 +45,10 @@ export function renderInfo(u) {
|
||||
// 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,
|
||||
// 0.2.0 自选编队: the pick of a DIY slot's unit (UnitInfo.diy { charId, skillIndex, uniEquipId }; spine / avatar / name
|
||||
// are already the operator's) — the detail card composes the operator from it, the data lookups follow it
|
||||
diy: u.diy && typeof u.diy === 'object' && typeof u.diy.charId === 'string'
|
||||
? { charId: u.diy.charId, skillIndex: Number.isInteger(u.diy.skillIndex) ? u.diy.skillIndex : null, uniEquipId: typeof u.diy.uniEquipId === 'string' ? u.diy.uniEquipId : null }
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
// 自选编队 (0.2.0 DIY; research 0.2.0 §2, the owner's decisions of 2026-10-05): the third tab of the 干员调配 overlay
|
||||
// (screens/loadout.js). Four slots — 5阶 ×2, 6阶 ×2 — each filled with an operator: a 6★ the player owns outside the
|
||||
// chess pool (any of its three skills, any module of its elite form but a 集成战略 one) or a prototype (原型干员, its skill
|
||||
// and module locked as when it stands in). Only operators with a kit are listed (the server's welcome.diyKitted). The
|
||||
// slot picker lists the legal picks of the slot's tier — avatar, name, class, the bonds derived from its factions, a
|
||||
// 原型 / 已持有 tag —, an operator held by another slot greyed out with where it is. In the match the operator is sold
|
||||
// only in this player's shop, from 调度中心 level 5 (tier 5) / 6 (tier 6), and fights with the pick (server/match/player
|
||||
// /diy.js). Out of match: the next match takes the picks. The picks live in ui/loadoutSync.js (localStorage + room.diy);
|
||||
// the model is ui/diyModel.js. Styles: css/screens/loadout.css (diy-*).
|
||||
|
||||
import { useState } from '../../vendor/hooks.module.js';
|
||||
import { html, Icon, Button, TierChip } from '../ui/components.js';
|
||||
import { Img, RichText, BondGlyph } from '../ui/gameComponents.js';
|
||||
import { chessAvatarUrl, chessPortraitUrl, profIconUrl, skillRecordIconUrl, moduleTypeIconUrl } from '../ui/assetUrls.js';
|
||||
import { data } from '../data.js';
|
||||
import { PROF_NAME, skillLabel, moduleBadge } from '../ui/loadoutModel.js';
|
||||
import { diySlotList, pickChoices, pickOptions, slotRecord, defaultPick } from '../ui/diyModel.js';
|
||||
import { t } from '../../../shared/i18n.js';
|
||||
|
||||
const cx = (...p) => p.flat().filter(Boolean).join(' ');
|
||||
const ROMAN = ['', 'I', 'II', 'III', 'IV', 'V', 'VI'];
|
||||
|
||||
/** The data the 自选 rules read (shared/diy.js): chess.json + backups.json (localized copies). */
|
||||
export const diyData = () => ({ chess: data.get('chess'), backups: data.get('backups') });
|
||||
|
||||
/** A unit summary's avatar / portrait URL (a backups.json unit or a composed record). */
|
||||
const avatarOf = (m, unit, elite = false) => chessAvatarUrl(m, unit && { charId: unit.charId, assets: { avatar: elite ? unit.assets?.avatarGolden : unit.assets?.avatar } });
|
||||
const classLine = (u) => `${t(PROF_NAME[u?.profession] || '') || ''}${u?.subProfessionName ? ` · ${u.subProfessionName}` : ''}`;
|
||||
const bondName = (id) => data.lookup('bonds', id)?.name || id;
|
||||
|
||||
/** The 原型 / 已持有 tag of a pick. */
|
||||
function KindTag({ proto }) {
|
||||
return html`<span class=${cx('diy-tag', proto ? 'diy-tag--proto' : 'diy-tag--own')}>${proto ? t('原型') : t('已持有')}</span>`;
|
||||
}
|
||||
|
||||
/** Bond chips (the bonds derived from the operator's factions; 协防 when none matches). */
|
||||
function Bonds({ bonds }) {
|
||||
return html`<span class="diy-bonds">${(bonds || []).map((b) => html`<span key=${b} class="diy-bond"><${BondGlyph} bondId=${b} /><span>${bondName(b)}</span></span>`)}</span>`;
|
||||
}
|
||||
|
||||
/** One slot: the pick (operator, class, bonds, skill, module) or an empty slot to fill. */
|
||||
function SlotCard({ m, slot, pick, illegal, onOpen, onClear }) {
|
||||
const D = diyData();
|
||||
const rec = pick ? slotRecord(slot.slotId, pick, D) : null;
|
||||
const elite = pick ? slotRecord(slot.slotId, pick, D, { elite: true }) : null;
|
||||
const label = t('{tier}阶 自选', { tier: slot.tier });
|
||||
if (!rec) {
|
||||
return html`<div class=${cx('diy-slot', `diy-slot--t${slot.tier}`, 'is-empty', pick && 'is-bad')} data-slot=${slot.slotId}>
|
||||
<button type="button" class="diy-slot__fill" onClick=${() => onOpen(slot.slotId)} aria-label=${t('{slot}:选择干员', { slot: label })}>
|
||||
<${TierChip} tier=${slot.tier} size="sm" />
|
||||
<span class="diy-slot__plus"><${Icon} name="plus" /></span>
|
||||
<b>${t('选择干员')}</b>
|
||||
<small>${t('调度中心等级 {level} 起出现在商店', { level: slot.shopLevel })}</small>
|
||||
</button>
|
||||
${pick ? html`<p class="diy-slot__bad"><${Icon} name="warn" />${t('这项自选在当前版本不可用,开局时会被移除')}</p>` : null}
|
||||
</div>`;
|
||||
}
|
||||
const proto = !!pick && D.backups?.diy?.prototypes?.[slot.tier]?.includes(pick.charId);
|
||||
const sk = rec.skill;
|
||||
const mod = elite?.module?.active ? elite.module : null;
|
||||
return html`<div class=${cx('diy-slot', `diy-slot--t${slot.tier}`, illegal && 'is-bad')} data-slot=${slot.slotId} data-char=${rec.charId}>
|
||||
<div class="diy-slot__art"><${Img} src=${chessPortraitUrl(m, elite || rec)} fallback=${html`<b>${[...(rec.name || '?')][0]}</b>`} /></div>
|
||||
<div class="diy-slot__body">
|
||||
<div class="diy-slot__head"><${TierChip} tier=${slot.tier} size="sm" /><span class="diy-slot__label">${label}</span><${KindTag} proto=${proto} /></div>
|
||||
<b class="diy-slot__name">${rec.name}</b>
|
||||
<small class="diy-slot__class">${classLine(rec)}</small>
|
||||
<${Bonds} bonds=${rec.bonds} />
|
||||
<span class="diy-slot__kit" data-skill=${sk?.index ?? ''}>
|
||||
<${Img} src=${skillRecordIconUrl(m, sk, { empty: false })} class="diy-slot__sicon" fallback=${html`<b class="num">${skillLabel(sk?.index)}</b>`} />
|
||||
<span><b class="num">${skillLabel(sk?.index)}</b> ${sk?.name || ''}${proto ? html` <small class="t-dim">${t('(锁定)')}</small>` : null}</span>
|
||||
</span>
|
||||
<span class="diy-slot__kit" data-module=${mod ? mod.id : 'none'}>
|
||||
${mod ? html`<${Img} src=${moduleTypeIconUrl(data.get('local'), mod.type)} class="diy-slot__micon" fallback=${html`<b class="num">${moduleBadge({ typeName: mod.type })}</b>`} />` : html`<span class="diy-slot__micon diy-slot__micon--none">—</span>`}
|
||||
<span>${mod ? html`<b class="num">${mod.type || ''}</b> ${mod.name || ''}` : t('不装备模组')} <small class="t-dim">${t('(精锐时生效)')}</small></span>
|
||||
</span>
|
||||
${illegal ? html`<p class="diy-slot__bad"><${Icon} name="warn" />${t('这项自选在当前版本不可用,开局时会被移除')}</p>` : null}
|
||||
</div>
|
||||
<div class="diy-slot__acts">
|
||||
<${Button} variant="secondary" size="sm" icon="refresh" data-testid="diy-change" onClick=${() => onOpen(slot.slotId)}>${t('更换')}<//>
|
||||
<${Button} variant="ghost" size="sm" icon="close" data-testid="diy-clear" onClick=${() => onClear(slot.slotId)}>${t('清空')}<//>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/** One operator of the picker's list. */
|
||||
function OptionCard({ m, opt, selected, onSel }) {
|
||||
const u = opt.unit;
|
||||
const name = u?.name || opt.charId;
|
||||
const takenBy = opt.taken ? diySlotList(diyData()).find((s) => s.slotId === opt.taken) : null;
|
||||
return html`<button type="button" role="option" aria-selected=${selected ? 'true' : 'false'} data-char=${opt.charId}
|
||||
class=${cx('diy-opt', selected && 'is-sel', opt.taken && 'is-taken')} disabled=${!!opt.taken} onClick=${() => onSel(opt.charId)}
|
||||
title=${takenBy ? t('已在{tier}阶自选中', { tier: takenBy.tier }) : name}>
|
||||
<span class="diy-opt__ava"><${Img} src=${avatarOf(m, u)} fallback=${html`<b>${[...name][0]}</b>`} /></span>
|
||||
<span class="diy-opt__txt">
|
||||
<span class="diy-opt__top"><b class="diy-opt__name">${name}</b><${KindTag} proto=${opt.proto} /></span>
|
||||
<small class="diy-opt__class"><${Img} src=${profIconUrl(m, u?.profession)} class="diy-opt__prof" />${classLine(u)}</small>
|
||||
<${Bonds} bonds=${opt.bonds} />
|
||||
</span>
|
||||
${takenBy ? html`<span class="diy-opt__taken">${t('已在{tier}阶自选中', { tier: takenBy.tier })}</span>` : null}
|
||||
</button>`;
|
||||
}
|
||||
|
||||
/** A skill choice of an owned pick (or the locked skill of a prototype). */
|
||||
function SkillRow({ m, s, on, locked, onPick }) {
|
||||
const rec = s.elite || s.normal;
|
||||
return html`<button type="button" role="radio" aria-checked=${on ? 'true' : 'false'} data-skill=${s.index} disabled=${locked}
|
||||
class=${cx('diy-choice', on && 'is-on')} onClick=${() => onPick(s.index)}>
|
||||
<span class="diy-choice__icon"><${Img} src=${skillRecordIconUrl(m, rec, { empty: false })} fallback=${html`<b class="num">${skillLabel(s.index)}</b>`} /></span>
|
||||
<span class="diy-choice__body">
|
||||
<span class="diy-choice__head"><b class="num">${skillLabel(s.index)}</b><b>${rec?.name || ''}</b>${on ? html`<span class="lo-badge lo-badge--on"><${Icon} name="check" />${locked ? t('锁定') : t('已选择')}</span>` : null}</span>
|
||||
<${RichText} as="span" class="diy-choice__desc" text=${s.normal?.descRaw || s.normal?.desc || ''} />
|
||||
</span>
|
||||
</button>`;
|
||||
}
|
||||
|
||||
/** A module choice of an owned pick (不装备 or a module of the elite form). */
|
||||
function ModuleRow({ id, rec, on, locked, onPick }) {
|
||||
const none = id == null;
|
||||
const talents = rec && Array.isArray(rec.talentChanges) ? rec.talentChanges.filter((x) => x && x.desc && !x.hidden) : [];
|
||||
const trait = rec?.traitOverride ? (rec.traitOverride.moduleDescRaw || rec.traitOverride.moduleDesc || rec.traitOverride.descRaw || rec.traitOverride.desc) : null;
|
||||
return html`<button type="button" role="radio" aria-checked=${on ? 'true' : 'false'} data-module=${none ? 'none' : id} disabled=${locked}
|
||||
class=${cx('diy-choice', 'diy-choice--mod', on && 'is-on')} onClick=${() => onPick(id)}>
|
||||
<span class="diy-choice__icon">${none ? html`<b>—</b>` : html`<${Img} src=${moduleTypeIconUrl(data.get('local'), rec.typeName)} fallback=${html`<b class="num">${moduleBadge(rec)}</b>`} />`}</span>
|
||||
<span class="diy-choice__body">
|
||||
<span class="diy-choice__head">${none ? html`<b>${t('不装备')}</b>` : html`<b class="num">${rec.typeName || ''}</b><b>${rec.name || id}</b>`}${on ? html`<span class="lo-badge lo-badge--on"><${Icon} name="check" />${locked ? t('锁定') : t('已选择')}</span>` : null}</span>
|
||||
${trait ? html`<${RichText} as="span" class="diy-choice__desc" text=${trait} />` : null}
|
||||
${talents.map((x, i) => html`<${RichText} key=${i} as="span" class="diy-choice__desc" text=${x.descRaw || x.desc} />`)}
|
||||
</span>
|
||||
</button>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The slot picker: the legal operators of the slot's tier (prototypes first, then the owned 6★; a 原型 / 已持有 filter and
|
||||
* a search), the chosen one's skill / module (an owned pick) or its locked selection (a prototype), 确认 / 取消. The
|
||||
* state (filter, search, the draft pick) lives here; DiyPickerView draws it.
|
||||
* @param {{ m: any, slot: any, picks: Record<string, any>, kitted: string[]|null, onDone: (pick: any) => void, onClose: () => void }} props
|
||||
*/
|
||||
export function DiyPicker(props) {
|
||||
const cur = props.picks[props.slot.slotId] || null;
|
||||
const [filter, setFilter] = useState('all');
|
||||
const [query, setQuery] = useState('');
|
||||
const [draft, setDraft] = useState(cur ? { ...cur } : null);
|
||||
return DiyPickerView({ ...props, filter, query, draft, onFilter: setFilter, onQuery: setQuery, onDraft: setDraft });
|
||||
}
|
||||
|
||||
/**
|
||||
* The picker's view (no hooks: the tests draw it): `filter` 'all' | 'proto' | 'owned', `query`, `draft` the pick being
|
||||
* made (null = none chosen yet) and their setters.
|
||||
*/
|
||||
export function DiyPickerView({ m, slot, picks, kitted, onDone, onClose, filter, query, draft, onFilter: setFilter, onQuery: setQuery, onDraft: setDraft }) {
|
||||
const D = diyData();
|
||||
const cur = picks[slot.slotId] || null;
|
||||
const options = pickOptions(slot.slotId, picks, D, kitted);
|
||||
const q = query.trim().toLowerCase();
|
||||
const list = options.filter((o) => (filter === 'all' || (filter === 'proto') === o.proto)
|
||||
&& (!q || [o.unit?.name, o.unit?.appellation, o.unit?.subProfessionName, t(PROF_NAME[o.unit?.profession] || ''), ...o.bonds.map(bondName)].some((x) => typeof x === 'string' && x.toLowerCase().includes(q))));
|
||||
const ch = draft ? pickChoices(draft.charId, slot.slotId, D) : null;
|
||||
const sel = (charId) => setDraft(cur && cur.charId === charId ? { ...cur } : defaultPick(charId, slot.slotId, D));
|
||||
const lk = ch?.locked || null;
|
||||
const skillOn = ch ? (ch.proto ? lk?.skillIndex : draft.skillIndex) : null;
|
||||
const modOn = ch ? (ch.proto ? lk?.uniEquipId ?? null : draft.uniEquipId ?? null) : null;
|
||||
const unit = draft ? D.backups?.units?.[draft.charId] : null;
|
||||
const label = t('{tier}阶 自选', { tier: slot.tier });
|
||||
return html`<section class="diy-pick" role="dialog" aria-label=${t('{slot}:选择干员', { slot: label })} data-testid="diy-picker" data-slot=${slot.slotId}>
|
||||
<header class="diy-pick__head">
|
||||
<${TierChip} tier=${slot.tier} size="sm" /><b>${label}</b>
|
||||
<span class="diy-pick__note">${slot.tier === 5 ? t('6★ 原型干员与持有的 6★ 干员,另有 4★ 原型干员(先锋、特种除外)') : t('6★ 原型干员与持有的 6★ 干员')}</span>
|
||||
<button type="button" class="diy-pick__x" aria-label=${t('关闭')} onClick=${onClose}><${Icon} name="close" /></button>
|
||||
</header>
|
||||
<div class="diy-pick__bar">
|
||||
<div class="lo-chips" role="group" aria-label=${t('筛选')}>
|
||||
${[['all', t('全部')], ['proto', t('原型')], ['owned', t('已持有')]].map(([k, text]) => html`<button key=${k} type="button" class=${cx('lo-chip', filter === k && 'is-on')}
|
||||
aria-pressed=${filter === k ? 'true' : 'false'} data-filter=${k} onClick=${() => setFilter(k)}>${text}</button>`)}
|
||||
</div>
|
||||
<input type="search" class="diy-pick__search" value=${query} placeholder=${t('搜索干员 / 职业 / 盟约')} aria-label=${t('搜索干员 / 职业 / 盟约')}
|
||||
onInput=${(e) => setQuery(String(e.currentTarget.value).slice(0, 24))} />
|
||||
</div>
|
||||
<div class="diy-pick__main">
|
||||
<div class="diy-pick__list" role="listbox" aria-label=${t('可选干员')}>
|
||||
${kitted == null ? html`<p class="lo-empty t-dim">${t('连接服务器后才能选择自选干员')}</p>`
|
||||
: list.length ? list.map((o) => html`<${OptionCard} key=${o.charId} m=${m} opt=${o} selected=${draft?.charId === o.charId} onSel=${sel} />`)
|
||||
: html`<p class="lo-empty t-dim">${t('没有符合条件的干员')}</p>`}
|
||||
</div>
|
||||
<div class="diy-pick__detail">
|
||||
${ch && unit ? html`
|
||||
<div class="diy-pick__who">
|
||||
<span class="diy-pick__ava"><${Img} src=${avatarOf(m, unit, true)} fallback=${html`<b>${[...(unit.name || '?')][0]}</b>`} /></span>
|
||||
<span class="diy-pick__wtxt"><b>${unit.name}</b><small>${classLine(unit)}</small></span>
|
||||
<${KindTag} proto=${ch.proto} />
|
||||
</div>
|
||||
<h4 class="diy-pick__sec">${t('技能')}${ch.proto ? html`<small>${t('原型干员的技能与补位时一致,不可更改')}</small>` : null}</h4>
|
||||
<div role="radiogroup" aria-label=${t('选择技能')} class="diy-pick__choices">
|
||||
${ch.skills.filter((s) => !ch.proto || s.index === skillOn).map((s) => html`<${SkillRow} key=${s.index} m=${m} s=${s} on=${s.index === skillOn} locked=${ch.proto}
|
||||
onPick=${(i) => setDraft({ ...draft, skillIndex: i })} />`)}
|
||||
</div>
|
||||
<h4 class="diy-pick__sec">${t('模组')}<small>${t('精锐时生效 · 模组等级 {stage}', { stage: ch.stage })}</small></h4>
|
||||
<div role="radiogroup" aria-label=${t('选择模组')} class="diy-pick__choices">
|
||||
${ch.proto
|
||||
? html`<${ModuleRow} id=${modOn} rec=${modOn ? ch.elite.modules?.find((x) => x.uniEquipId === modOn) || null : null} on=${true} locked=${true} onPick=${() => {}} />`
|
||||
: [html`<${ModuleRow} key="none" id=${null} rec=${null} on=${modOn == null} locked=${false} onPick=${() => setDraft({ ...draft, uniEquipId: null })} />`,
|
||||
...ch.modules.map((x) => html`<${ModuleRow} key=${x.uniEquipId} id=${x.uniEquipId} rec=${x.rec} on=${modOn === x.uniEquipId} locked=${false}
|
||||
onPick=${(id) => setDraft({ ...draft, uniEquipId: id })} />`)]}
|
||||
</div>` : html`<p class="lo-empty t-dim">${t('从左侧选择一名干员')}</p>`}
|
||||
</div>
|
||||
</div>
|
||||
<footer class="diy-pick__foot">
|
||||
<${Button} variant="ghost" onClick=${onClose}>${t('取消')}<//>
|
||||
<${Button} variant="primary" icon="check" data-testid="diy-confirm" disabled=${!ch} onClick=${() => onDone(ch.proto ? { charId: draft.charId } : draft)}>${t('确认')}<//>
|
||||
</footer>
|
||||
</section>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The tab's body: the four slots by tier, and the picker of the slot being filled (`picking`, kept here).
|
||||
* @param {{ m: any, picks: Record<string, any>, legal: Record<string, any>, kitted: string[]|null,
|
||||
* onSet: (slotId: string, pick: any) => void }} props
|
||||
*/
|
||||
export function DiyPanel(props) {
|
||||
const [picking, setPicking] = useState(null);
|
||||
return DiyPanelView({ ...props, picking, onPicking: setPicking });
|
||||
}
|
||||
|
||||
/** The tab's view (no hooks: the tests draw it): `picking` = the slot being filled, or null. */
|
||||
export function DiyPanelView({ m, picks, legal, kitted, onSet, picking, onPicking: setPicking }) {
|
||||
const slots = diySlotList(diyData());
|
||||
const slot = picking ? slots.find((s) => s.slotId === picking) : null;
|
||||
const tiers = [...new Set(slots.map((s) => s.tier))];
|
||||
return html`<main class="diy" data-testid="diy">
|
||||
<div class="own__bar">
|
||||
<p class="own__lead">${t('5阶与6阶各有两个自选名额:选择持有的 6★ 干员(任选技能与模组)或原型干员(技能锁定)。自选干员只出现在你自己的商店里,调度中心达到该阶等级后才会刷新,价格与同阶干员相同;盟约按干员实际所属阵营分配,没有特质。')}</p>
|
||||
</div>
|
||||
<div class="diy__body">
|
||||
${tiers.map((tier) => html`<section key=${tier} class="own-tier diy-tier" data-tier=${tier} aria-label=${t('{tier}阶', { tier })}>
|
||||
<h3 class="own-tier__head"><${TierChip} tier=${tier} size="sm" /><span class="num">${ROMAN[tier]}</span><span>${t('阶')}</span></h3>
|
||||
<div class="diy-grid">
|
||||
${slots.filter((s) => s.tier === tier).map((s) => html`<${SlotCard} key=${s.slotId} m=${m} slot=${s} pick=${picks[s.slotId] || null}
|
||||
illegal=${!!picks[s.slotId] && !legal[s.slotId]} onOpen=${setPicking} onClear=${(id) => onSet(id, null)} />`)}
|
||||
</div>
|
||||
</section>`)}
|
||||
</div>
|
||||
${slot ? html`<${DiyPicker} m=${m} slot=${slot} picks=${picks} kitted=${kitted} onClose=${() => setPicking(null)}
|
||||
onDone=${(pick) => { onSet(slot.slotId, pick); setPicking(null); }} />` : null}
|
||||
</main>`;
|
||||
}
|
||||
@@ -96,7 +96,7 @@ import {
|
||||
snapHud, activeBubbles, shortcutFor, shortcutBlocked, closesOnFieldPress, phaseTotalSeconds, homeFieldId, ownFieldId, normalizeSp, sortedPlayers,
|
||||
countdownState, shopBlockReason, stageOverrides, effectiveStage, watchTarget, dropFailureReason,
|
||||
previewEnemyKey, prepCamera, prepCameraFor, foldCamera, deployFieldOf, panelSide, panelSlots, bondPopupPlace, unitLoadout, deployedRecord,
|
||||
mergeTarget, modeOffBonds, readyFundsPrompt, ownerBandId,
|
||||
mergeTarget, modeOffBonds, readyFundsPrompt, ownerBandId, ownDiyRecord,
|
||||
} from '../ui/gameLogic.js';
|
||||
import { toast } from '../ui/toasts.js';
|
||||
import { BriefingScreen } from './briefing.js';
|
||||
@@ -236,9 +236,13 @@ function MatchScreen() {
|
||||
// the field the own pieces are deployed on: the own board, or the player's half of the boss field in a boss round's
|
||||
// prep (user playtest #5 item 7: legality and the legal-tile highlights read THOSE tiles, like the server)
|
||||
const deployField = deployFieldOf(pub, myId);
|
||||
// the own pieces' records: a DIY slot the player filled is its 自选 operator (0.2.0, m.private.diy — position, range,
|
||||
// name; gameLogic/diy.js), every other chess its data record
|
||||
const ownChess = (id) => { const c = gd.chess(id); return ownDiyRecord(c, live.current.priv, { chess: data.get('chess'), backups: data.get('backups') }) || c; };
|
||||
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, backups: gd.backups,
|
||||
getChess: (id) => { const c = gd.chess(id); return ownDiyRecord(c, priv, { chess: data.get('chess'), backups: data.get('backups') }) || c; },
|
||||
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 };
|
||||
|
||||
@@ -719,12 +723,12 @@ 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) => deployedRecord(rec, live.current.priv, gd.chess, gd.backups) };
|
||||
const lookups = { getChess: ownChess, getToken: gd.token, getItem: gd.item, chessRecord: (rec) => deployedRecord(rec, live.current.priv, ownChess, gd.backups) };
|
||||
const runIntent = async (intent) => {
|
||||
const L = live.current;
|
||||
if (intent.confirmReplace) {
|
||||
// both slots used: the player picks the equipped item to destroy (cancel ⇒ nothing is sent)
|
||||
const request = replaceRequest(L.placeCtx, intent, gd.chess, gd.item);
|
||||
const request = replaceRequest(L.placeCtx, intent, ownChess, gd.item);
|
||||
if (request) {
|
||||
const uid = await openReplaceRef.current(request);
|
||||
if (!Number.isInteger(uid)) { audio.sfx('back', { volume: 0.5 }); return; }
|
||||
@@ -750,7 +754,7 @@ function MatchScreen() {
|
||||
/** Open the direction wheel for a legal board drop (the piece stays on the tile meanwhile). */
|
||||
const openFacing = (entry, t) => {
|
||||
const piece = entry.piece;
|
||||
const rec = piece.kind === 'item' ? gd.item(piece.id) : piece.kind === 'token' ? gd.token(piece.id) : gd.chess(piece.id);
|
||||
const rec = piece.kind === 'item' ? gd.item(piece.id) : piece.kind === 'token' ? gd.token(piece.id) : ownChess(piece.id);
|
||||
holdPiece(view, piece.uid, { row: t.row, col: t.col });
|
||||
setSel(null);
|
||||
setFacing({ uid: piece.uid, piece, row: t.row, col: t.col, grid: previewGrid(lookups, piece), name: rec?.name || '' });
|
||||
@@ -889,8 +893,8 @@ 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); 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) => deployedRecord(rec, live.current.priv, gd.chess, gd.backups) }), [gd.ready]);
|
||||
const lookups = useMemo(() => ({ getChess: ownChess, getToken: gd.token, getItem: gd.item,
|
||||
chessRecord: (rec) => deployedRecord(rec, live.current.priv, ownChess, 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);
|
||||
@@ -1200,7 +1204,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}
|
||||
${showPrep && view && viewKind !== 'loading' && priv && !pen ? html`<${StandInTags} view=${view} priv=${priv} getChess=${gd.chess} backups=${gd.backups} diyData=${{ chess: data.get('chess'), backups: data.get('backups') }} />` : null}
|
||||
|
||||
<div class="gm__hud" ref=${hudElRef}>
|
||||
<${TopBar} pub=${pub} priv=${priv} conn=${conn} hud=${hud} total=${total} drawer=${drawer}
|
||||
@@ -1270,7 +1274,7 @@ function MatchScreen() {
|
||||
|
||||
${selEntry && editable && !facing && !drag && showPrep ? html`<${Underframe} key=${sel.uid} view=${view} uid=${sel.uid}
|
||||
row=${pieceTile(selEntry)?.row} col=${pieceTile(selEntry)?.col} actions=${underframeActions(placeCtx, sel.uid)} busy=${selBusy}
|
||||
name=${(selEntry.piece.kind === 'item' ? gd.item(selEntry.piece.id) : selEntry.piece.kind === 'token' ? gd.token(selEntry.piece.id) : gd.chess(selEntry.piece.id))?.name || ''}
|
||||
name=${(selEntry.piece.kind === 'item' ? gd.item(selEntry.piece.id) : selEntry.piece.kind === 'token' ? gd.token(selEntry.piece.id) : ownChess(selEntry.piece.id))?.name || ''}
|
||||
onRetreat=${retreatSel} onSell=${sellSel} onDestroy=${sellSel} />` : null}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
// 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.
|
||||
// chess it is either way. 0.2.0 自选编队: the 「自选」 tag under the player's own pieces of a DIY slot it filled
|
||||
// (m.private.diy) — the model is the operator's everywhere. Anchored to each piece's drawn body (view.pieceScreenRect,
|
||||
// followed every frame like the direction wheel follows its tile); never takes the pointer, so the pieces stay
|
||||
// draggable.
|
||||
|
||||
import { useEffect, useRef, useState } from '../../../vendor/hooks.module.js';
|
||||
import { html } from '../../ui/components.js';
|
||||
import { fieldsStandIn, standInOf, standInLabel } from '../../ui/gameLogic.js';
|
||||
import { fieldsStandIn, standInOf, standInLabel, ownDiyRecord } from '../../ui/gameLogic.js';
|
||||
import { t } from '../../../../shared/i18n.js';
|
||||
|
||||
const rawOf = (view) => (view && view.raw) || view || null;
|
||||
|
||||
@@ -30,9 +33,27 @@ export function standInPieces(priv, getChess, backups) {
|
||||
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);
|
||||
/**
|
||||
* The player's own prep pieces of a DIY slot it filled (0.2.0 自选编队): [{ uid, label, name, area, diy: true }].
|
||||
* @param {any} priv m.private @param {(id: string) => any} getChess @param {{ chess: any, backups: any }} data
|
||||
*/
|
||||
export function diyPieces(priv, getChess, data) {
|
||||
const out = [];
|
||||
if (!priv || !priv.diy || typeof priv.diy !== 'object' || !Object.keys(priv.diy).length) return out;
|
||||
const add = (p, area) => {
|
||||
if (!p || p.kind !== 'chess' || !Number.isInteger(p.uid)) return;
|
||||
const rec = ownDiyRecord(getChess(p.id), priv, data);
|
||||
if (rec) out.push({ uid: p.uid, label: t('自选'), name: rec.name, area, diy: true });
|
||||
};
|
||||
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, diyData?: { chess: any, backups: any } | null }} props */
|
||||
export function StandInTags({ view, priv, getChess, backups, diyData = null }) {
|
||||
const items = [...standInPieces(priv, getChess, backups), ...(diyData ? diyPieces(priv, getChess, diyData) : [])];
|
||||
const [pos, setPos] = useState({});
|
||||
const last = useRef('');
|
||||
const key = items.map((it) => it.uid).join(',');
|
||||
@@ -64,7 +85,8 @@ export function StandInTags({ view, priv, getChess, backups }) {
|
||||
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} 上场`}
|
||||
return p ? html`<span key=${it.uid} class=${`sitag sitag--${it.area}${it.diy ? ' sitag--diy' : ''}`} data-uid=${it.uid} data-diy=${it.diy ? '1' : undefined}
|
||||
title=${it.diy ? t('自选编队:{name}', { name: it.name }) : `未持有:由 ${it.name} 上场`}
|
||||
style=${`left:${p.x.toFixed(1)}px;top:${p.y.toFixed(1)}px`}>${it.label}</span>` : null;
|
||||
})}
|
||||
</div>`;
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
// The loadout lives in ui/loadoutSync.js (localStorage + room.loadout); the model is ui/loadoutModel.js.
|
||||
// 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 导出 / 导入 / 全部持有.
|
||||
// The third tab, 自选编队 (0.2.0 DIY, screens/diy.js): the operators of the four DIY slots (room.diy; ui/diyModel.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';
|
||||
@@ -29,9 +31,12 @@ 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, setNotOwned, applyOwnershipImport } from '../ui/loadoutSync.js';
|
||||
import { loadoutStore, openLoadout, closeLoadout, setEntries, applyLoadoutEntries, setNotOwned, applyOwnershipImport, setDiyPicks, applyDiyImport } from '../ui/loadoutSync.js';
|
||||
import { setOwned, notOwnedCount, serializeOwnership, parseOwnershipImport, OWNERSHIP_IMPORT_MAX_BYTES } from '../ui/ownershipModel.js';
|
||||
import { OwnershipPanel, useOwnershipRoster } from './ownership.js';
|
||||
import { DiyPanel, diyData } from './diy.js';
|
||||
import { diyCount, sanitizeDiyPicks, setPick, serializeDiy, parseDiyImport, DIY_IMPORT_MAX_BYTES } from '../ui/diyModel.js';
|
||||
import { t } from '../../../shared/i18n.js';
|
||||
import { copyText } from '../ui/clipboard.js';
|
||||
import { toast } from '../ui/toasts.js';
|
||||
|
||||
@@ -71,7 +76,7 @@ function readFileText(file) {
|
||||
});
|
||||
}
|
||||
|
||||
/** `stronghold-loadout-20261003-1245.json` (`stronghold-ownership-…` for the 干员持有 list) */
|
||||
/** `stronghold-loadout-20261003-1245.json` (`stronghold-ownership-…` for the 干员持有 list, `stronghold-diy-…` for 自选编队) */
|
||||
function exportFilename(now = new Date(), what = 'loadout') {
|
||||
const p = (n) => String(n).padStart(2, '0');
|
||||
return `stronghold-${what}-${now.getFullYear()}${p(now.getMonth() + 1)}${p(now.getDate())}-${p(now.getHours())}${p(now.getMinutes())}.json`;
|
||||
@@ -383,10 +388,12 @@ const SYNC_TEXT = {
|
||||
};
|
||||
/** The 干员持有 tab's status line: the setting never applies to a running match. */
|
||||
const OWN_SYNC_TEXT = { ...SYNC_TEXT, locked: ['下一局生效', 'is-warn'] };
|
||||
/** The 自选编队 tab's status line (msgids, translated where shown): out of match, like 干员持有. */
|
||||
const DIY_SYNC_TEXT = { ...OWN_SYNC_TEXT };
|
||||
|
||||
/** The overlay screen. */
|
||||
function LoadoutScreen({ st }) {
|
||||
const ready = useData('chess', 'bonds', 'assets', 'local');
|
||||
const ready = useData('chess', 'bonds', 'assets', 'local', 'backups');
|
||||
const phase = useStore((s) => s.match?.public?.phase || null);
|
||||
const inMatch = useStore((s) => !!s.room?.inMatch);
|
||||
// co-op briefing (INFO_CHECK, 25 s): the overlay covers the briefing's own countdown, so it shows the time left — the
|
||||
@@ -411,9 +418,18 @@ 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 tab = st.tab === 'ownership' || st.tab === 'diy' ? st.tab : 'loadout';
|
||||
const ownRoster = useOwnershipRoster(ready);
|
||||
const nNotOwned = notOwnedCount(st.notOwned, ownRoster);
|
||||
// 自选编队 (0.2.0 DIY): the stored picks, those the server would keep (the kit list of the last welcome)
|
||||
const nDiy = diyCount(st.diy);
|
||||
const diyLegal = ready ? sanitizeDiyPicks(st.diy, diyData(), st.diyKitted || []) : {};
|
||||
const setDiySlot = (slotId, pick) => setDiyPicks(setPick(loadoutStore.get().diy, slotId, pick));
|
||||
const clearDiy = async () => {
|
||||
if (!nDiy) return;
|
||||
const ok = await confirmDialog({ title: t('全部清空'), text: t('清空全部 {n} 个自选名额?', { n: nDiy }), okText: t('全部清空') });
|
||||
if (ok) setDiyPicks({});
|
||||
};
|
||||
const setTab = (t) => loadoutStore.set({ tab: t });
|
||||
const toggleOwned = (id, owned) => setNotOwned(setOwned(loadoutStore.get().notOwned, id, owned));
|
||||
const ownAll = async () => {
|
||||
@@ -434,27 +450,38 @@ function LoadoutScreen({ st }) {
|
||||
// the clipboard, or the textarea); `io.kind` says which
|
||||
const ioText = io?.text ?? '';
|
||||
const ioOwn = io?.kind === 'ownership';
|
||||
const ioDiy = io?.kind === 'diy';
|
||||
const openExport = () => setIo(tab === 'ownership'
|
||||
? { mode: 'export', kind: 'ownership', text: serializeOwnership(loadoutStore.get().notOwned) }
|
||||
: { mode: 'export', kind: 'loadout', text: serializeExport(loadoutStore.get().entries) });
|
||||
: tab === 'diy' ? { mode: 'export', kind: 'diy', text: serializeDiy(loadoutStore.get().diy) }
|
||||
: { 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(new Date(), ioOwn ? 'ownership' : 'loadout'), ioText);
|
||||
const ioDownload = () => downloadText(exportFilename(new Date(), ioOwn ? 'ownership' : ioDiy ? 'diy' : '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 (ioDiy && f.size > DIY_IMPORT_MAX_BYTES) { toast(t('文件过大,请选择「导出」下载的自选编队文件'), 'error'); return; }
|
||||
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 (ioDiy) {
|
||||
const r = parseDiyImport(ioText);
|
||||
if (!r.ok) { toast(t('导入失败:{error}', { error: t(r.error, r.params) }), 'error'); return; }
|
||||
const { applied, dropped } = applyDiyImport(r.picks, diyData(), loadoutStore.get().diyKitted || []);
|
||||
setIo(null);
|
||||
toast(applied ? t('已导入 {n} 个自选名额', { n: applied }) + (dropped ? t('(另有 {n} 项不可用,未导入)', { n: dropped }) : '') : t('已导入:全部名额为空'), dropped ? 'warn' : 'success');
|
||||
return;
|
||||
}
|
||||
if (ioOwn) {
|
||||
const r = parseOwnershipImport(ioText);
|
||||
if (!r.ok) { toast(`导入失败:${r.error}`, 'error'); return; }
|
||||
@@ -482,7 +509,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') && loadoutStore.get().tab !== 'ownership') {
|
||||
if ((e.key === 'ArrowRight' || e.key === 'ArrowLeft') && loadoutStore.get().tab !== 'ownership' && loadoutStore.get().tab !== 'diy') {
|
||||
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));
|
||||
@@ -503,12 +530,15 @@ function LoadoutScreen({ st }) {
|
||||
if (el && typeof el.scrollIntoView === 'function') el.scrollIntoView({ block: 'nearest' });
|
||||
}, [selId]);
|
||||
|
||||
const [syncText, syncCls] = tab === 'ownership' ? OWN_SYNC_TEXT[st.ownSync] || OWN_SYNC_TEXT.idle : SYNC_TEXT[st.sync] || SYNC_TEXT.idle;
|
||||
const [syncText, syncCls] = tab === 'ownership' ? OWN_SYNC_TEXT[st.ownSync] || OWN_SYNC_TEXT.idle
|
||||
: tab === 'diy' ? DIY_SYNC_TEXT[st.diySync] || DIY_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 ? '干员持有是局外设置:本局按开局时的设置进行,修改将在下一局生效'
|
||||
: '局外设置,下一局生效 · 联机时只影响你自己的棋子 · 默认全部持有';
|
||||
const diyText = ownLocked ? t('自选编队是局外设置:本局按开局时的设置进行,修改将在下一局生效')
|
||||
: t('局外设置,下一局生效 · 联机时只进入你自己的商店');
|
||||
|
||||
return html`<${Fragment}>
|
||||
<div class="lo" role="dialog" aria-modal="true" aria-label="干员调配">
|
||||
@@ -518,15 +548,23 @@ 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">${tab === 'ownership' ? 'OPERATOR ROSTER' : 'OPERATOR LOADOUT'}<//>
|
||||
<div class="lo-tabs" role="tablist" aria-label="干员调配 / 干员持有">
|
||||
<${MicroLabel} tone="mint">${tab === 'ownership' ? 'OPERATOR ROSTER' : tab === 'diy' ? 'SELF-SELECT SQUAD' : 'OPERATOR LOADOUT'}<//>
|
||||
<div class="lo-tabs" role="tablist" aria-label=${t('干员调配 / 干员持有 / 自选编队')}>
|
||||
<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>
|
||||
<button type="button" role="tab" aria-selected=${tab === 'diy' ? 'true' : 'false'} data-tab="diy" class=${cx('lo-tab', tab === 'diy' && 'is-on')} onClick=${() => setTab('diy')}
|
||||
title=${t('5阶、6阶的自选名额:持有的 6★ 干员或原型干员')}>${t('自选编队')}${nDiy ? html`<span class="lo-tab__n num" aria-label=${t('{n} 个自选名额已选择', { n: nDiy })}>${nDiy}</span>` : null}</button>
|
||||
</div>
|
||||
</div>
|
||||
${tab === 'ownership' ? html`<div class="lo-top__right">
|
||||
${tab === 'diy' ? html`<div class="lo-top__right">
|
||||
${syncText ? html`<span class=${cx('lo-sync', syncCls)} role="status" data-testid="diy-sync">${t(syncText)}</span>` : null}
|
||||
<span class="lo-count">${t('已选')} <b class="num">${nDiy}</b><span class="num t-dim">/4</span></span>
|
||||
<${Button} variant="ghost" size="sm" data-testid="diy-export" disabled=${!nDiy} onClick=${openExport} title=${t('导出自选编队(可复制或下载)')}>${t('导出')}<//>
|
||||
<${Button} variant="ghost" size="sm" data-testid="diy-import" disabled=${!ready} onClick=${openImport} title=${t('导入自选编队(粘贴或选择文件)')}>${t('导入')}<//>
|
||||
<${Button} variant="secondary" size="sm" icon="refresh" data-testid="diy-reset" disabled=${!nDiy} onClick=${clearDiy}>${t('全部清空')}<//>
|
||||
</div>` : 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="导出干员持有(可复制或下载)">导出<//>
|
||||
@@ -541,10 +579,14 @@ function LoadoutScreen({ st }) {
|
||||
<${Button} variant="secondary" size="sm" icon="refresh" disabled=${!nChanged} onClick=${resetAll}>全部恢复默认<//>
|
||||
</div>`}
|
||||
</header>
|
||||
${tab === 'ownership'
|
||||
${tab === 'diy'
|
||||
? html`<p class=${cx('lo-note', ownLocked && 'is-locked')}><${Icon} name="info" />${diyText}</p>`
|
||||
: 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'
|
||||
${!ready ? html`<div class="lo-loading"><${Spinner} size="sm" />正在载入干员数据(打开页面后仅载入一次)…</div>` : tab === 'diy'
|
||||
? html`<${DiyPanel} m=${m} picks=${st.diy || {}} legal=${diyLegal} kitted=${st.diyKitted} onSet=${setDiySlot} />`
|
||||
: 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">
|
||||
@@ -562,7 +604,8 @@ function LoadoutScreen({ st }) {
|
||||
</main>`}
|
||||
</div>
|
||||
${io ? html`<${Modal} open=${true} onClose=${() => setIo(null)}
|
||||
title=${`${io.mode === 'export' ? '导出' : '导入'}${ioOwn ? '干员持有' : '干员调配'}`} micro=${ioOwn ? 'OPERATOR ROSTER' : 'OPERATOR LOADOUT'}
|
||||
title=${ioDiy ? t(io.mode === 'export' ? '导出自选编队' : '导入自选编队') : `${io.mode === 'export' ? '导出' : '导入'}${ioOwn ? '干员持有' : '干员调配'}`}
|
||||
micro=${ioOwn ? 'OPERATOR ROSTER' : ioDiy ? 'SELF-SELECT SQUAD' : '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}>复制<//>
|
||||
@@ -570,7 +613,11 @@ 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">${ioOwn
|
||||
<p class="lo-io__hint">${ioDiy
|
||||
? (io.mode === 'export'
|
||||
? t('共 {n} 个自选名额已选择。复制或下载这份数据,即可在别的设备或浏览器上导入。', { n: nDiy })
|
||||
: t('把导出的自选编队数据粘贴到下方,或点「选择文件」。导入会覆盖当前的自选编队。'))
|
||||
: ioOwn
|
||||
? (io.mode === 'export'
|
||||
? html`共 <b class="num">${nNotOwned}</b> 名干员未持有。复制或下载这份数据,即可在别的设备或浏览器上导入。`
|
||||
: html`把导出的干员持有数据粘贴到下方,或点「选择文件」。导入会<strong>覆盖</strong>当前的干员持有设置。`)
|
||||
@@ -578,7 +625,7 @@ function LoadoutScreen({ st }) {
|
||||
? 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' ? '' : ioOwn ? '在此粘贴导出的干员持有内容…' : '在此粘贴导出的调配内容…'}
|
||||
placeholder=${io.mode === 'export' ? '' : ioDiy ? t('在此粘贴导出的自选编队内容…') : 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}
|
||||
@@ -632,14 +679,18 @@ export function LoadoutButton({ from, size = 'md', variant = 'secondary', class:
|
||||
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 diy = useStore((s) => s.diy, 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;
|
||||
// 0.2.0 自选编队: how many DIY slots the player filled
|
||||
const nDiy = diyCount(diy);
|
||||
return html`<button type="button" class=${cx('btn', `btn--${variant}`, `btn--${size}`, 'lo-entry', cls)} data-testid="loadout-open"
|
||||
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}
|
||||
${nDiy ? html`<span class="lo-entry__own lo-entry__diy" aria-label=${t('{n} 个自选名额已选择', { n: nDiy })} title=${t('{n} 个自选名额已选择', { n: nDiy })}>${t('自选')} <b class="num">${nDiy}</b></span>` : null}
|
||||
</button>`;
|
||||
}
|
||||
@@ -6,7 +6,9 @@
|
||||
// { victory, roundsPassed, lastRound?, hiddenCleared?, bossId?, hiddenBossId?, difficulty?, modeId?, durationMs?,
|
||||
// players: [{ playerId, seat, name, isBot, alive, lp, bandId, roundsPassed?,
|
||||
// title: 'comment_1' | { id, name? } | null,
|
||||
// lineup: [{ id /* chessId, golden id if elite */, golden?, tier?, items?: [itemId] }],
|
||||
// lineup: [{ id /* chessId, golden id if elite */, golden?, tier?, items?: [itemId],
|
||||
// diy? /* 0.2.0 自选编队: a DIY slot's pick { charId, skillIndex, uniEquipId } — the card
|
||||
// draws the operator (gameLogic diyRecordFor) */ }],
|
||||
// bonds?: [{ bondId, layers, active }],
|
||||
// stats: { dmgDealt, kills, leaks, gold /* funds SPENT */, refreshes, merges, bossDamage?, itemsEquipped?,
|
||||
// activatedLayers?, lpLost?, perfectRounds? } }] }
|
||||
@@ -14,7 +16,8 @@
|
||||
import { useEffect } from '../../vendor/hooks.module.js';
|
||||
import { html, Button, Icon, MicroLabel, DifficultyTag } from '../ui/components.js';
|
||||
import { useGameData, Img, UnitThumb, BandIcon, PlayerAvatar, BondGlyph, LpTower, Sprite } from '../ui/gameComponents.js';
|
||||
import { normalizeResult, fmtNum } from '../ui/gameLogic.js';
|
||||
import { normalizeResult, fmtNum, diyRecordFor } from '../ui/gameLogic.js';
|
||||
import { data } from '../data.js';
|
||||
import { enemyIconUrl, titleIconUrl, uiUrl } from '../ui/assetUrls.js';
|
||||
import { store, useStore, emptyMatch } from '../store.js';
|
||||
import { audio } from '../audio.js';
|
||||
@@ -46,7 +49,8 @@ function PlayerCard({ p, myId, titles, best, solo = false }) {
|
||||
</div>
|
||||
<div class="rcard__mid">
|
||||
<div class="rcard__lineup">
|
||||
${lineup.length ? lineup.map((u, i) => html`<${UnitThumb} key=${i} kind=${u.kind === 'token' ? 'token' : 'chess'} id=${u.id} golden=${!!u.golden} tier=${u.tier} size="sm" />`)
|
||||
${lineup.length ? lineup.map((u, i) => html`<${UnitThumb} key=${i} kind=${u.kind === 'token' ? 'token' : 'chess'} id=${u.id} golden=${!!u.golden} tier=${u.tier} size="sm"
|
||||
rec=${u.diy ? diyRecordFor(gd.chess(u.id), u.diy, { chess: data.get('chess'), backups: data.get('backups') }) : null} />`)
|
||||
: html`<span class="rcard__noinfo">${p.alive === false ? '阵容已撤离' : 'NO INFO'}</span>`}
|
||||
</div>
|
||||
${bonds.length ? html`<div class="rcard__bonds">${bonds.map((b) => html`<span key=${b.bondId} class=${cx('rbond', b.active && 'is-on')} title=${gd.bond(b.bondId)?.name || b.bondId}>
|
||||
|
||||
@@ -21,10 +21,11 @@
|
||||
|
||||
import { html, BondDisc, Icon, MicroLabel, Tooltip } from './components.js';
|
||||
import { RichText, UnitThumb, BondGlyph, GIcon } from './gameComponents.js';
|
||||
import { sortBonds, bondMembers, nextThreshold, bondTier, harmonyMembers, HARMONY_BOND, memberHeadCount, briefingBondTip } from './gameLogic.js';
|
||||
import { sortBonds, bondMembers, nextThreshold, bondTier, harmonyMembers, HARMONY_BOND, memberHeadCount, briefingBondTip, diyGetter, diyRecordFor } from './gameLogic.js';
|
||||
import { formatBondEffect } from './richText.js';
|
||||
import { bondIconUrl } from './assetUrls.js';
|
||||
import { data } from '../data.js';
|
||||
import { t } from '../../../shared/i18n.js';
|
||||
|
||||
const cx = (...p) => p.flat().filter(Boolean).join(' ');
|
||||
|
||||
@@ -99,8 +100,11 @@ export function BondPopup({ bondId, entry, priv, banned = [], onClose, onMember,
|
||||
const th = Array.isArray(entry?.thresholds) && entry.thresholds.length ? entry.thresholds : b.thresholds || [];
|
||||
const tier = off ? 0 : entry?.tier ?? bondTier(count, th, b.maxCount);
|
||||
const active = !off && (entry ? !!entry.active : tier > 0);
|
||||
const getChess = (id) => data.lookup('chess', id);
|
||||
const members = bondMembers(b, priv, banned, getChess, (id) => data.lookup('items', id));
|
||||
// (自选 pieces are their operators: the own ones by m.private.diy, a watched teammate's by the pick its unit carries)
|
||||
const dd = { chess: data.get('chess'), backups: data.get('backups') };
|
||||
const getChess = diyGetter((id) => data.lookup('chess', id), priv, dd);
|
||||
const pieceRecord = (p) => (p && p.diy && typeof p.diy === 'object' ? diyRecordFor(data.lookup('chess', p.id), p.diy, dd) : null) || getChess(p.id);
|
||||
const members = bondMembers(b, priv, banned, getChess, (id) => data.lookup('items', id), pieceRecord);
|
||||
const countsHand = entry?.countsHand ?? b.countsHand;
|
||||
const next = nextThreshold(count, th);
|
||||
const hasNow = !off && !!(b.effectDescRaw || b.effectDesc);
|
||||
@@ -149,10 +153,11 @@ export function BondPopup({ bondId, entry, priv, banned = [], onClose, onMember,
|
||||
${members.map((mb) => html`<button key=${mb.id} type="button" class=${cx('bpop__member', mb.onBoard && 'is-on', mb.owned && !mb.onBoard && 'is-owned', mb.banned && 'is-banned', mb.granted && 'is-granted')}
|
||||
onClick=${() => onMember?.(mb.id, mb.granted && Array.isArray(mb.items) ? mb.items : null)} data-granted=${mb.granted ? '1' : null}
|
||||
title=${`${mb.name}${mb.granted ? '(变形同构体:视为本盟约成员)' : ''}${mb.banned ? '(本局禁用)' : mb.onBoard ? '(在场)' : mb.owned ? '(整备区)' : ''}`}>
|
||||
<${UnitThumb} kind="chess" id=${mb.id} size="sm" dim=${!mb.owned || mb.banned} />
|
||||
<${UnitThumb} kind="chess" id=${mb.id} size="sm" dim=${!mb.owned || mb.banned} rec=${mb.diy ? mb.rec : null} />
|
||||
<span class="bpop__mname">${mb.name}</span>
|
||||
${mb.banned ? html`<span class="bpop__ban"><${Icon} name="close" /></span>` : null}
|
||||
${mb.granted ? html`<span class="bpop__iso" aria-hidden="true">同构</span>` : null}
|
||||
${mb.diy ? html`<span class="bpop__iso bpop__diy" data-diy="1" aria-hidden="true">${t('自选')}</span>` : null}
|
||||
</button>`)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -33,13 +33,14 @@
|
||||
|
||||
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, fieldsStandIn, standInOf, standInLoadout, standInLabel } from './gameLogic.js';
|
||||
import { attackInterval, rangeGridBox, fmtNum, tileKey, chessLoadout, nextThreshold, bondTier, briefingBondTip, pieceBondIds, grantedBonds, morphPairings, fieldsStandIn, standInOf, standInLoadout, standInLabel, ownDiyRecord, ownDiyPick, diyRecordFor, pickGetter } from './gameLogic.js';
|
||||
import { chessPortraitUrl, skillIconUrl, skillRecordIconUrl, profIconUrl, subProfIconUrl, itemIconUrl, enemyIconUrl, tokenAvatarUrl, factionIconUrl, uiUrl, moduleTypeIconUrl } from './assetUrls.js';
|
||||
import { abilityRows } from './abilityLines.js';
|
||||
import { data } from '../data.js';
|
||||
import { attackRangeGrid } from '../../../shared/loadoutRecord.js';
|
||||
import { SKILL_SUMMON_START_DEPLOY } from '../../../shared/constants.js';
|
||||
import { moduleBadge } from './loadoutModel.js';
|
||||
import { t } from '../../../shared/i18n.js';
|
||||
|
||||
const cx = (...p) => p.flat().filter(Boolean).join(' ');
|
||||
|
||||
@@ -354,10 +355,12 @@ 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, standIn = null }) {
|
||||
export function ChessDetail({ chess, piece, unit, snapHp, editable, onSell, bonds, offBonds = null, loadout, onBond, live = null, hint = null, unitItems = null, standIn = null, diy = null }) {
|
||||
const m = data.get('assets');
|
||||
const hp = hpOf(live, snapHp);
|
||||
const getChess = (id) => data.lookup('chess', id);
|
||||
// 0.2.0 自选编队: `chess` is then the composed 自选 record (the operator, the slot's tier / price); its skill and module are
|
||||
// the pick's — the lookups resolve the slot's ids to the same pick (shared/diy.js), so the loadout reads them as defaults
|
||||
const getChess = diy ? pickGetter((id) => data.lookup('chess', id), diy, { chess: data.get('chess'), backups: data.get('backups') }) : (id) => data.lookup('chess', id);
|
||||
// 0.2.0 补位: a chess fielded as its stand-in keeps its identity (name, tier, bonds, 特质, sell price) and shows the
|
||||
// stand-in's body — portrait, class, 特性, stats, range, skill, talents, module (its backup selection, no loadout)
|
||||
const si = standIn && standIn.standInFor ? standIn : null;
|
||||
@@ -392,6 +395,7 @@ export function ChessDetail({ chess, piece, unit, snapHp, editable, onSell, bond
|
||||
${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}
|
||||
${diy ? html`<span class="dtag-diy" data-diy=${c.charId || ''} title=${t('自选编队:所选技能与模组,没有特质,盟约按所属阵营分配')}>${t('自选')}</span>` : null}
|
||||
</div>
|
||||
<h3 class="dhead__name">${c.name}</h3>
|
||||
<span class="dhead__en">${si ? `${si.name}${si.appellation && si.appellation !== si.name ? ` · ${si.appellation}` : ''}` : c.appellation || ''}</span>
|
||||
@@ -613,11 +617,18 @@ export function TokenDetail({ token, piece, ownerId = null, snapHp = null, live
|
||||
* @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)
|
||||
* card shows as the body); 0.2.0 自选编队: the player's own pieces and cards of a DIY slot it filled (m.private.diy) —
|
||||
* and a unit carrying `diy` — resolve to the composed 自选 record (`chess`, the operator) with `diy` = the pick
|
||||
*/
|
||||
export function resolveDetail(target, pieces, { priv = null, backups = data.get('backups') } = {}) {
|
||||
if (!target) return null;
|
||||
const ownStandIn = (c) => (fieldsStandIn(priv, c) ? standInOf(c, backups) : null);
|
||||
const dd = { chess: data.get('chess'), backups };
|
||||
/** the own card of chess `c`: its 自选 record and pick when the player filled that DIY slot */
|
||||
const ownDiy = (c) => {
|
||||
const rec = c ? ownDiyRecord(c, priv, dd) : null;
|
||||
return rec ? { chess: rec, diy: ownDiyPick(priv, c) } : null;
|
||||
};
|
||||
if (target.kind === 'piece') {
|
||||
const e = pieces?.get(target.uid);
|
||||
if (!e) return null;
|
||||
@@ -625,6 +636,8 @@ export function resolveDetail(target, pieces, { priv = null, backups = data.get(
|
||||
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);
|
||||
const d = ownDiy(c);
|
||||
if (d) return { type: 'chess', chess: d.chess, piece: p, standIn: null, diy: d.diy };
|
||||
return c ? { type: 'chess', chess: c, piece: p, standIn: ownStandIn(c) } : null;
|
||||
}
|
||||
if (target.kind === 'chess') {
|
||||
@@ -634,6 +647,8 @@ export function resolveDetail(target, pieces, { priv = null, backups = data.get(
|
||||
// (a bond popup's member card of a teammate's strip — `owner` another player — and the mode's banned list (`foreign`)
|
||||
// show the chess as it is: the viewer's 补位 list is not theirs)
|
||||
const foreign = !!target.foreign || (target.owner != null && !!priv && target.owner !== priv.playerId);
|
||||
const d = foreign ? null : ownDiy(c);
|
||||
if (d) return { type: 'chess', chess: d.chess, hint: target.hint || null, standIn: null, diy: d.diy, ...(items.length ? { unitItems: items } : {}) };
|
||||
return c ? { type: 'chess', chess: c, hint: target.hint || null, standIn: foreign ? null : ownStandIn(c), ...(items.length ? { unitItems: items } : {}) } : null;
|
||||
}
|
||||
if (target.kind === 'item') { const it = data.lookup('items', target.id); return it ? { type: 'item', item: it } : null; }
|
||||
@@ -651,6 +666,11 @@ export function resolveDetail(target, pieces, { priv = null, backups = data.get(
|
||||
let si = null;
|
||||
if (c && typeof u.standInFor === 'string' && u.standInFor) si = standInOf(c, backups);
|
||||
else if (c && own?.piece) si = ownStandIn(c);
|
||||
// 0.2.0 自选编队: a unit says itself which operator fills its DIY slot (UnitInfo diy); an own piece's unit follows
|
||||
// m.private.diy like the piece
|
||||
const pick = c && u.diy && typeof u.diy === 'object' ? u.diy : own?.piece ? ownDiyPick(priv, c) : null;
|
||||
const dr = pick ? diyRecordFor(c, pick, dd) : null;
|
||||
if (dr) return { type: 'chess', chess: dr, piece: own?.piece || null, unitId: u.id, unitItems: Array.isArray(u.items) ? u.items : null, standIn: null, diy: pick };
|
||||
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) };
|
||||
@@ -695,7 +715,7 @@ export function DetailPanel({ detail, editable, snapHp, onClose, onSell, onDestr
|
||||
<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}
|
||||
standIn=${detail.standIn || null} />` : null}
|
||||
standIn=${detail.standIn || null} diy=${detail.diy || 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}
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
// 自选编队 (0.2.0 DIY; research 0.2.0 §2, the owner's decisions of 2026-10-05) — pure logic of the 自选编队 tab of the
|
||||
// 干员调配 overlay (screens/diy.js), shared with the sync (ui/loadoutSync.js), the match UI (ui/gameLogic/diy.js) and
|
||||
// the tests. Four DIY slots (data/backups.json `diy.slots`: two at tier 5, two at tier 6, sold from 调度中心 level 5 / 6);
|
||||
// each takes an operator: a 6★ the player owns outside the chess pool (`diy.ownedPool`) — any of its three skills and
|
||||
// any module of its elite form but a 集成战略 one (shared/diy.js isDiyModule) — or a prototype (原型干员) with its locked
|
||||
// skill / module; only operators with a kit are offered (`kitted`: the server's `welcome.diyKitted`). A prototype may
|
||||
// fill a tier-5 and a tier-6 slot, an owned operator one slot; the picks of a tier differ (shared/diy.js
|
||||
// validateDiyPicks).
|
||||
//
|
||||
// The per-browser setting is `{ [slotBaseId]: { charId, skillIndex?, uniEquipId? } }`, persisted in localStorage
|
||||
// (`sp.pref.diy` = { v: 1, picks }) and sent with C2S `room.diy { picks }` (the server keeps the legal picks and drops
|
||||
// the rest — shared/protocol.js checkDiyPicks — so a stale pick never gets the roster refused). Out of match: the next
|
||||
// match takes it. 导出 / 导入 use a versioned envelope like the 干员持有 list.
|
||||
|
||||
import { DIY_LIMITS, checkDiyPicks } from '../../../shared/protocol.js';
|
||||
import { diySlotIds, diySlot, diyPool, isPrototypePick, lockedSelection, isDiyModule, diyRecord } from '../../../shared/diy.js';
|
||||
import { unitForm } from '../../../shared/standIn.js';
|
||||
|
||||
/** localStorage key (store.js loadPref/savePref prefix `sp.pref.`) and format version. */
|
||||
export const DIY_PREF = 'diy';
|
||||
export const DIY_VERSION = 1;
|
||||
/** `kind` of an exported 自选编队 envelope. */
|
||||
export const DIY_EXPORT_KIND = 'stronghold.diy';
|
||||
/** A picked file / pasted payload longer than this is refused before parsing (a real payload is under 1 KB). */
|
||||
export const DIY_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);
|
||||
const isId = (v) => typeof v === 'string' && ID_RE.test(v) && !UNSAFE_IDS.has(v);
|
||||
|
||||
/**
|
||||
* The structurally valid picks of a raw map (junk dropped, empty slots left out; at most DIY_LIMITS.slots): each pick
|
||||
* `{ charId, skillIndex?, uniEquipId? }` (skill 0–9, module an id; absent / null = the locked or no module).
|
||||
* @param {any} raw
|
||||
* @returns {Record<string, { charId: string, skillIndex?: number, uniEquipId?: string|null }>}
|
||||
*/
|
||||
export function cleanPicks(raw) {
|
||||
const out = {};
|
||||
if (!isObj(raw)) return out;
|
||||
for (const [slotId, p] of Object.entries(raw)) {
|
||||
if (Object.keys(out).length >= DIY_LIMITS.slots) break;
|
||||
if (!isId(slotId) || !isObj(p) || !isId(p.charId)) continue;
|
||||
const pick = { charId: p.charId };
|
||||
if (Number.isInteger(p.skillIndex) && p.skillIndex >= 0 && p.skillIndex <= 9) pick.skillIndex = p.skillIndex;
|
||||
if (isId(p.uniEquipId)) pick.uniEquipId = p.uniEquipId;
|
||||
else if (p.uniEquipId === null) pick.uniEquipId = null;
|
||||
out[slotId] = pick;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Parse a stored setting (any junk → {}): `{ v, picks }`. @param {any} raw */
|
||||
export function parseStoredDiy(raw) {
|
||||
return isObj(raw) ? cleanPicks(raw.picks) : {};
|
||||
}
|
||||
|
||||
/** Serialised form for localStorage. */
|
||||
export const toStoredDiy = (picks) => ({ v: DIY_VERSION, picks: cleanPicks(picks) });
|
||||
|
||||
/** Number of filled slots. */
|
||||
export const diyCount = (picks) => Object.keys(cleanPicks(picks)).length;
|
||||
|
||||
/**
|
||||
* The legal picks of a stored roster against the loaded data and the kit list (checkDiyPicks: what the server keeps).
|
||||
* @param {any} picks @param {any} data `{ chess, backups }` @param {Iterable<string>|null} kitted
|
||||
* @returns {Record<string, { charId: string, skillIndex: number, uniEquipId: string|null }>}
|
||||
*/
|
||||
export function sanitizeDiyPicks(picks, data, kitted) {
|
||||
const res = checkDiyPicks(cleanPicks(picks), { data, kitted });
|
||||
return res && 'ok' in res ? res.picks : {};
|
||||
}
|
||||
|
||||
/**
|
||||
* The four slots in data order: [{ slotId, goldenId, tier, shopLevel }].
|
||||
* @param {any} data
|
||||
*/
|
||||
export function diySlotList(data) {
|
||||
return diySlotIds(data).map((id) => diySlot(id, data)).filter(Boolean)
|
||||
.map((s) => ({ slotId: s.baseId, goldenId: s.goldenId, tier: s.tier, shopLevel: s.shopLevel }));
|
||||
}
|
||||
|
||||
const backupsOf = (data) => (isObj(data?.backups) ? data.backups : null);
|
||||
|
||||
/**
|
||||
* What a slot's tier offers of an operator: its two forms (normal E2 Lv1 skill rank 4; elite E2 Lv60 rank 7, module
|
||||
* stage 1 at tier 5 / 3 at tier 6), the skills (normal + elite record per index), the modules it may carry (the elite
|
||||
* form's, 集成战略 ones left out) and, for a prototype, its locked selection. Null when the data lacks a form.
|
||||
* @param {string} charId @param {string} slotId a slot base id @param {any} data
|
||||
*/
|
||||
export function pickChoices(charId, slotId, data) {
|
||||
const slot = diySlot(slotId, data);
|
||||
const backups = backupsOf(data);
|
||||
if (!slot || !backups || !slot.normal || !slot.golden) return null;
|
||||
const normal = unitForm(backups, charId, slot.normal.status);
|
||||
const elite = unitForm(backups, charId, slot.golden.status);
|
||||
if (!normal || !elite) return null;
|
||||
const proto = isPrototypePick(data, slot.tier, charId);
|
||||
const skills = (normal.skills || []).filter(Boolean).map((s) => ({ index: s.index, normal: s, elite: (elite.skills || []).find((e) => e && e.index === s.index) || s }));
|
||||
const modules = (elite.modules || []).filter(isDiyModule).map((m) => ({ uniEquipId: m.uniEquipId, rec: m }));
|
||||
return { tier: slot.tier, stage: slot.golden.status?.equipLevel ?? 0, proto, locked: proto ? lockedSelection(data, slot.tier, charId) : null, skills, modules, normal, elite };
|
||||
}
|
||||
|
||||
/**
|
||||
* The pick a newly chosen operator starts with: a prototype's locked selection (`{ charId }` — the server completes it),
|
||||
* an owned operator's third skill (its last) and the first module it may carry (none when it has none).
|
||||
* @param {string} charId @param {string} slotId @param {any} data
|
||||
*/
|
||||
export function defaultPick(charId, slotId, data) {
|
||||
const ch = pickChoices(charId, slotId, data);
|
||||
if (!ch) return { charId };
|
||||
if (ch.proto) return { charId };
|
||||
const last = ch.skills.length ? ch.skills[ch.skills.length - 1].index : 0;
|
||||
return { charId, skillIndex: last, uniEquipId: ch.modules[0]?.uniEquipId ?? null };
|
||||
}
|
||||
|
||||
/**
|
||||
* The operators a slot lists (shared/diy.js diyPool of its tier with the kit list — prototypes first, then the owned
|
||||
* 6★), each with what the picker card shows and whether another slot holds it: `taken` names that slot (an owned
|
||||
* operator fills one slot; the two picks of a tier differ).
|
||||
* @param {string} slotId @param {Record<string, any>} picks the current roster @param {any} data
|
||||
* @param {Iterable<string>|null} kitted
|
||||
* @returns {Array<{ charId: string, unit: any, bonds: string[], proto: boolean, taken: string|null }>}
|
||||
*/
|
||||
export function pickOptions(slotId, picks, data, kitted) {
|
||||
const slot = diySlot(slotId, data);
|
||||
const backups = backupsOf(data);
|
||||
if (!slot || !backups) return [];
|
||||
const kit = kitted ? [...kitted] : [];
|
||||
const tierOf = (id) => diySlot(id, data)?.tier ?? null;
|
||||
const roster = cleanPicks(picks);
|
||||
return diyPool(slot.tier, { data, kitted: kit }).map((charId) => {
|
||||
const proto = isPrototypePick(data, slot.tier, charId);
|
||||
let taken = null;
|
||||
for (const [other, p] of Object.entries(roster)) {
|
||||
if (other === slot.baseId || p.charId !== charId) continue;
|
||||
// the same tier: never twice; another tier: an owned operator only once (a prototype may repeat across tiers)
|
||||
if (tierOf(other) === slot.tier || !isPrototypePick(data, tierOf(other), charId)) { taken = other; break; }
|
||||
}
|
||||
return { charId, unit: backups.units?.[charId] ?? null, bonds: backups.diy?.operators?.[charId]?.bonds ?? [], proto, taken };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The record a slot shows for a pick (shared/diy.js diyRecord: the operator at the slot's normal form, or the elite).
|
||||
* @param {string} slotId @param {any} pick @param {any} data @param {{ elite?: boolean }} [opts]
|
||||
*/
|
||||
export function slotRecord(slotId, pick, data, { elite = false } = {}) {
|
||||
if (!isObj(pick)) return null;
|
||||
try { return diyRecord(slotId, pick, { elite, data }); } catch { return null; }
|
||||
}
|
||||
|
||||
/** Set (or with null, clear) one slot. Returns a new roster (the input is not changed). */
|
||||
export function setPick(picks, slotId, pick) {
|
||||
const next = { ...cleanPicks(picks) };
|
||||
if (isObj(pick) && isId(pick.charId)) next[slotId] = cleanPicks({ [slotId]: pick })[slotId];
|
||||
else delete next[slotId];
|
||||
return next;
|
||||
}
|
||||
|
||||
// ---- export / import -------------------------------------------------------------------------------------------------
|
||||
|
||||
/** Portable payload of a roster, as downloaded / copied by 导出. */
|
||||
export function exportDiy(picks, { now = Date.now() } = {}) {
|
||||
const clean = cleanPicks(picks);
|
||||
return {
|
||||
kind: DIY_EXPORT_KIND,
|
||||
v: DIY_VERSION,
|
||||
exportedAt: new Date(Number.isFinite(now) ? now : Date.now()).toISOString(),
|
||||
count: Object.keys(clean).length,
|
||||
picks: clean,
|
||||
};
|
||||
}
|
||||
|
||||
/** Pretty JSON of `exportDiy`. */
|
||||
export const serializeDiy = (picks, opts) => JSON.stringify(exportDiy(picks, opts), null, 2);
|
||||
|
||||
/**
|
||||
* Parse an imported roster: the envelope, the stored `{ v, picks }` form, or the text of either. STRUCTURAL only — the
|
||||
* caller sanitises against the loaded data (sanitizeDiyPicks). An envelope may hold no picks (= every slot empty).
|
||||
* Errors are msgids (shown through t()).
|
||||
* @param {any} input payload or text
|
||||
* @returns {{ ok: true, picks: Record<string, any> } | { ok: false, error: string, params?: Record<string, any> }}
|
||||
*/
|
||||
export function parseDiyImport(input) {
|
||||
let raw = input;
|
||||
if (typeof raw === 'string') {
|
||||
if (raw.length > DIY_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 (!isObj(raw)) return { ok: false, error: '无法识别的格式' };
|
||||
const v = Number.isInteger(raw.v) ? raw.v : null;
|
||||
if (v != null && v > DIY_VERSION) return { ok: false, error: '这份数据来自更新的版本(v{v}),请先更新游戏', params: { v } };
|
||||
const kind = typeof raw.kind === 'string' ? raw.kind : null;
|
||||
if (kind && kind !== DIY_EXPORT_KIND) return { ok: false, error: '这不是自选编队的数据' };
|
||||
if (!isObj(raw.picks)) return { ok: false, error: '里面没有自选编队的数据' };
|
||||
return { ok: true, picks: cleanPicks(raw.picks) };
|
||||
}
|
||||
@@ -21,7 +21,7 @@ import { render } from '../../vendor/preact.module.js';
|
||||
import { html, TierChip } from './components.js';
|
||||
import { GEO } from '../../../shared/constants.js';
|
||||
import { chessAvatarUrl, itemIconUrl, tokenAvatarUrl, enemyIconUrl } from './assetUrls.js';
|
||||
import { tileKey, hasFlag, UF, penPlacement, PEN, fieldTile, fieldsStandIn, standInOf } from './gameLogic.js';
|
||||
import { tileKey, hasFlag, UF, penPlacement, PEN, fieldTile, fieldsStandIn, standInOf, ownDiyRecord } from './gameLogic.js';
|
||||
|
||||
const DRAG_PX = 6;
|
||||
const DMG_TTL = 900;
|
||||
@@ -126,12 +126,17 @@ export function createFallbackView(host, opts = {}) {
|
||||
const chess = lookup('chess', p.id);
|
||||
// 0.2.0 补位: on the board a chess the player does not own is its stand-in (render/app.js pieceInfo)
|
||||
const si = area === 'board' && fieldsStandIn(st.priv, chess) ? standInOf(chess, dataStore?.get?.('backups') ?? null) : null;
|
||||
return chessAvatarUrl(mm, si || chess);
|
||||
return chessAvatarUrl(mm, si || ownDiy(chess) || chess);
|
||||
}
|
||||
/** 0.2.0 自选编队: the player's own piece of a DIY slot it filled is its operator (gameLogic ownDiyRecord) */
|
||||
function ownDiy(chess) {
|
||||
return chess ? ownDiyRecord(chess, st.priv, { chess: dataStore?.get?.('chess') ?? null, backups: dataStore?.get?.('backups') ?? null }) : null;
|
||||
}
|
||||
function pieceName(p) {
|
||||
if (p.kind === 'item') return lookup('items', p.id)?.name || '道具';
|
||||
if (p.kind === 'token') return lookup('tokens', p.id)?.name || '召唤物';
|
||||
return lookup('chess', p.id)?.name || '干员';
|
||||
const chess = lookup('chess', p.id);
|
||||
return (ownDiy(chess) || chess)?.name || '干员';
|
||||
}
|
||||
|
||||
function Piece({ p, x, y, L, area }) {
|
||||
|
||||
@@ -93,15 +93,16 @@ export const isGoldenPiece = (piece, chess) => !!(piece?.golden || chess?.isGold
|
||||
/**
|
||||
* Square unit thumbnail for a piece / chess / item / token / enemy: art + tier chip + elite frame.
|
||||
* @param {{ kind?: 'chess'|'item'|'token'|'enemy', id: string, golden?: boolean, size?: 'xs'|'sm'|'md'|'lg', tier?: number,
|
||||
* showTier?: boolean, class?: string, dim?: boolean, badge?: any, title?: string }} props
|
||||
* showTier?: boolean, class?: string, dim?: boolean, badge?: any, title?: string, rec?: any }} props
|
||||
* `rec`: the chess record to draw instead of the data's (a 自选 piece's composed record — 0.2.0, gameLogic/diy.js)
|
||||
*/
|
||||
export function UnitThumb({ kind = 'chess', id, golden, size = 'md', tier, showTier = true, class: cls, dim = false, badge = null, title }) {
|
||||
export function UnitThumb({ kind = 'chess', id, golden, size = 'md', tier, showTier = true, class: cls, dim = false, badge = null, title, rec = null }) {
|
||||
const m = data.get('assets');
|
||||
let src = null;
|
||||
let name = '';
|
||||
let t = tier;
|
||||
if (kind === 'chess') {
|
||||
const c = data.lookup('chess', id);
|
||||
const c = rec || data.lookup('chess', id);
|
||||
src = chessAvatarUrl(m, c);
|
||||
name = c?.name || '';
|
||||
t = t ?? c?.tier;
|
||||
|
||||
@@ -42,4 +42,5 @@ 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 { diyPicks, ownDiyPick, diyRecordFor, ownDiyRecord, cardDiy, diyGetter, pickGetter, isDiyRecord } from './gameLogic/diy.js';
|
||||
export { panelSide, panelSlots, PANEL_RIGHT_GAP, PANEL_RIGHT_BOTTOM, PANEL_RIGHT_BOTTOM_SHOP, BPOP, bondPopupPlace } from './gameLogic/panel.js';
|
||||
@@ -155,14 +155,18 @@ export function harmonyMembers(priv, getChess = () => null) {
|
||||
* operators that are members through 变形同构体 (grantedBonds; `granted: true` and `items`: the item ids of the copy that
|
||||
* wears the pair — the card the row opens shows them; one row per operator — normal and elite copies are one member,
|
||||
* like the count's distinct members), so "成员 x/y" agrees with the count the server reports (在场; memberHeadCount).
|
||||
* 0.2.0 自选编队: the 自选 pieces of the bond (a DIY slot filled with an operator, whose bonds come from its factions — the
|
||||
* own pieces through `getChess` (gameLogic/diy.js diyGetter), a teammate's through `pieceRecord` (its UnitInfo `diy`
|
||||
* pick) are rows too (`diy: true`, the operator's name and record `rec`).
|
||||
* `inHand`: in the 整备区 (hand), not the 5 temporary slots — what BOARD_AND_DECK bonds (投资人 远见 奇迹) count.
|
||||
* @param {any} bond bonds.json record
|
||||
* @param {any} priv m.private (hand/board/temp) — or a teammate's field operators (ui/watchBonds.js ownerBoard)
|
||||
* @param {Set<string>|string[]} [banned] banned base chess ids
|
||||
* @param {(id:string)=>any} [getChess]
|
||||
* @param {(id:string)=>any} [getItem] items.json lookup (the 变形同构体 pairings)
|
||||
* @param {((p: any) => any)|null} [pieceRecord] the record of one of the pieces (default: getChess(p.id))
|
||||
*/
|
||||
export function bondMembers(bond, priv, banned = [], getChess = () => null, getItem = () => null) {
|
||||
export function bondMembers(bond, priv, banned = [], getChess = () => null, getItem = () => null, pieceRecord = null) {
|
||||
const bannedSet = banned instanceof Set ? banned : new Set(Array.isArray(banned) ? banned : []);
|
||||
const members = Array.isArray(bond?.visibleMembers) && bond.visibleMembers.length ? bond.visibleMembers : (Array.isArray(bond?.members) ? bond.members : []);
|
||||
const baseOf = (id) => getChess(id)?.baseId || (typeof id === 'string' ? id.replace(/_b$/, '_a') : id);
|
||||
@@ -173,11 +177,22 @@ export function bondMembers(bond, priv, banned = [], getChess = () => null, getI
|
||||
/** base id → { on: on the board?, hand: in the hand?, items: the wearer's item ids } — operators of the player that join this bond through 变形同构体 */
|
||||
const granted = new Map();
|
||||
const grants = (p) => typeof bond?.bondId === 'string' && grantedBonds(p.items, getItem).includes(bond.bondId);
|
||||
/** base id → { on, hand, rec } — the player's 自选 pieces whose operator carries this bond */
|
||||
const diy = new Map();
|
||||
const diyOf = (p, on, hand) => {
|
||||
const rec = pieceRecord ? pieceRecord(p) : getChess(p.id);
|
||||
if (!rec || typeof rec.diyFor !== 'string' || !Array.isArray(rec.bonds) || !rec.bonds.includes(bond?.bondId)) return false;
|
||||
const g = diy.get(rec.diyFor);
|
||||
if (!g) diy.set(rec.diyFor, { on, hand, rec });
|
||||
else { g.on = g.on || on; g.hand = g.hand || hand; }
|
||||
return true;
|
||||
};
|
||||
const itemIds = (p) => p.items.map((it) => (typeof it === 'string' ? it : it?.id)).filter((x) => typeof x === 'string');
|
||||
for (const p of Array.isArray(priv?.board) ? priv.board : []) {
|
||||
if (p?.kind !== 'chess') continue;
|
||||
const base = baseOf(p.id);
|
||||
onBoard.add(base); owned.add(base);
|
||||
if (diyOf(p, true, false)) continue;
|
||||
if (!memberSet.has(base) && !granted.has(base) && grants(p)) granted.set(base, { on: true, hand: false, items: itemIds(p) });
|
||||
}
|
||||
for (const [list, hand] of [[priv?.hand, true], [priv?.temp, false]]) {
|
||||
@@ -186,6 +201,7 @@ export function bondMembers(bond, priv, banned = [], getChess = () => null, getI
|
||||
const base = baseOf(p.id);
|
||||
owned.add(base);
|
||||
if (hand) inHand.add(base);
|
||||
if (diyOf(p, false, hand)) continue;
|
||||
if (memberSet.has(base) || !grants(p)) continue;
|
||||
const g = granted.get(base);
|
||||
if (!g) granted.set(base, { on: false, hand, items: itemIds(p) });
|
||||
@@ -200,6 +216,7 @@ export function bondMembers(bond, priv, banned = [], getChess = () => null, getI
|
||||
const c = getChess(id);
|
||||
rows.push({ id, tier: c?.tier ?? 0, name: c?.name ?? id, onBoard: g.on, owned: true, inHand: g.hand, banned: false, granted: true, items: g.items });
|
||||
}
|
||||
for (const [id, g] of diy) rows.push({ id, tier: g.rec.tier ?? 0, name: g.rec.name ?? id, onBoard: g.on, owned: true, inHand: g.hand, banned: false, diy: true, rec: g.rec });
|
||||
return rows.sort((a, b) => (b.onBoard - a.onBoard) || (b.owned - a.owned) || (a.tier - b.tier) || (a.id < b.id ? -1 : 1));
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
// ui/gameLogic/diy.js — 自选编队 pieces in the match UI (0.2.0 DIY; the owner's decisions of 2026-10-05). Re-exported
|
||||
// from ../gameLogic.js.
|
||||
//
|
||||
// A DIY slot (chess_char_5_diy1_a …) has no body of its own (甄选干员): each player's piece of it is the operator that
|
||||
// player slotted — its name, art, class, bonds (from its factions), the pick's skill and module, no 特质; the slot's tier,
|
||||
// price and merge. Whose pick: the player's own pieces and cards follow m.private.diy (the picks its 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 `diy`: the sim's, Match.prepFieldMeta's for scouting, m.result lineups). The
|
||||
// composed record is shared/diy.js diyRecordOf over chess.json + backups.json — the one the server and the sim use.
|
||||
// `data` below = `{ chess, backups }` (the client's data.get('chess') / data.get('backups'), localized copies).
|
||||
|
||||
import { diyRecordOf } from '../../../../shared/diy.js';
|
||||
import { isObj } from './shared.js';
|
||||
|
||||
/** The player's 自选 picks in this match (m.private.diy; {} when absent). */
|
||||
export function diyPicks(priv) {
|
||||
return isObj(priv) && isObj(priv.diy) ? priv.diy : {};
|
||||
}
|
||||
|
||||
/**
|
||||
* The pick the player's own piece / card of chess record `chess` fields: its slot's in m.private.diy, or null (not a DIY
|
||||
* slot, or one the player did not fill). A composed record (`diyFor`) names its slot itself.
|
||||
* @param {any} priv @param {any} chess
|
||||
*/
|
||||
export function ownDiyPick(priv, chess) {
|
||||
if (!isObj(chess) || !chess.isDiy) return null;
|
||||
const slot = typeof chess.diyFor === 'string' ? chess.diyFor : chess.baseId || chess.chessId;
|
||||
const p = diyPicks(priv)[slot];
|
||||
return isObj(p) && typeof p.charId === 'string' ? p : null;
|
||||
}
|
||||
|
||||
/** Per backups object: `chessId|charId|skill|module` → composed record (or null). */
|
||||
const CACHE = new WeakMap();
|
||||
|
||||
/**
|
||||
* The 自选 record of DIY slot record `chess` (normal or elite) filled with `pick` (shared/diy.js diyRecordOf), cached
|
||||
* per data; null for an illegal pick or missing data.
|
||||
* @param {any} chess the slot's own chess.json record @param {any} pick `{ charId, skillIndex, uniEquipId }`
|
||||
* @param {{ chess?: any, backups?: any }} data
|
||||
*/
|
||||
export function diyRecordFor(chess, pick, data) {
|
||||
if (!isObj(chess) || !chess.isDiy) return null;
|
||||
if (chess.diyFor) return chess; // already composed
|
||||
if (!isObj(pick) || !isObj(data?.backups)) return null;
|
||||
let m = CACHE.get(data.backups);
|
||||
if (!m) { m = new Map(); CACHE.set(data.backups, m); }
|
||||
const key = `${chess.chessId}|${pick.charId}|${pick.skillIndex ?? ''}|${pick.uniEquipId ?? ''}`;
|
||||
if (m.has(key)) return m.get(key);
|
||||
let rec;
|
||||
try { rec = diyRecordOf(chess, pick, data); } catch { rec = null; }
|
||||
m.set(key, rec);
|
||||
return rec;
|
||||
}
|
||||
|
||||
/** The record the player's own piece / card of `chess` shows: its 自选 record (own pick), or null. */
|
||||
export function ownDiyRecord(chess, priv, data) {
|
||||
const pick = ownDiyPick(priv, chess);
|
||||
return pick ? diyRecordFor(chess, pick, data) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The 自选 record a card of `chess` shows for this viewer, or null: a unit that carries `diy` (another player's — the
|
||||
* sim's UnitInfo, prep scouting, a result lineup entry), else the player's own piece / card (m.private.diy).
|
||||
* @param {any} chess @param {{ priv?: any, unit?: any, data?: any }} o
|
||||
*/
|
||||
export function cardDiy(chess, { priv = null, unit = null, data = null } = {}) {
|
||||
if (!isObj(chess) || !chess.isDiy) return null;
|
||||
if (unit) return isObj(unit.diy) ? diyRecordFor(chess, unit.diy, data) : null;
|
||||
return ownDiyRecord(chess, priv, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* id → the player's 自选 record of a filled DIY slot (both forms), else the chess record: the lookup the player's own
|
||||
* pieces and cards resolve against (names, art, position, the loadout / range of a 自选 piece — shared/protocol.js
|
||||
* resolveLoadout then reads the pick's skill and module as the record's defaults).
|
||||
* @param {(id: string) => any} getChess @param {any} priv m.private (or a getter of the current one) @param {any} data
|
||||
*/
|
||||
export function diyGetter(getChess, priv, data) {
|
||||
return (id) => {
|
||||
const c = getChess(id);
|
||||
return ownDiyRecord(c, typeof priv === 'function' ? priv() : priv, typeof data === 'function' ? data() : data) || c;
|
||||
};
|
||||
}
|
||||
|
||||
/** id → the 自选 record of a slot filled with `pick` (another player's unit), else the chess record. */
|
||||
export function pickGetter(getChess, pick, data) {
|
||||
return (id) => {
|
||||
const c = getChess(id);
|
||||
return (isObj(pick) && c && c.isDiy ? diyRecordFor(c, pick, data) : null) || c;
|
||||
};
|
||||
}
|
||||
|
||||
/** Whether a record is a composed 自选 record. */
|
||||
export const isDiyRecord = (rec) => isObj(rec) && typeof rec.diyFor === 'string';
|
||||
+67
-13
@@ -1,23 +1,28 @@
|
||||
// Operator loadout + ownership state and server sync (DESIGN §16; 干员持有 — 0.2.0 补位).
|
||||
// Operator loadout + ownership + 自选编队 state and server sync (DESIGN §16; 干员持有 — 0.2.0 补位; 自选编队 — 0.2.0 DIY).
|
||||
//
|
||||
// `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
|
||||
// per-browser not-owned list of the 干员持有 tab (`notOwned`, likewise), the 自选编队 picks (`diy`, likewise; with
|
||||
// `diyKitted` = the operators the server lets a DIY slot field, from every `welcome`) and the overlay's screen state
|
||||
// (open / origin / tab / selection / filters). `installLoadoutSync()` / `installOwnershipSync()` / `installDiySync()`
|
||||
// (called once by main.js) keep the server's copies current — one sync engine (installPrefSync), three 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.
|
||||
// the whole loadout), `room.ownership { notOwned }` (sent as stored: the server keeps the droppable chess and drops
|
||||
// the rest, so no data is needed) and `room.diy { picks }` (sent as stored, structurally clean: the server keeps the
|
||||
// legal picks). Replies: RATE → retried later; WRONG_PHASE / ROOM_STARTED → the running match keeps what it took (the
|
||||
// loadout locks when INFO_CHECK ends; the ownership and the 自选 picks never change during a match) — stored for the
|
||||
// next match, not an error for the player; anything else is logged. `sync` / `ownSync` / `diySync` ∈ '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 { DIY_PREF, parseStoredDiy, toStoredDiy, cleanPicks, sanitizeDiyPicks } from './diyModel.js';
|
||||
import { toast } from './toasts.js';
|
||||
import { t } from '../../../shared/i18n.js';
|
||||
|
||||
export const SYNC_DEBOUNCE_MS = 500;
|
||||
export const RETRY_MS = 1500;
|
||||
@@ -28,18 +33,24 @@ function readStored() {
|
||||
function readStoredOwnership() {
|
||||
try { return parseStoredOwnership(loadPref(OWNERSHIP_PREF, null)); } catch { return []; }
|
||||
}
|
||||
function readStoredDiy() {
|
||||
try { return parseStoredDiy(loadPref(DIY_PREF, null)); } catch { return {}; }
|
||||
}
|
||||
|
||||
/** 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)
|
||||
diy: readStoredDiy(), // 自选编队: { [slotBaseId]: { charId, skillIndex?, uniEquipId? } } ({} = every slot empty)
|
||||
diyKitted: null, // the operators a DIY slot may field (welcome.diyKitted; null before the first welcome)
|
||||
open: false,
|
||||
from: null, // 'lobby' | 'room' | 'briefing'
|
||||
tab: 'loadout', // 'loadout' (干员调配) | 'ownership' (干员持有)
|
||||
tab: 'loadout', // 'loadout' (干员调配) | 'ownership' (干员持有) | 'diy' (自选编队)
|
||||
sel: null, // selected base chess id
|
||||
filters: { tier: null, prof: null, bond: null, query: '', changedOnly: false },
|
||||
sync: 'idle',
|
||||
ownSync: 'idle',
|
||||
diySync: 'idle',
|
||||
});
|
||||
|
||||
/** Replace the stored entries (persisted at once; the sync picks the change up). */
|
||||
@@ -86,9 +97,30 @@ export function applyOwnershipImport(list, lookup) {
|
||||
return { applied: clean.length, dropped: Math.max(0, asked - clean.length) };
|
||||
}
|
||||
|
||||
/** Replace the stored 自选编队 picks (persisted at once, the sync picks the change up). */
|
||||
export function setDiyPicks(picks) {
|
||||
const next = cleanPicks(picks);
|
||||
try { savePref(DIY_PREF, toStoredDiy(next)); } catch { /* private mode: the session keeps it */ }
|
||||
loadoutStore.set({ diy: next });
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply an imported roster (parseDiyImport(...).picks), sanitised against the loaded data and the kit list (the picks
|
||||
* the server would keep), persisted and synced like an edit. An empty roster is a valid import (= every slot empty).
|
||||
* @param {Record<string, any>} picks @param {any} data `{ chess, backups }` @param {Iterable<string>|null} kitted
|
||||
* @returns {{ applied: number, dropped: number }}
|
||||
*/
|
||||
export function applyDiyImport(picks, data, kitted) {
|
||||
const asked = Object.keys(cleanPicks(picks)).length;
|
||||
const clean = sanitizeDiyPicks(picks, data, kitted);
|
||||
setDiyPicks(clean);
|
||||
const applied = Object.keys(clean).length;
|
||||
return { applied, dropped: Math.max(0, asked - applied) };
|
||||
}
|
||||
|
||||
/**
|
||||
* 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)
|
||||
* @param {'loadout'|'ownership'|'diy'|null} [tab] the tab to show (default: the last one)
|
||||
*/
|
||||
export function openLoadout(from = 'lobby', sel = null, tab = null) {
|
||||
data.load('chess');
|
||||
@@ -96,7 +128,7 @@ export function openLoadout(from = 'lobby', sel = null, tab = null) {
|
||||
data.load('assets');
|
||||
data.load('local');
|
||||
data.load('backups');
|
||||
loadoutStore.set({ open: true, from, ...(sel ? { sel } : {}), ...(tab === 'loadout' || tab === 'ownership' ? { tab } : {}) });
|
||||
loadoutStore.set({ open: true, from, ...(sel ? { sel } : {}), ...(tab === 'loadout' || tab === 'ownership' || tab === 'diy' ? { tab } : {}) });
|
||||
}
|
||||
export const closeLoadout = () => loadoutStore.set({ open: false });
|
||||
|
||||
@@ -107,7 +139,8 @@ export const closeLoadout = () => loadoutStore.set({ open: false });
|
||||
*/
|
||||
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 tell = notify || ((text) => toast(text, 'warn'));
|
||||
// (`lockedText` is a msgid: the toast is translated when it shows — docs/I18N.md)
|
||||
const tell = notify || ((text) => toast(t(text), 'warn'));
|
||||
let timer = null;
|
||||
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
|
||||
@@ -228,3 +261,24 @@ export function installOwnershipSync({ net, timers, target = loadoutStore, notif
|
||||
prepare: async () => cleanIds(target.get().notOwned),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Wire the 自选编队 sync once (0.2.0 DIY): `room.diy { picks }` after every welcome and edit. The picks go as stored
|
||||
* (structurally clean); the server keeps the legal ones. During a match the server stores them for the next one
|
||||
* (ROOM_STARTED → 'locked'): the setting is out of match. Every `welcome` also brings the operators a DIY slot may field
|
||||
* (`diyKitted`, the picker's list).
|
||||
* @param {{ net: any, timers?: { setTimeout: Function, clearTimeout: Function }, target?: ReturnType<typeof createStore>,
|
||||
* notify?: (text: string) => void }} deps
|
||||
*/
|
||||
export function installDiySync({ net, timers, target = loadoutStore, notify } = {}) {
|
||||
const offKit = net.on('welcome', (msg) => {
|
||||
const list = msg && Array.isArray(msg.diyKitted) ? msg.diyKitted.filter((x) => typeof x === 'string') : null;
|
||||
target.set({ diyKitted: list });
|
||||
});
|
||||
const sync = installPrefSync({
|
||||
net, timers, target, notify, key: 'diy', stateKey: 'diySync', msgType: 'room.diy', field: 'picks', tag: 'diy',
|
||||
lockedText: '自选编队是局外设置,修改将在下一局生效',
|
||||
prepare: async () => cleanPicks(target.get().diy),
|
||||
});
|
||||
return { flush: sync.flush, dispose() { offKit?.(); sync.dispose(); } };
|
||||
}
|
||||
+12
-4
@@ -19,14 +19,17 @@
|
||||
// §16): the skill icon above the name, mint-framed with 已调配 in its title when it is not the default skill (and the
|
||||
// module type of an elite card, with its official type icon when the local-client art has it). A chess the player does
|
||||
// not own (0.2.0 补位, m.private.standIns) keeps its card — name, portrait, bonds, price — with an ice 「替补:X」 badge,
|
||||
// and the skill icon is the stand-in's (its backup skill: the one that fights).
|
||||
// and the skill icon is the stand-in's (its backup skill: the one that fights). A 自选 piece (0.2.0, m.private.diy — a DIY
|
||||
// slot the player filled, only ever in its own shop) shows the operator — name, portrait, class, the bonds of its
|
||||
// factions, the pick's skill and module — with a mint 「自选」 badge.
|
||||
|
||||
import { useEffect, useState } from '../../vendor/hooks.module.js';
|
||||
import { html, Icon, HexBadge, TierChip, Tooltip, MicroLabel } from './components.js';
|
||||
import { Img, BondGlyph, CoinGlyph, GIcon, RichText } from './gameComponents.js';
|
||||
import { priceTone, mergeProgress, mergeTarget, shopBlockReason, chessLoadout, offerHeader, briefingBondTip, fieldsStandIn, standInOf, standInLoadout, standInLabel } from './gameLogic.js';
|
||||
import { priceTone, mergeProgress, mergeTarget, shopBlockReason, chessLoadout, offerHeader, briefingBondTip, fieldsStandIn, standInOf, standInLoadout, standInLabel, ownDiyRecord, diyGetter } from './gameLogic.js';
|
||||
import { chessPortraitUrl, itemIconUrl, profIconUrl, uiUrl, skillIconUrl, skillRecordIconUrl, moduleTypeIconUrl } from './assetUrls.js';
|
||||
import { data } from '../data.js';
|
||||
import { t } from '../../../shared/i18n.js';
|
||||
|
||||
const cx = (...p) => p.flat().filter(Boolean).join(' ');
|
||||
|
||||
@@ -64,7 +67,11 @@ function ArmedTag({ reason, free }) {
|
||||
* onTap?:(idx:number)=>void, onBuy:Function, onDetail:Function }} props
|
||||
*/
|
||||
export function ChessCard({ slot, idx, priv, frozen = false, reason = null, free = false, armed = false, onTap = null, onBuy, onDetail, offBonds = null }) {
|
||||
const c = data.lookup('chess', slot.id);
|
||||
const c0 = data.lookup('chess', slot.id);
|
||||
// 0.2.0 自选编队: a DIY slot the player filled is its operator (shared/diy.js, the pick of m.private.diy)
|
||||
const diyData = { chess: data.get('chess'), backups: data.get('backups') };
|
||||
const dr = c0 ? ownDiyRecord(c0, priv, diyData) : null;
|
||||
const c = dr || c0;
|
||||
const m = data.get('assets');
|
||||
const tier = c?.tier ?? 1;
|
||||
const prog = mergeProgress(priv, slot.id, (id) => data.lookup('chess', id));
|
||||
@@ -74,7 +81,7 @@ export function ChessCard({ slot, idx, priv, frozen = false, reason = null, free
|
||||
const disabled = !!reason;
|
||||
// 0.2.0 补位: the player's not-owned chess is deployed as its stand-in — badge + the stand-in's skill
|
||||
const si = c && fieldsStandIn(priv, c) ? standInOf(c, data.get('backups')) : null;
|
||||
const lo = si ? standInLoadout(si, LOOKUPS.getChess, data.get('backups')) : c ? chessLoadout(c, priv?.loadout, LOOKUPS.getChess) : null;
|
||||
const lo = si ? standInLoadout(si, LOOKUPS.getChess, data.get('backups')) : c ? chessLoadout(c, priv?.loadout, dr ? diyGetter(LOOKUPS.getChess, priv, diyData) : LOOKUPS.getChess) : null;
|
||||
const tap = () => { if (onTap) onTap(idx); else if (!disabled) onBuy(idx); else onDetail(slot.id, 'chess', hint); };
|
||||
const card = html`<button type="button" class=${cx('scard', `scard--t${tier}`, frozen && 'is-frozen', disabled && 'is-disabled', willMerge && 'is-merge', armed && 'is-armed')}
|
||||
onClick=${tap} onContextMenu=${(e) => { e.preventDefault(); onDetail(slot.id, 'chess', hint); }}
|
||||
@@ -90,6 +97,7 @@ export function ChessCard({ slot, idx, priv, frozen = false, reason = null, free
|
||||
</span>` : null}
|
||||
</span>
|
||||
${si ? html`<span class="scard__standin" data-standin=${si.charId} title=${`未持有:由替补干员 ${si.name} 上场(盟约、阶级与价格不变)`}>${standInLabel(si)}</span>` : null}
|
||||
${dr ? html`<span class="scard__diy" data-diy=${dr.charId} title=${t('自选编队:{name}(只在你的商店出现)', { name: dr.name })}>${t('自选')}</span>` : null}
|
||||
<span class="scard__body">
|
||||
${lo?.skill ? html`<${SkillBadge} chess=${si || c} lo=${lo} standIn=${!!si} />` : null}
|
||||
<span class="scard__name">${c?.name || slot.id}</span>
|
||||
|
||||
@@ -162,7 +162,12 @@ export function ownerBoard(field, ownerId, extra = null) {
|
||||
if (!fromField && !fromBattle) return null;
|
||||
const board = [...(fromField || []), ...(fromBattle || [])]
|
||||
.filter((u) => isObj(u) && u.ownerId === ownerId && u.kind === 'op' && typeof u.defId === 'string')
|
||||
.map((u) => (Array.isArray(u.items) && u.items.length ? { kind: 'chess', id: u.defId, items: u.items.filter((x) => typeof x === 'string') } : { kind: 'chess', id: u.defId }));
|
||||
.map((u) => {
|
||||
const p = Array.isArray(u.items) && u.items.length ? { kind: 'chess', id: u.defId, items: u.items.filter((x) => typeof x === 'string') } : { kind: 'chess', id: u.defId };
|
||||
// 0.2.0 自选编队: a DIY slot's unit names its operator (UnitInfo diy) — the popup counts it for that operator's bonds
|
||||
if (isObj(u.diy) && typeof u.diy.charId === 'string') p.diy = u.diy;
|
||||
return p;
|
||||
});
|
||||
return { board, hand: [], temp: [] };
|
||||
}
|
||||
|
||||
|
||||
@@ -126,13 +126,15 @@ export class PlayerDiy {
|
||||
* Fix the 自选 picks for the match (see the header): the seat's picks re-checked against this match's data and kits;
|
||||
* a slot whose record cannot be composed is left empty. With picks, `this.gd` becomes the player's data view.
|
||||
* @param {any} picks `{ [slotBaseId]: { charId, skillIndex?, uniEquipId? } | null }`
|
||||
* @param {{ kitted?: Iterable<string> }} [opts] the operators with a kit (default: the registry's KITTED_CHARS; tests may
|
||||
* widen it to slot an operator whose kit is still being written)
|
||||
* @returns {boolean} false when the picks are malformed (nothing changes) or the player is a bot
|
||||
*/
|
||||
setDiy(picks) {
|
||||
setDiy(picks, { kitted = KITTED_CHARS } = {}) {
|
||||
if (this.isBot) return false;
|
||||
const gd = this.m.gd;
|
||||
const data = gd.raw;
|
||||
const res = checkDiyPicks(picks, { data, kitted: KITTED_CHARS });
|
||||
const res = checkDiyPicks(picks, { data, kitted });
|
||||
if (!res || !('ok' in res)) {
|
||||
this.m.log?.warn?.(`[match ${this.m.roomCode}] 自选 picks of ${this.playerId} ignored: ${res && res.detail}`);
|
||||
return false;
|
||||
|
||||
@@ -56,6 +56,7 @@ export async function startRealServer(opts = {}) {
|
||||
if (opts.fast.autoPlace) env.SP_AUTO_PLACE = '1';
|
||||
if (opts.fast.eliminate?.length) env.SP_ELIMINATE = opts.fast.eliminate.join(',');
|
||||
if (opts.fast.stage) env.SP_STAGE = String(opts.fast.stage);
|
||||
if (opts.fast.level) env.SP_START_LEVEL = String(opts.fast.level);
|
||||
}
|
||||
const child = spawn(process.execPath, [entry], { cwd: ROOT, env, stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
const logs = [];
|
||||
|
||||
@@ -22,6 +22,8 @@
|
||||
// eliminated spectator from the first round on (test/ui/watch-bonds.e2e.test.js, the Final Assault).
|
||||
// SP_STAGE=<stageId>: every match is played on this stage instead of the drawn one (test/ui/feedback1-placement.e2e.test.js:
|
||||
// 战场#08's pool).
|
||||
// SP_START_LEVEL=<1..6>: the starter kit also puts every human's 调度中心 at this level (and rerolls its shop there, before
|
||||
// SP_START_SHOP stocks its first slots) — test/ui/diy.e2e.test.js: a tier-5 自选 piece is sold from level 5.
|
||||
// Not a test file (node --test runs it as a no-op module when NODE_TEST_CONTEXT is set).
|
||||
|
||||
import { startServer } from '../../server/index.js';
|
||||
@@ -46,6 +48,7 @@ if (!process.env.NODE_TEST_CONTEXT) {
|
||||
const autoPlace = process.env.SP_AUTO_PLACE === '1';
|
||||
const eliminate = String(process.env.SP_ELIMINATE || '').split(',').map((x) => x.trim()).filter(Boolean).map(Number);
|
||||
const forcedStage = String(process.env.SP_STAGE || '').trim();
|
||||
const startLevel = Math.max(0, Math.min(6, Number(process.env.SP_START_LEVEL) || 0));
|
||||
|
||||
class FastMatch extends Match {
|
||||
constructor(opts) {
|
||||
@@ -152,6 +155,11 @@ if (!process.env.NODE_TEST_CONTEXT) {
|
||||
if (slot < 0 || !this.gd.item(itemId)) continue;
|
||||
try { ps.hand[slot] = ps.newPiece('item', itemId); } catch { /* best effort */ }
|
||||
}
|
||||
if (startLevel > 0) {
|
||||
ps.shop.level = Math.min(startLevel, this.gd.maxShopLevel);
|
||||
ps.shop.upgradePrice = this.gd.upgradeBase(ps.shop.level) ?? 0;
|
||||
ps.rollShop({ keepFrozen: false });
|
||||
}
|
||||
kitShop.forEach((id, i) => {
|
||||
if (i < ps.shop.slots.length && this.gd.chess(id)) ps.shop.slots[i] = { kind: 'chess', id, basePrice: this.gd.chessPrice(id), frozen: false, sold: false };
|
||||
});
|
||||
|
||||
@@ -117,6 +117,7 @@ test('public/i18n/en.json: valid, English values, placeholders kept, the pilot s
|
||||
|
||||
test('every msgid of the pilot screens, the shell and the server messages is in public/i18n/en.json', async () => {
|
||||
const files = ['public/js/screens/title.js', 'public/js/screens/lobby.js', 'public/js/screens/room.js', 'public/js/ui/settings.js',
|
||||
'public/js/screens/diy.js',
|
||||
'public/js/main.js', 'public/js/ui/toasts.js', 'server/match/match/messaging.js', 'server/match/player/placement.js',
|
||||
'server/match/player/basics.js', 'server/match/player/acquire.js', 'server/match/effectsMeta.js', 'server/match/match/settle.js',
|
||||
'server/match/match/platform.js', 'server/match/match/unitePhase.js', 'server/match/match/bossRounds.js',
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
// test/match/diy-bonds.test.js — 0.2.0 自选编队: a 自选 piece is a member of each of its derived bonds (data/backups.json
|
||||
// diy.operators[charId].bonds — its factions, else 协防; "甄选加入的干员会根据其实际阵营所属分配核心盟约…一个或数个", PRTS 卫戍协议:
|
||||
// 盟约 下半/PRTS盟约记录) in every place membership is decided: the match's bond counts (bondsMeta through the player's
|
||||
// data view — BOARD / BOARD_AND_DECK distinct pieces: the normal and elite copies of one slot count once; 煌 counts for
|
||||
// 炎 and 维多利亚; 绝技 counts a 自选 elite like any elite), the views (m.private / m.public bonds), the battle input's
|
||||
// bond snapshot, and the sim's bond effects (they read the unit's def.bonds: 叙拉古 on deployment, 协防 ×1.2 / elite ×1.4).
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { KITTED_CHARS } from '../../server/sim/content/kits/index.js';
|
||||
import { hasGeneratedData } from '../../server/sim/simdata.js';
|
||||
import { makeBattle, checkInvariants as checkBattle } from '../helpers/battleHarness.js';
|
||||
import { DATA, makeMatch, legalTileFor } from './harness.js';
|
||||
|
||||
const REAL = { skip: !hasGeneratedData() };
|
||||
const T5A = 'chess_char_5_diy1_a';
|
||||
const T5B = 'chess_char_5_diy2_a';
|
||||
const T6A = 'chess_char_6_diy1_a';
|
||||
const HUANG = 'char_017_huang'; // 煌: 炎 + 维多利亚 (both from her 隐藏势力)
|
||||
const CROSLY = 'char_1502_crosly'; // 弑君者: 叙拉古
|
||||
const SHARP = 'char_609_acguad'; // a prototype: 协防
|
||||
const OPS = DATA.backups?.diy?.operators ?? {};
|
||||
|
||||
function soloMatch() {
|
||||
const seats = [{ seat: 0, playerId: 'p_0', name: 'P0', isBot: false, connected: true }];
|
||||
const h = makeMatch({ mode: 'solo', seats, seed: 21 }).start();
|
||||
h.toPrep(1);
|
||||
return h;
|
||||
}
|
||||
/** Slot `picks` for p_0 now (kit list widened for operators whose kit is still being written) and give it stock. */
|
||||
function slot(h, picks) {
|
||||
const p0 = h.ps('p_0');
|
||||
assert.equal(p0.setDiy(picks, { kitted: [...KITTED_CHARS, HUANG, CROSLY] }), true);
|
||||
p0.initDiyStock(new Set());
|
||||
p0.recompute();
|
||||
return p0;
|
||||
}
|
||||
function deploy(h, ps, id) {
|
||||
const p = ps.acquireChess(id, { source: 'buy' });
|
||||
const t = legalTileFor(h.m, ps, p.id);
|
||||
assert.ok(t, `a tile for ${id}`);
|
||||
assert.deepEqual(h.m.handle(ps.playerId, { t: 'g.move', uid: p.uid, to: { area: 'board', row: t[0], col: t[1] } }), { ok: true });
|
||||
return p;
|
||||
}
|
||||
|
||||
test('the derived bonds of the data: 协防 39 owned + every prototype, 炎 14, 维多利亚 8, 煌 two', REAL, () => {
|
||||
const owned = DATA.backups.diy.ownedPool;
|
||||
// (research 0.2.0 §2.4: 协防 39, 炎 14, 维多利亚 8, 萨尔贡 4, 叙拉古 4, 拉特兰 1, 卡西米尔 1, 炎 + 维多利亚 1 — 煌)
|
||||
const only = (b) => owned.filter((id) => OPS[id].bonds.length === 1 && OPS[id].bonds[0] === b).length;
|
||||
assert.deepEqual([only('emptyShip'), only('yanShip'), only('victoriaShip'), only('sargonShip'), only('siracusaShip'), only('lateranoShip'), only('kazimierzShip')], [39, 14, 8, 4, 4, 1, 1]);
|
||||
assert.deepEqual(owned.filter((id) => OPS[id].bonds.length > 1), [HUANG]);
|
||||
assert.deepEqual(OPS[HUANG].bonds, ['yanShip', 'victoriaShip']);
|
||||
assert.deepEqual(OPS.char_300_phenxi.bonds, ['lateranoShip']);
|
||||
assert.deepEqual(OPS.char_4098_vvana.bonds, ['kazimierzShip']);
|
||||
for (const id of [...DATA.backups.diy.prototypes[5], ...DATA.backups.diy.prototypes[6]]) assert.deepEqual(OPS[id].bonds, ['emptyShip'], id);
|
||||
});
|
||||
|
||||
test('煌 counts for 炎 and 维多利亚; its normal and elite copies count once; views and the battle input agree', REAL, () => {
|
||||
const h = soloMatch();
|
||||
const p0 = slot(h, { [T5A]: { charId: HUANG, skillIndex: 0 } });
|
||||
const before = { yan: p0.bonds.yanShip?.count ?? 0, vic: p0.bonds.victoriaShip?.count ?? 0 };
|
||||
deploy(h, p0, T5A);
|
||||
assert.equal(p0.bonds.yanShip.count, before.yan + 1, '炎');
|
||||
assert.equal(p0.bonds.victoriaShip.count, before.vic + 1, '维多利亚');
|
||||
// a second copy in the hand: still one distinct member (BOARD counts the slot once)
|
||||
p0.acquireChess(T5A, { source: 'buy' });
|
||||
assert.equal(p0.bonds.yanShip.count, before.yan + 1);
|
||||
h.m.flush(true);
|
||||
const priv = h.lastTo('p_0', 'm.private');
|
||||
assert.equal(priv.bonds.find((b) => b.bondId === 'yanShip').count, before.yan + 1);
|
||||
assert.equal(priv.bonds.find((b) => b.bondId === 'victoriaShip').count, before.vic + 1);
|
||||
const pub = h.lastBc('m.public');
|
||||
assert.equal(pub.players[0].bonds.find((b) => b.bondId === 'victoriaShip').count, before.vic + 1, 'm.public (a teammate\'s strip)');
|
||||
const input = p0.battleInput();
|
||||
assert.equal(input.bonds.yanShip.count, before.yan + 1, 'the battle input\'s bond snapshot');
|
||||
h.invariants();
|
||||
h.m.dispose();
|
||||
});
|
||||
|
||||
test('协防: two 自选 prototypes (a tier-5 and a tier-6 slot) enable it; a preset 协防 chess with one too; 绝技 counts 自选 elites', REAL, () => {
|
||||
const h = soloMatch();
|
||||
const p0 = slot(h, { [T5B]: { charId: SHARP }, [T6A]: { charId: SHARP } });
|
||||
assert.ok(!p0.bonds.emptyShip?.active);
|
||||
deploy(h, p0, T5B);
|
||||
assert.equal(p0.bonds.emptyShip.count, 1);
|
||||
deploy(h, p0, T6A);
|
||||
assert.equal(p0.bonds.emptyShip.count, 2, 'two slots = two chess (like any operator who is two chess) [ASSUMED]');
|
||||
assert.equal(p0.bonds.emptyShip.active, true);
|
||||
// 绝技 (BOARD_ALL_CHESS): every elite on the board — a 自选 elite included
|
||||
const sunt0 = p0.bonds.suntShip?.count ?? 0;
|
||||
deploy(h, p0, 'chess_char_5_diy2_b');
|
||||
assert.equal(p0.bonds.suntShip.count, sunt0 + 1);
|
||||
h.invariants();
|
||||
h.m.dispose();
|
||||
});
|
||||
|
||||
test('sim: a 叙拉古 自选 piece gets 叙拉古\'s deployment buff; a 协防 one the ×1.2 damage (elite ×1.4)', REAL, () => {
|
||||
const bonds = { siracusaShip: { count: 3, active: true, tier: 1, layers: 10 }, emptyShip: { count: 2, active: true, tier: 1, layers: 0 } };
|
||||
const h = makeBattle({
|
||||
units: [
|
||||
{ diy: { slot: 5, charId: CROSLY, skillIndex: 0 }, row: 10, col: 4 },
|
||||
{ diy: { slot: T5B, charId: SHARP }, row: 10, col: 6 },
|
||||
{ diy: { slot: 6, charId: SHARP }, elite: true, row: 11, col: 6 },
|
||||
],
|
||||
bonds,
|
||||
timeLimit: 10,
|
||||
});
|
||||
h.run(1);
|
||||
const crosly = h.allies().find((u) => u.def?.charId === CROSLY);
|
||||
const [normal, elite] = [h.allies().find((u) => u.def?.charId === SHARP && !u.def.golden), h.allies().find((u) => u.def?.charId === SHARP && u.def.golden)];
|
||||
assert.ok(crosly && normal && elite, 'the three 自选 pieces are fielded');
|
||||
assert.deepEqual(crosly.def.bonds, ['siracusaShip']);
|
||||
assert.ok(crosly.hasBuff('bond:siracusa'), '叙拉古: ASPD on deployment');
|
||||
assert.ok(!normal.hasBuff('bond:siracusa'), 'not for a non-member');
|
||||
const scale = (u) => u.buffs.filter((b) => /empty/i.test(b.key)).map((b) => b.mods?.dmgDealtMul).find((x) => x != null);
|
||||
const bb = DATA.bonds.emptyShip;
|
||||
assert.ok(bb);
|
||||
assert.equal(scale(normal), 1.2, '协防 member ×1.2');
|
||||
assert.equal(scale(elite), 1.4, '协防 elite member ×1.4');
|
||||
assert.equal(scale(crosly), undefined, '弑君者 is no 协防 member');
|
||||
checkBattle(h.b);
|
||||
});
|
||||
@@ -0,0 +1,340 @@
|
||||
// 0.2.0 自选编队 — client side (research 0.2.0 §2; the owner's decisions of 2026-10-05): the 自选编队 tab's model (storage,
|
||||
// the slots, the picker's operators with the kit list, skill / module choices without 集成战略 modules, export / import)
|
||||
// and its server sync (room.diy; welcome.diyKitted), the tab's components (four slots, the picker, 确认), and the match
|
||||
// UI of a 自选 piece — the shop / reward card and the detail card show the operator (name, art, class, derived bonds,
|
||||
// the pick's skill and module) with a 「自选」 badge, the own pieces get a tag, placement reads the operator's position, and
|
||||
// the bond popup lists the 自选 member (own and a teammate's).
|
||||
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/diyModel.js');
|
||||
const { installDiySync, 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 { BondPopup } = await import('../../public/js/ui/bondStrip.js');
|
||||
const { ownerBoard } = await import('../../public/js/ui/watchBonds.js');
|
||||
const { diyPieces } = await import('../../public/js/screens/game/standInTags.js');
|
||||
const { DiyPanelView, DiyPickerView } = await import('../../public/js/screens/diy.js');
|
||||
const { data } = await import('../../public/js/data.js');
|
||||
const { DATA } = await import('../match/harness.js');
|
||||
const { KITTED_CHARS } = await import('../../server/sim/content/kits/index.js');
|
||||
|
||||
await data.loadAll('chess', 'bonds', 'assets', 'garrisons', 'items', 'backups');
|
||||
|
||||
const D = { chess: DATA.chess, backups: DATA.backups };
|
||||
const getChess = (id) => (Object.hasOwn(DATA.chess, id) ? DATA.chess[id] : null);
|
||||
const T5A = 'chess_char_5_diy1_a';
|
||||
const T5B = 'chess_char_5_diy2_a';
|
||||
const T6A = 'chess_char_6_diy1_a';
|
||||
const T6B = 'chess_char_6_diy2_a';
|
||||
const SIEGE = 'char_112_siege';
|
||||
const SHARP = 'char_609_acguad';
|
||||
const KALTS = 'char_003_kalts'; // 凯尔希: an ISW-A module at both elite stages
|
||||
const SIEGE_PICK = { charId: SIEGE, skillIndex: 2, uniEquipId: 'uniequip_002_siege' };
|
||||
const KIT = [...KITTED_CHARS];
|
||||
|
||||
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);
|
||||
/** Render a component tree one level of function components deep at a time (htm vnodes with function types). */
|
||||
function expand(v, depth = 6) {
|
||||
if (Array.isArray(v)) return v.map((x) => expand(x, depth));
|
||||
if (!v || typeof v !== 'object') return v;
|
||||
if (typeof v.type === 'function' && depth > 0) {
|
||||
try { return expand(v.type(v.props || {}), depth - 1); } catch { return v; }
|
||||
}
|
||||
const children = v.props?.children;
|
||||
return children === undefined ? v : { ...v, props: { ...v.props, children: expand(children, depth) } };
|
||||
}
|
||||
const 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/diyModel.js)', () => {
|
||||
test('storage: tolerant parse, junk and hostile keys dropped, round trip', () => {
|
||||
assert.deepEqual(M.parseStoredDiy(null), {});
|
||||
const raw = { v: 1, picks: { [T5A]: SIEGE_PICK, [T6A]: { charId: SHARP }, __proto__x: 1, bad: { charId: 'a b' }, [T6B]: { charId: SIEGE, skillIndex: 99 } } };
|
||||
assert.deepEqual(M.parseStoredDiy(raw), { [T5A]: SIEGE_PICK, [T6A]: { charId: SHARP }, [T6B]: { charId: SIEGE } });
|
||||
assert.deepEqual(M.toStoredDiy({ [T5A]: SIEGE_PICK }), { v: M.DIY_VERSION, picks: { [T5A]: SIEGE_PICK } });
|
||||
assert.equal(M.diyCount({ [T5A]: SIEGE_PICK, [T6A]: null }), 1);
|
||||
assert.deepEqual(M.setPick({ [T5A]: SIEGE_PICK }, T5A, null), {});
|
||||
assert.deepEqual(M.setPick({}, T6A, { charId: SHARP }), { [T6A]: { charId: SHARP } });
|
||||
});
|
||||
|
||||
test('the four slots by tier, with the 调度中心 level that sells them', () => {
|
||||
assert.deepEqual(M.diySlotList(D).map((s) => [s.slotId, s.tier, s.shopLevel]), [[T5A, 5, 5], [T5B, 5, 5], [T6A, 6, 6], [T6B, 6, 6]]);
|
||||
});
|
||||
|
||||
test('the picker lists only kitted operators (prototypes first), marks one another slot holds', () => {
|
||||
const opts = M.pickOptions(T5A, {}, D, KIT);
|
||||
const ids = opts.map((o) => o.charId);
|
||||
assert.ok(ids.includes(SIEGE) && ids.includes(SHARP) && ids.includes('char_601_cguard'));
|
||||
assert.ok(ids.every((id) => KIT.includes(id)), 'no operator without a kit');
|
||||
assert.ok(ids.indexOf(SHARP) < ids.indexOf(SIEGE), 'prototypes first');
|
||||
assert.deepEqual(opts.find((o) => o.charId === SIEGE).bonds, ['victoriaShip']);
|
||||
assert.equal(opts.find((o) => o.charId === SHARP).proto, true);
|
||||
assert.ok(!M.pickOptions(T6A, {}, D, KIT).some((o) => o.charId === 'char_601_cguard'), 'a 4★ reserve is a tier-5 pick only');
|
||||
// taken: an owned operator in any other slot; a prototype in the other slot of its tier (not across tiers)
|
||||
const roster = { [T5B]: { charId: SHARP }, [T6A]: SIEGE_PICK };
|
||||
const at5 = M.pickOptions(T5A, roster, D, KIT);
|
||||
assert.equal(at5.find((o) => o.charId === SIEGE).taken, T6A);
|
||||
assert.equal(at5.find((o) => o.charId === SHARP).taken, T5B);
|
||||
assert.equal(M.pickOptions(T6B, roster, D, KIT).find((o) => o.charId === SHARP).taken, null, 'a prototype may fill a tier-5 and a tier-6 slot');
|
||||
assert.deepEqual(M.pickOptions(T5A, {}, D, []), [], 'no kit list (not connected): nothing to pick');
|
||||
});
|
||||
|
||||
test('choices: three skills, the elite form\'s modules without the 集成战略 (ISW-A) ones; a prototype\'s locked selection', () => {
|
||||
const ch = M.pickChoices(SIEGE, T6A, D);
|
||||
assert.deepEqual(ch.skills.map((s) => s.index), [0, 1, 2]);
|
||||
assert.deepEqual(ch.modules.map((x) => x.uniEquipId), ['uniequip_002_siege', 'uniequip_003_siege']);
|
||||
assert.equal(ch.stage, 3);
|
||||
assert.equal(M.pickChoices(SIEGE, T5A, D).stage, 1);
|
||||
const kalts = M.pickChoices(KALTS, T6A, D);
|
||||
assert.ok(kalts.elite.modules.some((x) => x.typeName === 'ISW-A'), 'the form has one');
|
||||
assert.ok(!kalts.modules.some((x) => x.rec.typeName === 'ISW-A'), 'never offered');
|
||||
const proto = M.pickChoices(SHARP, T5A, D);
|
||||
assert.equal(proto.proto, true);
|
||||
assert.deepEqual(proto.locked, { skillIndex: 2, uniEquipId: 'uniequip_002_acguad' });
|
||||
assert.deepEqual(M.defaultPick(SIEGE, T5A, D), { charId: SIEGE, skillIndex: 2, uniEquipId: 'uniequip_002_siege' }, 'S3 and the first module');
|
||||
assert.deepEqual(M.defaultPick(SHARP, T5A, D), { charId: SHARP });
|
||||
});
|
||||
|
||||
test('sanitize keeps what the server keeps; export / import envelopes', () => {
|
||||
assert.deepEqual(M.sanitizeDiyPicks({ [T5A]: SIEGE_PICK, [T6A]: { charId: SIEGE, skillIndex: 0 } }, D, KIT), { [T5A]: SIEGE_PICK });
|
||||
const text = M.serializeDiy({ [T5A]: SIEGE_PICK }, { now: 0 });
|
||||
const env = JSON.parse(text);
|
||||
assert.deepEqual([env.kind, env.v, env.count, env.picks], [M.DIY_EXPORT_KIND, 1, 1, { [T5A]: SIEGE_PICK }]);
|
||||
assert.deepEqual(M.parseDiyImport(text), { ok: true, picks: { [T5A]: SIEGE_PICK } });
|
||||
assert.deepEqual(M.parseDiyImport({ v: 1, picks: {} }), { ok: true, picks: {} });
|
||||
for (const bad of ['', '{nope', { v: 9, picks: {} }, { kind: 'stronghold.ownership', v: 1, notOwned: [] }, 'x'.repeat(M.DIY_IMPORT_MAX_BYTES + 1), [1]]) {
|
||||
assert.equal(M.parseDiyImport(bad).ok, false, JSON.stringify(bad).slice(0, 30));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---- 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(([, x]) => x.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('diy sync: welcome brings the kit list and sends room.diy; edits are debounced; in a match stored for the next one (told once)', async () => {
|
||||
const net = fakeNet();
|
||||
const T = fakeTimers();
|
||||
const told = [];
|
||||
const target = createStore({ diy: { [T5A]: SIEGE_PICK }, diyKitted: null, open: false, diySync: 'idle' });
|
||||
const s = installDiySync({ net, timers: T, target, notify: (x) => told.push(x) });
|
||||
net.emit('welcome', { diyKitted: [SIEGE, SHARP, 7] });
|
||||
assert.deepEqual(target.get().diyKitted, [SIEGE, SHARP]);
|
||||
await T.advance(100);
|
||||
assert.deepEqual(net.sent, [{ t: 'room.diy', picks: { [T5A]: SIEGE_PICK } }]);
|
||||
assert.equal(target.get().diySync, 'synced');
|
||||
target.set({ diy: { [T5A]: SIEGE_PICK, [T6A]: { charId: SHARP } } });
|
||||
await T.advance(SYNC_DEBOUNCE_MS + 10);
|
||||
assert.deepEqual(net.sent[1], { t: 'room.diy', picks: { [T5A]: SIEGE_PICK, [T6A]: { charId: SHARP } } });
|
||||
net.replies.push({ error: 'ROOM_STARTED' });
|
||||
target.set({ diy: {} });
|
||||
await T.advance(SYNC_DEBOUNCE_MS + 10);
|
||||
assert.equal(target.get().diySync, 'locked');
|
||||
assert.deepEqual(told, ['自选编队是局外设置,修改将在下一局生效']);
|
||||
net.emit('room.state', { inMatch: false });
|
||||
await T.advance(SYNC_DEBOUNCE_MS + 10);
|
||||
assert.deepEqual(net.sent.at(-1), { t: 'room.diy', picks: {} });
|
||||
s.dispose();
|
||||
});
|
||||
|
||||
// ---- the tab --------------------------------------------------------------------------------------------------------------
|
||||
|
||||
describe('自选编队 tab (screens/diy.js)', () => {
|
||||
test('four slots by tier: an empty slot to fill, a filled one shows the operator, its tag, skill and module', () => {
|
||||
const tree = expand(DiyPanelView({ m: data.get('assets'), picks: { [T5A]: SIEGE_PICK, [T6A]: { charId: SHARP } }, legal: { [T5A]: SIEGE_PICK, [T6A]: { charId: SHARP } }, kitted: KIT, onSet() {}, picking: null, onPicking() {} }));
|
||||
const slots = [...walk(tree)].filter((v) => hasClass(v, 'diy-slot'));
|
||||
assert.deepEqual(slots.map((v) => v.props['data-slot']), [T5A, T5B, T6A, T6B]);
|
||||
const siege = slots[0];
|
||||
assert.equal(siege.props['data-char'], SIEGE);
|
||||
const txt = textOf(siege);
|
||||
assert.ok(txt.includes(DATA.backups.units[SIEGE].name) && txt.includes('已持有') && txt.includes('S3') && txt.includes('SOL-X'), txt);
|
||||
assert.ok(textOf(slots[2]).includes('原型'));
|
||||
assert.ok(hasClass(slots[1], 'is-empty') && textOf(slots[1]).includes('选择干员'));
|
||||
});
|
||||
|
||||
test('the picker: the slot\'s operators; choosing one and its skill / module confirms the pick', () => {
|
||||
const done = [];
|
||||
let draft = { ...SIEGE_PICK };
|
||||
const view = () => expand(DiyPickerView({ m: null, slot: { slotId: T5A, tier: 5, shopLevel: 5 }, picks: { [T5A]: SIEGE_PICK }, kitted: KIT, onDone: (p) => done.push(p), onClose() {},
|
||||
filter: 'all', query: '', draft, onFilter() {}, onQuery() {}, onDraft: (d) => { draft = d; } }));
|
||||
let tree = view();
|
||||
const opts = [...walk(tree)].filter((v) => hasClass(v, 'diy-opt'));
|
||||
assert.ok(opts.some((v) => v.props['data-char'] === SIEGE) && opts.some((v) => v.props['data-char'] === SHARP));
|
||||
// the current pick is shown with its three skills and its modules (none + X + Y)
|
||||
const skills = [...walk(tree)].filter((v) => v.props?.role === 'radio' && v.props['data-skill'] != null);
|
||||
assert.deepEqual(skills.map((v) => v.props['data-skill']), [0, 1, 2]);
|
||||
assert.equal(skills.find((v) => v.props['aria-checked'] === 'true').props['data-skill'], 2);
|
||||
const mods = [...walk(tree)].filter((v) => v.props?.role === 'radio' && v.props['data-module'] != null);
|
||||
assert.deepEqual(mods.map((v) => v.props['data-module']), ['none', 'uniequip_002_siege', 'uniequip_003_siege']);
|
||||
// another skill and no module, then 确认
|
||||
skills.find((v) => v.props['data-skill'] === 0).props.onClick();
|
||||
tree = view();
|
||||
[...walk(tree)].find((v) => v.props?.role === 'radio' && v.props['data-module'] === 'none').props.onClick();
|
||||
tree = view();
|
||||
[...walk(tree)].find((v) => v.props?.['data-testid'] === 'diy-confirm').props.onClick();
|
||||
assert.deepEqual(done, [{ charId: SIEGE, skillIndex: 0, uniEquipId: null }]);
|
||||
// a prototype: its locked skill only; 确认 sends just the operator (the server completes the locked selection)
|
||||
[...walk(tree)].find((v) => hasClass(v, 'diy-opt') && v.props['data-char'] === SHARP).props.onClick();
|
||||
tree = view();
|
||||
assert.deepEqual([...walk(tree)].filter((v) => v.props?.role === 'radio' && v.props['data-skill'] != null).map((v) => [v.props['data-skill'], v.props.disabled]), [[2, true]]);
|
||||
[...walk(tree)].find((v) => v.props?.['data-testid'] === 'diy-confirm').props.onClick();
|
||||
assert.deepEqual(done.at(-1), { charId: SHARP });
|
||||
});
|
||||
});
|
||||
|
||||
// ---- match UI -------------------------------------------------------------------------------------------------------------
|
||||
|
||||
describe('match UI of a 自选 piece', () => {
|
||||
const priv = { diy: { [T5A]: SIEGE_PICK, [T6A]: { charId: SHARP, skillIndex: 2, uniEquipId: 'uniequip_002_acguad' } }, loadout: {}, standIns: [], board: [], hand: [], temp: [] };
|
||||
test('gameLogic: the own pick of a slot (normal + elite), the composed record, the own getter', () => {
|
||||
assert.deepEqual(G.ownDiyPick(priv, getChess(T5A)), SIEGE_PICK);
|
||||
assert.deepEqual(G.ownDiyPick(priv, getChess('chess_char_5_diy1_b')), SIEGE_PICK, 'the elite too');
|
||||
assert.equal(G.ownDiyPick(priv, getChess(T5B)), null, 'a slot not filled');
|
||||
assert.equal(G.ownDiyPick(priv, getChess('chess_char_4_22_a')), null);
|
||||
const rec = G.ownDiyRecord(getChess(T5A), priv, D);
|
||||
assert.deepEqual([rec.name, rec.charId, rec.diyFor, rec.bonds, rec.tier, rec.price], [DATA.backups.units[SIEGE].name, SIEGE, T5A, ['victoriaShip'], 5, 4]);
|
||||
assert.equal(G.ownDiyRecord(getChess(T5A), priv, D), rec, 'cached');
|
||||
const get = G.diyGetter(getChess, priv, D);
|
||||
assert.equal(get(T5A), rec);
|
||||
assert.equal(get('chess_char_4_22_a'), getChess('chess_char_4_22_a'));
|
||||
assert.equal(G.chessLoadout(get('chess_char_5_diy1_b'), priv.loadout, get).skill.skillId, 'skchr_siege_3', 'the pick\'s skill');
|
||||
assert.equal(G.deployedModuleId(get('chess_char_5_diy1_b'), priv, get, D.backups), 'uniequip_002_siege', 'the pick\'s module on the elite');
|
||||
assert.equal(G.cardDiy(getChess(T5A), { unit: { diy: { charId: SHARP, skillIndex: 2, uniEquipId: 'uniequip_002_acguad' } }, data: D }).charId, SHARP, 'a teammate\'s unit says it itself');
|
||||
});
|
||||
|
||||
test('placement reads the operator\'s position (推进之王 melee) through the own getter', () => {
|
||||
const get = G.diyGetter(getChess, priv, D);
|
||||
const ctx = G.placementContext({ priv, stage: null, editable: true, getChess: get, backups: D.backups });
|
||||
assert.equal(G.piecePosition(ctx, { kind: 'chess', id: T5A }), 'MELEE');
|
||||
});
|
||||
|
||||
test('shop / reward card: the operator\'s name and bonds, the 「自选」 badge, the pick\'s skill', () => {
|
||||
const card = ChessCard({ slot: { kind: 'chess', id: T5A, price: 4, basePrice: 4 }, idx: 0, priv, onBuy() {}, onDetail() {} });
|
||||
const nodes = [...walk(card)];
|
||||
const badge = nodes.find((v) => hasClass(v, 'scard__diy'));
|
||||
assert.ok(badge);
|
||||
assert.equal(badge.props['data-diy'], SIEGE);
|
||||
assert.equal(badge.props.children, '自选');
|
||||
assert.equal(nodes.find((v) => hasClass(v, 'scard__name')).props.children, DATA.backups.units[SIEGE].name);
|
||||
assert.ok(nodes.some((v) => v.props?.bondId === 'victoriaShip'), 'the derived bond');
|
||||
assert.equal(nodes.find((v) => v?.type && v.props?.lo).props.lo.skill.skillId, 'skchr_siege_3');
|
||||
const other = ChessCard({ slot: { kind: 'chess', id: T5A, price: 4, basePrice: 4 }, idx: 0, priv: { diy: {} }, onBuy() {}, onDetail() {} });
|
||||
assert.equal([...walk(other)].find((v) => hasClass(v, 'scard__diy')), undefined);
|
||||
});
|
||||
|
||||
test('detail card: own piece / card / a unit carrying diy → the operator, the 「自选」 tag, the pick\'s skill and module', () => {
|
||||
const piece = { uid: 7, kind: 'chess', id: 'chess_char_5_diy1_b', golden: true, items: [] };
|
||||
const pieces = new Map([[7, { piece, area: 'board', row: 10, col: 4 }]]);
|
||||
const r = resolveDetail({ kind: 'piece', uid: 7 }, pieces, { priv, backups: D.backups });
|
||||
assert.equal(r.chess.charId, SIEGE);
|
||||
assert.deepEqual(r.diy, SIEGE_PICK);
|
||||
assert.equal(resolveDetail({ kind: 'chess', id: T5A }, new Map(), { priv, backups: D.backups }).chess.charId, SIEGE, 'a shop card');
|
||||
assert.equal(resolveDetail({ kind: 'chess', id: T5A, foreign: true }, new Map(), { priv, backups: D.backups }).diy, undefined, 'the banned list / another player\'s card');
|
||||
const u = resolveDetail({ kind: 'unit', unit: { id: 3, uid: 33, side: 'ally', kind: 'op', defId: T6A, diy: { charId: SHARP, skillIndex: 2, uniEquipId: 'uniequip_002_acguad' } } }, new Map(), { priv: null, backups: D.backups });
|
||||
assert.equal(u.chess.charId, SHARP);
|
||||
const blocks = ChessDetail({ chess: r.chess, piece, editable: false, bonds: [], loadout: priv.loadout, diy: r.diy });
|
||||
const head = blocks.find((b) => b.key === 'head');
|
||||
assert.equal([...walk(head)].find((v) => hasClass(v, 'dtag-diy'))?.props.children, '自选');
|
||||
assert.equal([...walk(head)].find((v) => hasClass(v, 'dhead__name')).props.children, DATA.backups.units[SIEGE].name);
|
||||
const skill = blocks.find((b) => b.key === 'skill');
|
||||
assert.ok(JSON.stringify(skill.props).includes('skchr_siege_3'));
|
||||
const mod = blocks.find((b) => b.key === 'module');
|
||||
assert.ok(JSON.stringify(mod.props).includes('uniequip_002_siege'), 'the pick\'s module (elite)');
|
||||
assert.ok(!blocks.some((b) => b.key === 'garrison'), 'no 特质');
|
||||
});
|
||||
|
||||
test('own prep pieces of a filled slot get the 「自选」 tag', () => {
|
||||
const p = { ...priv, hand: [{ uid: 1, kind: 'chess', id: T5A }, { uid: 2, kind: 'chess', id: 'chess_char_4_22_a' }], temp: [], board: [{ uid: 4, kind: 'chess', id: 'chess_char_6_diy1_b', row: 10, col: 4 }] };
|
||||
assert.deepEqual(diyPieces(p, getChess, D).map((x) => [x.uid, x.area, x.label, x.name]), [[1, 'hand', '自选', DATA.backups.units[SIEGE].name], [4, 'board', '自选', DATA.backups.units[SHARP].name]]);
|
||||
assert.deepEqual(diyPieces({ ...p, diy: {} }, getChess, D), []);
|
||||
});
|
||||
|
||||
test('bonds: the popup lists the own 自选 member (煌-style several bonds count each) and a teammate\'s by its unit\'s pick', () => {
|
||||
const get = G.diyGetter(getChess, priv, D);
|
||||
const own = { ...priv, board: [{ uid: 4, kind: 'chess', id: T5A, row: 10, col: 4 }], hand: [{ uid: 5, kind: 'chess', id: T6A }] };
|
||||
const vic = G.bondMembers(DATA.bonds.victoriaShip, own, [], get);
|
||||
const row = vic.find((x) => x.diy);
|
||||
assert.deepEqual([row.id, row.name, row.onBoard, row.owned, row.rec.charId], [T5A, DATA.backups.units[SIEGE].name, true, true, SIEGE]);
|
||||
const empty = G.bondMembers(DATA.bonds.emptyShip, own, [], get);
|
||||
assert.deepEqual(empty.filter((x) => x.diy).map((x) => [x.id, x.inHand]), [[T6A, true]], 'the prototype in the hand counts for 协防');
|
||||
assert.equal(G.memberHeadCount(vic.filter((x) => x.diy)), 1);
|
||||
// a watched teammate: its board from the field units, their picks carried along (watchBonds ownerBoard)
|
||||
const field = { units: [{ kind: 'op', ownerId: 'p_2', defId: T5B, diy: { charId: 'char_017_huang', skillIndex: 0, uniEquipId: null } }] };
|
||||
const mate = ownerBoard(field, 'p_2');
|
||||
assert.deepEqual(mate.board[0].diy, { charId: 'char_017_huang', skillIndex: 0, uniEquipId: null });
|
||||
const pieceRecord = (p) => (p.diy ? G.diyRecordFor(getChess(p.id), p.diy, D) : null) || getChess(p.id);
|
||||
for (const b of ['yanShip', 'victoriaShip']) {
|
||||
const rows = G.bondMembers(DATA.bonds[b], mate, [], getChess, () => null, pieceRecord);
|
||||
assert.ok(rows.some((x) => x.diy && x.rec.charId === 'char_017_huang'), `煌 is a ${b} member`);
|
||||
}
|
||||
// the popup itself renders the 自选 row with its tag
|
||||
const pop = expand(BondPopup({ bondId: 'victoriaShip', entry: { count: 1, active: false, tier: 0, layers: 0 }, priv: own, onClose() {} }));
|
||||
const mrow = [...walk(pop)].find((v) => hasClass(v, 'bpop__member') && textOf(v).includes(DATA.backups.units[SIEGE].name));
|
||||
assert.ok(mrow, 'the 自选 member row');
|
||||
assert.ok([...walk(mrow)].some((v) => hasClass(v, 'bpop__diy')), 'tagged 自选');
|
||||
});
|
||||
|
||||
test('the renderer draws the operator (pieceInfo reads m.private.diy; UnitInfo diy is kept)', () => {
|
||||
const src = readFileSync(path.join(ROOT, 'public/js/render/app.js'), 'utf8');
|
||||
assert.match(src, /const pick = chess && chess\.isDiy \? diyPicks\[chess\.baseId \|\| chess\.chessId\] : null;/);
|
||||
assert.match(src, /diyPicks = src\.diy && typeof src\.diy === 'object' \? src\.diy : \{\};/);
|
||||
assert.match(readFileSync(path.join(ROOT, 'public/js/render/app/info.js'), 'utf8'), /diy: u\.diy && typeof u\.diy === 'object'/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,164 @@
|
||||
// Real-server browser E2E of 0.2.0 自选编队 end to end (research 0.2.0 §2; the owner's decisions of 2026-10-05): the
|
||||
// 自选编队 tab of the 干员调配 overlay fills the first tier-5 slot with 推进之王 (real clicks: the slot, the operator, S3, the
|
||||
// SOL-X module, 确认; persisted in localStorage; room.diy → session → seat → the next match's m.private.diy); in the
|
||||
// match the 调度中心 is at level 5 and the shop sells the slot as 推进之王 with the 「自选」 badge; bought, the hand piece is
|
||||
// tagged 「自选」 and draws 推进之王's model; deployed, the detail card shows 推进之王 with the pick's skill; and in the
|
||||
// browser's own battle (client-side combat) the unit is the operator — the b.start spec carries the pick (`diy`), the
|
||||
// local sim fields char_112_siege with its operator kit, and the battle view draws its Spine model.
|
||||
//
|
||||
// SP_E2E=1 node --test test/ui/diy.e2e.test.js
|
||||
//
|
||||
// The server is test/e2e/fastServer.mjs with its starter-kit hooks (no starter operators, the 调度中心 at level 5 —
|
||||
// SP_START_LEVEL —, the tier-5 DIY slot 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/diy-*.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 SLOT = 'chess_char_5_diy1_a';
|
||||
const SIEGE = 'char_112_siege';
|
||||
const PICK = { charId: SIEGE, skillIndex: 2, uniEquipId: 'uniequip_002_siege' };
|
||||
|
||||
describe('0.2.0 自选编队 — a slotted operator in the own shop and battle (real server)', { skip: !ENABLED && 'set SP_E2E=1 (Chrome + public/assets)' }, () => {
|
||||
test('自选编队: slot 推进之王 → level-5 shop card (自选) → buy → deploy → the local battle fields 推进之王', { timeout: 6 * 60 * 1000 }, async () => {
|
||||
const srv = await startRealServer({ fast: { timerScale: 0.5, combatSpeed: 2, startRound: 1, kit: 0, level: 5, shop: [SLOT] } });
|
||||
const P = (await import('puppeteer-core')).default;
|
||||
const c = new Client(P, srv.base, 'diy', { prefix: 'diy' });
|
||||
try {
|
||||
await c.open();
|
||||
await c.enter('自选');
|
||||
// 1) 干员调配 → 自选编队: fill the first tier-5 slot (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="diy"]', { visible: true, timeout: 15000 });
|
||||
await c.click('.lo .lo-tab[data-tab="diy"]');
|
||||
await c.page.waitForSelector(`.diy-slot[data-slot="${SLOT}"] .diy-slot__fill`, { visible: true, timeout: 15000 });
|
||||
const slots = await c.page.evaluate(() => [...document.querySelectorAll('.diy-slot')].map((el) => el.dataset.slot));
|
||||
assert.deepEqual(slots, ['chess_char_5_diy1_a', 'chess_char_5_diy2_a', 'chess_char_6_diy1_a', 'chess_char_6_diy2_a']);
|
||||
await c.shot('tab');
|
||||
await c.click(`.diy-slot[data-slot="${SLOT}"] .diy-slot__fill`);
|
||||
await c.page.waitForSelector(`[data-testid="diy-picker"] .diy-opt[data-char="${SIEGE}"]`, { visible: true, timeout: 8000 });
|
||||
const listed = await c.page.evaluate(() => [...document.querySelectorAll('[data-testid="diy-picker"] .diy-opt')].map((el) => el.dataset.char));
|
||||
assert.ok(listed.includes('char_609_acguad') && listed.includes('char_601_cguard'), 'the tier-5 prototypes are listed');
|
||||
await c.click(`[data-testid="diy-picker"] .diy-opt[data-char="${SIEGE}"]`);
|
||||
await c.page.waitForSelector('[data-testid="diy-picker"] .diy-choice[data-skill="2"]', { visible: true, timeout: 5000 });
|
||||
await c.click('[data-testid="diy-picker"] .diy-choice[data-skill="2"]');
|
||||
await c.click('[data-testid="diy-picker"] .diy-choice[data-module="uniequip_002_siege"]');
|
||||
await c.shot('picker');
|
||||
await c.click('[data-testid="diy-confirm"]');
|
||||
await c.page.waitForFunction((slot, id) => document.querySelector(`.diy-slot[data-slot="${slot}"]`)?.dataset.char === id, { timeout: 4000 }, SLOT, SIEGE);
|
||||
await c.page.waitForFunction(() => /已同步/.test(document.querySelector('[data-testid="diy-sync"]')?.textContent || ''), { timeout: 8000 });
|
||||
const card = await c.page.evaluate((slot) => document.querySelector(`.diy-slot[data-slot="${slot}"]`)?.textContent || '', SLOT);
|
||||
assert.ok(card.includes('推进之王') && card.includes('已持有') && card.includes('SOL-X'), card);
|
||||
const stored = await c.page.evaluate(() => JSON.parse(localStorage.getItem('sp.pref.diy') || 'null'));
|
||||
assert.deepEqual(stored, { v: 1, picks: { [SLOT]: PICK } });
|
||||
await c.shot('slotted');
|
||||
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 picks
|
||||
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 diy = await c.page.evaluate(() => globalThis.__SP__.store.get().match.private?.diy ?? null);
|
||||
assert.deepEqual(diy, { [SLOT]: PICK }, 'the match received the picks');
|
||||
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 >= 4 && s.level >= 5, 'prep at 调度中心 level 5', 60000);
|
||||
await sleep(1800); // camera flight
|
||||
|
||||
// 3) the shop card at level 5: 推进之王 with 「自选」 (only this player's shop sells it); two taps buy it
|
||||
await c.page.waitForSelector('.scard .scard__diy', { visible: true, timeout: 10000 });
|
||||
const shopCard = await c.page.evaluate(() => {
|
||||
const el = document.querySelector('.scard .scard__diy')?.closest('.scard');
|
||||
return el ? { name: el.querySelector('.scard__name')?.textContent, badge: el.querySelector('.scard__diy')?.textContent, diy: el.querySelector('.scard__diy')?.dataset.diy, bonds: el.querySelector('.scard__bonds')?.textContent } : null;
|
||||
});
|
||||
assert.deepEqual([shopCard?.name, shopCard?.badge, shopCard?.diy], ['推进之王', '自选', SIEGE]);
|
||||
assert.ok(shopCard.bonds.includes('维多利亚'), `derived bond: ${shopCard.bonds}`);
|
||||
const level = await c.page.evaluate(() => globalThis.__SP__.store.get().match.private?.shop?.level);
|
||||
assert.ok(level >= 5, 'sold from 调度中心 level 5');
|
||||
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 === SLOT);
|
||||
assert.ok(piece, 'the slot\'s piece is in the hand');
|
||||
await c.page.waitForFunction((uid) => document.querySelector(`.sitag[data-uid="${uid}"]`)?.textContent === '自选', { 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.equal(handModel, SIEGE, 'the hand draws the operator');
|
||||
await c.shot('hand');
|
||||
|
||||
// 4) deploy it (drag → direction wheel); the detail card shows 推进之王 with the pick's skill and the 「自选」 tag
|
||||
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 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-diy', { timeout: 2500 }).then(() => c.page.evaluate(() => ({
|
||||
tag: document.querySelector('.dpanel .dtag-diy')?.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: '自选', name: '推进之王', skill: 'skchr_siege_3' });
|
||||
await c.shot('board');
|
||||
await c.page.keyboard.press('Escape');
|
||||
|
||||
// 5) ready → the local battle fields 推进之王 (the spec's diy pick; the sim's def and kit; 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, siege) => {
|
||||
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, diy: entry.diy ?? null, skillIndex: entry.skillIndex ?? null },
|
||||
charId: u.def?.charId, diyFor: u.def?.diyFor, skill: u.skill?.id ?? null, bonds: u.def?.bonds, generic: !!u.kit?.generic,
|
||||
viewSpine: v.info.spine, viewDiy: v.info.diy?.charId ?? null, model: String(v._actorEntry?.skel || ''), siege,
|
||||
};
|
||||
}, { timeout: 30000, polling: 150 }, piece.uid, SIEGE).then((h) => h.jsonValue());
|
||||
assert.equal(got.authoritative, true, 'the own normal battle is simulated by this browser');
|
||||
assert.deepEqual(got.spec, { chessId: SLOT, diy: PICK, skillIndex: null }, 'b.start spec carries the pick');
|
||||
assert.equal(got.charId, SIEGE, 'the local sim fields 推进之王');
|
||||
assert.equal(got.diyFor, SLOT);
|
||||
assert.equal(got.skill, 'skchr_siege_3', 'with the picked S3');
|
||||
assert.deepEqual(got.bonds, ['victoriaShip']);
|
||||
assert.equal(got.generic, false, 'its operator kit');
|
||||
assert.equal(got.viewSpine, SIEGE);
|
||||
assert.equal(got.viewDiy, SIEGE, 'the view knows the pick');
|
||||
assert.match(got.model, /char_112_siege/, 'the battle view draws 推进之王\'s Spine model');
|
||||
await sleep(800);
|
||||
await c.shot('combat');
|
||||
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