feedback7 R22-LOADOUT: 干员调配's detail starts with the operator's 特质 (the in-match card's block), and the compact skill tags of the quick choices; golden: unchanged
Substantially PR #301 by @farridge (its non-layout part, ported; the owner's decision of 2026-10-08: 「可以做,具体布局 怎么优化你来决定」 — the card layout is redesigned in the next commit): - ui/detailPanel.js exports GarrisonBlock; screens/loadout.js LoadoutGarrisons renders the 特质 of the form the detail's 普通 / 精锐 skill toggle shows (garrisons.json `descRaw` with its trigger markers, 「<进入休整期时>」 …) at the top of the detail; garrisons.json loads with the overlay; its reading sizes in loadout.css. - ui/loadoutModel.js quickSkillTags: SP recovery (攻回 / 受回 / 自回 / 被动, 部署触发 for a deploy-and-expire passive) and duration (s, 弹药, 常驻, ∞, 瞬发) from the generated SkillRecord, never from a localized description. - i18n: the six new strings in the four packs (en from the PR; ja / ko / zh-TW added). - Tests: test/ui/loadout-quick.test.js (from the PR, 4): quick tags of 空弦 / deployment skills / ammo / infinite, the 特质 blocks of 空弦 / 星熊 / 缄默德克萨斯 both forms; loadout-stats.test.js reads the new import.
This commit is contained in:
1 parent
7a0937e3dd
commit
519f04e2d5
12 files changed
+142
-9
No files matched your search
@@ -99,7 +99,7 @@ Official rule (bwiki 盟约, 更新公告 5114): before a match the player canno
|
|||||||
|
|
||||||
**Sim:** `simdata` resolves a unit def for `(chessId, skillIndex, moduleId)` (cached): `skill` = the selected SkillRecord; stats = `statsBase` + module attr (elite) ; trait/talents = base with the selected module's overrides. Kit contract (backward compatible): a kit function `(bb, chess) => { skill?, skills?: { [skillId]: SkillSpec }, talents, trait }` receives `chess.skill` = the **selected** skill and `bb` = its blackboard; the loader uses `skills[selectedSkillId]` if present, else `skill` only when the selected skill is the chess's default, else the **generic** skill spec — talents/trait from the kit always apply. Every visible chess must get hand-authored specs for all of its selectable skills (tracked by a coverage test).
|
**Sim:** `simdata` resolves a unit def for `(chessId, skillIndex, moduleId)` (cached): `skill` = the selected SkillRecord; stats = `statsBase` + module attr (elite) ; trait/talents = base with the selected module's overrides. Kit contract (backward compatible): a kit function `(bb, chess) => { skill?, skills?: { [skillId]: SkillSpec }, talents, trait }` receives `chess.skill` = the **selected** skill and `bb` = its blackboard; the loader uses `skills[selectedSkillId]` if present, else `skill` only when the selected skill is the chess's default, else the **generic** skill spec — talents/trait from the kit always apply. Every visible chess must get hand-authored specs for all of its selectable skills (tracked by a coverage test).
|
||||||
|
|
||||||
**UI:** a 干员调配 screen reachable from the lobby, room and briefing (INFO_CHECK): filter by tier/bond/class, per chess choose skill (icon, name, SP info, desc at Lv4 and Lv7) and elite module (official type icon from the local-client art when extracted (§13), else a lettered tile; name/type, stat bonus, trait/talent changes), 恢复默认, persisted; a 局内数值 section between the skills and the modules (普通 / 精锐 toggle, 精锐 first) draws the detail card's own stats block, 特性 and 天赋 for the chosen skill / module (§22.16); the chosen skill/module shown on shop cards and in the detail panel (a teammate's unit — 前往查看 in prep, 联防 / 最终攻势 fields, an observed battle — shows **its owner's** skill/module from its UnitInfo `skillIndex` / `moduleId`, `gameLogic.js unitLoadout`) — which also shows the stats, 特性, talents and attack range the unit fights with (`shared/loadoutRecord.js` `loadoutRecord` / `attackRangeGrid`, the same composition the sim's `getChess(id, loadout)` uses; the deploy wheel's range preview too) — the range it is deployed with: a selected passive range skill (引星棘刺 S3 3-9) and the 特性's permanent 攻击距离 (信仰搅拌机 SPT-Y +1) included, over all 1284 loadouts equal to the battle's range at deployment; in battle and prep the card draws the live `range` (§21.16). In battle the renderer plays the equipped skill's own Spine clip and ON_SKILL_START sound (`UnitInfo.skillIndex`; `data/assets.json` lists icons, clips and sounds for every skill index).
|
**UI:** a 干员调配 screen reachable from the lobby, room and briefing (INFO_CHECK): the detail starts with the operator's 特质 — the in-match `GarrisonBlock` / RichText of the form its 普通 / 精锐 skill toggle shows, triggers such as 「<进入休整期时>」 kept (0.2.2, PR #301 by @farridge); filter by tier/bond/class, per chess choose skill (icon, name, SP info, desc at Lv4 and Lv7) and elite module (official type icon from the local-client art when extracted (§13), else a lettered tile; name/type, stat bonus, trait/talent changes), 恢复默认, persisted; a 局内数值 section between the skills and the modules (普通 / 精锐 toggle, 精锐 first) draws the detail card's own stats block, 特性 and 天赋 for the chosen skill / module (§22.16); the chosen skill/module shown on shop cards and in the detail panel (a teammate's unit — 前往查看 in prep, 联防 / 最终攻势 fields, an observed battle — shows **its owner's** skill/module from its UnitInfo `skillIndex` / `moduleId`, `gameLogic.js unitLoadout`) — which also shows the stats, 特性, talents and attack range the unit fights with (`shared/loadoutRecord.js` `loadoutRecord` / `attackRangeGrid`, the same composition the sim's `getChess(id, loadout)` uses; the deploy wheel's range preview too) — the range it is deployed with: a selected passive range skill (引星棘刺 S3 3-9) and the 特性's permanent 攻击距离 (信仰搅拌机 SPT-Y +1) included, over all 1284 loadouts equal to the battle's range at deployment; in battle and prep the card draws the live `range` (§21.16). In battle the renderer plays the equipped skill's own Spine clip and ON_SKILL_START sound (`UnitInfo.skillIndex`; `data/assets.json` lists icons, clips and sounds for every skill index).
|
||||||
|
|
||||||
**导出 / 导入 a loadout:** 导出 hands the current loadout out as a versioned envelope `{ kind: 'stronghold.loadout', v, exportedAt, count, entries }` — copied through `ui/clipboard.js copyText` or downloaded as a file. 导入 accepts that envelope, the stored `{ v, entries }` form and a bare `{ [chessId]: { skill, module } }` map (serialised text too), and refuses another tool's `kind`, junk, an empty result and a **newer** `v` (`parseImport` — a newer payload is never mis-read). Both go through `loadoutSync.applyLoadoutEntries` → `sanitizeEntries`, so a preset from another build never sends the server an entry it would refuse, an import that keeps nothing changes nothing, and the result is reported as N imported / M not (`ui/loadoutModel.js`, `screens/loadout.js`).
|
**导出 / 导入 a loadout:** 导出 hands the current loadout out as a versioned envelope `{ kind: 'stronghold.loadout', v, exportedAt, count, entries }` — copied through `ui/clipboard.js copyText` or downloaded as a file. 导入 accepts that envelope, the stored `{ v, entries }` form and a bare `{ [chessId]: { skill, module } }` map (serialised text too), and refuses another tool's `kind`, junk, an empty result and a **newer** `v` (`parseImport` — a newer payload is never mis-read). Both go through `loadoutSync.applyLoadoutEntries` → `sanitizeEntries`, so a preset from another build never sends the server an entry it would refuse, an import that keeps nothing changes nothing, and the result is reported as N imported / M not (`ui/loadoutModel.js`, `screens/loadout.js`).
|
||||||
**干员持有 (0.2.0 补位):** the overlay's second tab marks operators as not owned (the 55 NORMAL chess; per-browser `sp.pref.ownership` = `{ v, notOwned }`, synced as `room.ownership { notOwned }` by the same sync engine as the loadout, `ui/loadoutSync.js installPrefSync`; 导出 / 导入 as `{ kind: 'stronghold.ownership', v, exportedAt, count, notOwned }`). A not-owned chess fights as its official stand-in with the backup skill / module — the loadout never applies to it ("对于补位干员其技能不可更改") — and is shown as the stand-in wherever the player's copy appears (shop / reward cards, hand and 临时整备区, board, detail card, result lineup, a teammate's scouting and bond popups: portrait, name, model, with a small 「替补」 mark; the owner's recall of the official mode, 2026-10-06), while its bonds, 特质, tier, price and merge stay the chess's; the 0.2.0 补位 design subsections have the rules and sources.
|
**干员持有 (0.2.0 补位):** the overlay's second tab marks operators as not owned (the 55 NORMAL chess; per-browser `sp.pref.ownership` = `{ v, notOwned }`, synced as `room.ownership { notOwned }` by the same sync engine as the loadout, `ui/loadoutSync.js installPrefSync`; 导出 / 导入 as `{ kind: 'stronghold.ownership', v, exportedAt, count, notOwned }`). A not-owned chess fights as its official stand-in with the backup skill / module — the loadout never applies to it ("对于补位干员其技能不可更改") — and is shown as the stand-in wherever the player's copy appears (shop / reward cards, hand and 临时整备区, board, detail card, result lineup, a teammate's scouting and bond popups: portrait, name, model, with a small 「替补」 mark; the owner's recall of the official mode, 2026-10-06), while its bonds, 特质, tier, price and merge stay the chess's; the 0.2.0 补位 design subsections have the rules and sources.
|
||||||
|
|||||||
@@ -236,6 +236,11 @@
|
|||||||
.lo-dhead__reset { position: absolute; right: .12rem; top: .12rem; }
|
.lo-dhead__reset { position: absolute; right: .12rem; top: .12rem; }
|
||||||
|
|
||||||
.lo-detail__body { flex: 1 1 auto; min-height: 0; overflow: auto; -webkit-overflow-scrolling: touch; overscroll-behavior: contain; padding: .12rem .18rem .2rem; scrollbar-width: thin; scrollbar-color: var(--line-3) transparent; }
|
.lo-detail__body { flex: 1 1 auto; min-height: 0; overflow: auto; -webkit-overflow-scrolling: touch; overscroll-behavior: contain; padding: .12rem .18rem .2rem; scrollbar-width: thin; scrollbar-color: var(--line-3) transparent; }
|
||||||
|
.lo-garrisons { margin-bottom: .18rem; }
|
||||||
|
.lo-garrisons .dgarrison__k { font-size: max(.14rem, 11px); }
|
||||||
|
.lo-garrisons .dgarrison__type { font-size: max(.12rem, 10px); }
|
||||||
|
.lo-garrisons .dgarrison__icon { width: max(.18rem, 14px); height: max(.18rem, 14px); }
|
||||||
|
.lo-garrisons .dgarrison__text { font-size: max(.15rem, 11px); }
|
||||||
.lo-sec { display: flex; flex-direction: column; gap: .08rem; }
|
.lo-sec { display: flex; flex-direction: column; gap: .08rem; }
|
||||||
.lo-sec + .lo-sec { margin-top: .18rem; }
|
.lo-sec + .lo-sec { margin-top: .18rem; }
|
||||||
.lo-sec__head { display: flex; align-items: center; gap: .12rem; min-height: max(.36rem, 22px); }
|
.lo-sec__head { display: flex; align-items: center; gap: .12rem; min-height: max(.36rem, 22px); }
|
||||||
|
|||||||
+7
-1
@@ -1055,5 +1055,11 @@
|
|||||||
"选择「{name}」的朝向": "Choose {name}'s facing",
|
"选择「{name}」的朝向": "Choose {name}'s facing",
|
||||||
"选择朝向": "Choose a facing",
|
"选择朝向": "Choose a facing",
|
||||||
"朝向:{dir}": "Facing: {dir}",
|
"朝向:{dir}": "Facing: {dir}",
|
||||||
"开发版 · 不稳定,请勿用于公开服务器": "Development build · unstable, not for public servers"
|
"开发版 · 不稳定,请勿用于公开服务器": "Development build · unstable, not for public servers",
|
||||||
|
"攻回": "Offensive",
|
||||||
|
"受回": "Defensive",
|
||||||
|
"自回": "Auto",
|
||||||
|
"常驻": "Always",
|
||||||
|
"瞬发": "Instant",
|
||||||
|
"部署触发": "On deployment"
|
||||||
}
|
}
|
||||||
+7
-1
@@ -1061,5 +1061,11 @@
|
|||||||
"{files} 没有下载成功,这一页无法显示;已保存的设置不受影响。请刷新页面;仍不行时,请检查广告拦截插件和网络。": "{files}をダウンロードできなかったため、このページを表示できません。保存済みの設定に影響はありません。ページを更新してください。それでも解決しない場合は、広告ブロッカーとネットワークを確認してください。",
|
"{files} 没有下载成功,这一页无法显示;已保存的设置不受影响。请刷新页面;仍不行时,请检查广告拦截插件和网络。": "{files}をダウンロードできなかったため、このページを表示できません。保存済みの設定に影響はありません。ページを更新してください。それでも解決しない場合は、広告ブロッカーとネットワークを確認してください。",
|
||||||
"全员无伤!": "全員無傷!",
|
"全员无伤!": "全員無傷!",
|
||||||
"生命值减少 −{n}": "耐久値減少 −{n}",
|
"生命值减少 −{n}": "耐久値減少 −{n}",
|
||||||
"当前语言的界面文字为机器翻译,可能不够准确,欢迎在 GitHub 上指正。": "この言語の画面表示は機械翻訳のため、不正確な場合があります。GitHubでの修正提案を歓迎します。"
|
"当前语言的界面文字为机器翻译,可能不够准确,欢迎在 GitHub 上指正。": "この言語の画面表示は機械翻訳のため、不正確な場合があります。GitHubでの修正提案を歓迎します。",
|
||||||
|
"攻回": "攻撃",
|
||||||
|
"受回": "被撃",
|
||||||
|
"自回": "自動",
|
||||||
|
"常驻": "常時",
|
||||||
|
"瞬发": "即時",
|
||||||
|
"部署触发": "配置時"
|
||||||
}
|
}
|
||||||
+7
-1
@@ -1057,5 +1057,11 @@
|
|||||||
"{files} 没有下载成功,这一页无法显示;已保存的设置不受影响。请刷新页面;仍不行时,请检查广告拦截插件和网络。": "{files} 다운로드에 실패하여 이 페이지를 표시할 수 없습니다. 저장된 설정에는 영향이 없습니다. 페이지를 새로고침해 주세요. 그래도 안 되면 광고 차단 확장 프로그램과 네트워크를 확인해 주세요.",
|
"{files} 没有下载成功,这一页无法显示;已保存的设置不受影响。请刷新页面;仍不行时,请检查广告拦截插件和网络。": "{files} 다운로드에 실패하여 이 페이지를 표시할 수 없습니다. 저장된 설정에는 영향이 없습니다. 페이지를 새로고침해 주세요. 그래도 안 되면 광고 차단 확장 프로그램과 네트워크를 확인해 주세요.",
|
||||||
"全员无伤!": "전원 무사!",
|
"全员无伤!": "전원 무사!",
|
||||||
"生命值减少 −{n}": "목표 HP 감소 −{n}",
|
"生命值减少 −{n}": "목표 HP 감소 −{n}",
|
||||||
"当前语言的界面文字为机器翻译,可能不够准确,欢迎在 GitHub 上指正。": "현재 언어의 인터페이스 텍스트는 기계 번역이므로 정확하지 않을 수 있습니다. GitHub에서 수정 제안을 환영합니다."
|
"当前语言的界面文字为机器翻译,可能不够准确,欢迎在 GitHub 上指正。": "현재 언어의 인터페이스 텍스트는 기계 번역이므로 정확하지 않을 수 있습니다. GitHub에서 수정 제안을 환영합니다.",
|
||||||
|
"攻回": "공격",
|
||||||
|
"受回": "피격",
|
||||||
|
"自回": "자동",
|
||||||
|
"常驻": "상시",
|
||||||
|
"瞬发": "즉시",
|
||||||
|
"部署触发": "배치 시"
|
||||||
}
|
}
|
||||||
@@ -1059,5 +1059,11 @@
|
|||||||
"游戏数据没有载入": "遊戲資料沒有載入",
|
"游戏数据没有载入": "遊戲資料沒有載入",
|
||||||
"{files} 没有下载成功,这一页无法显示;已保存的设置不受影响。请刷新页面;仍不行时,请检查广告拦截插件和网络。": "{files} 沒有下載成功,這一頁無法顯示;已儲存的設定不受影響。請重新整理頁面;仍不行時,請檢查廣告攔截擴充功能和網路。",
|
"{files} 没有下载成功,这一页无法显示;已保存的设置不受影响。请刷新页面;仍不行时,请检查广告拦截插件和网络。": "{files} 沒有下載成功,這一頁無法顯示;已儲存的設定不受影響。請重新整理頁面;仍不行時,請檢查廣告攔截擴充功能和網路。",
|
||||||
"全员无伤!": "全員無傷!",
|
"全员无伤!": "全員無傷!",
|
||||||
"生命值减少 −{n}": "生命值減少 −{n}"
|
"生命值减少 −{n}": "生命值減少 −{n}",
|
||||||
|
"攻回": "攻回",
|
||||||
|
"受回": "受回",
|
||||||
|
"自回": "自回",
|
||||||
|
"常驻": "常駐",
|
||||||
|
"瞬发": "瞬發",
|
||||||
|
"部署触发": "部署觸發"
|
||||||
}
|
}
|
||||||
@@ -22,7 +22,7 @@ import { useEffect, useMemo, useRef, useState } from '../../vendor/hooks.module.
|
|||||||
import { html, Icon, MicroLabel, Button, TierChip, TextField, Countdown, Spinner, confirmDialog, hasDeadline, Modal, Fragment } from '../ui/components.js';
|
import { html, Icon, MicroLabel, Button, TierChip, TextField, Countdown, Spinner, confirmDialog, hasDeadline, Modal, Fragment } from '../ui/components.js';
|
||||||
import { Img, RichText, UnitThumb } from '../ui/gameComponents.js';
|
import { Img, RichText, UnitThumb } from '../ui/gameComponents.js';
|
||||||
import { chessAvatarUrl, chessPortraitUrl, subProfIconUrl, bondIconUrl, moduleTypeIconUrl } from '../ui/assetUrls.js';
|
import { chessAvatarUrl, chessPortraitUrl, subProfIconUrl, bondIconUrl, moduleTypeIconUrl } from '../ui/assetUrls.js';
|
||||||
import { chessStatsBlock, traitText, chessTalents } from '../ui/detailPanel.js';
|
import { chessStatsBlock, traitText, chessTalents, GarrisonBlock } from '../ui/detailPanel.js';
|
||||||
import { chessLoadout } from '../ui/gameLogic.js';
|
import { chessLoadout } from '../ui/gameLogic.js';
|
||||||
import { data, useData, localAsset, DATA_FILES } from '../data.js';
|
import { data, useData, localAsset, DATA_FILES } from '../data.js';
|
||||||
import { useStore } from '../store.js';
|
import { useStore } from '../store.js';
|
||||||
@@ -172,6 +172,14 @@ function RosterCard({ m, chess, golden, entries, selected, onPick, notOwned = fa
|
|||||||
|
|
||||||
// ---- detail --------------------------------------------------------------------------------------------------------------
|
// ---- detail --------------------------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** The same attribute records and rich-text renderer as the in-match card, including literal trigger markers. */
|
||||||
|
export function LoadoutGarrisons({ chess, m }) {
|
||||||
|
const records = (chess?.garrisonIds || []).map((id) => data.lookup('garrisons', id)).filter(Boolean);
|
||||||
|
return records.length ? html`<section class="lo-garrisons" data-variant=${chess.isGolden ? 'elite' : 'normal'}>
|
||||||
|
${records.map((garrison) => html`<${GarrisonBlock} key=${garrison.garrisonId} garrison=${garrison} m=${m} />`)}
|
||||||
|
</section>` : null;
|
||||||
|
}
|
||||||
|
|
||||||
function SkillOption({ m, opt, on, level, onPick }) {
|
function SkillOption({ m, opt, on, level, onPick }) {
|
||||||
const rec = level === 'elite' ? opt.elite || opt.normal : opt.normal || opt.elite;
|
const rec = level === 'elite' ? opt.elite || opt.normal : opt.normal || opt.elite;
|
||||||
const tags = skillTags(rec);
|
const tags = skillTags(rec);
|
||||||
@@ -310,6 +318,7 @@ function Detail({ m, chess, golden, entries, onChange, onReset, locked, notOwned
|
|||||||
<${Button} variant="ghost" size="sm" icon="refresh" class="lo-dhead__reset" disabled=${!choice.changed} onClick=${onReset}>${t('恢复默认')}<//>
|
<${Button} variant="ghost" size="sm" icon="refresh" class="lo-dhead__reset" disabled=${!choice.changed} onClick=${onReset}>${t('恢复默认')}<//>
|
||||||
</div>
|
</div>
|
||||||
<div class="lo-detail__body" ref=${bodyRef}>
|
<div class="lo-detail__body" ref=${bodyRef}>
|
||||||
|
<${LoadoutGarrisons} chess=${level === 'elite' && golden ? golden : chess} m=${m} />
|
||||||
<section class="lo-sec">
|
<section class="lo-sec">
|
||||||
<header class="lo-sec__head">
|
<header class="lo-sec__head">
|
||||||
<h3>${t('技能')}<${MicroLabel}>SKILL<//></h3>
|
<h3>${t('技能')}<${MicroLabel}>SKILL<//></h3>
|
||||||
@@ -433,7 +442,7 @@ export function DataMissing({ files }) {
|
|||||||
|
|
||||||
/** The overlay screen. */
|
/** The overlay screen. */
|
||||||
function LoadoutScreen({ st }) {
|
function LoadoutScreen({ st }) {
|
||||||
const ready = useData('chess', 'bonds', 'assets', 'local', 'backups');
|
const ready = useData('chess', 'bonds', 'assets', 'local', 'backups', 'garrisons');
|
||||||
const phase = useStore((s) => s.match?.public?.phase || null);
|
const phase = useStore((s) => s.match?.public?.phase || null);
|
||||||
const inMatch = useStore((s) => !!s.room?.inMatch);
|
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
|
// co-op briefing (INFO_CHECK, 25 s): the overlay covers the briefing's own countdown, so it shows the time left — the
|
||||||
|
|||||||
@@ -315,7 +315,7 @@ export function garrisonTypeIconKey(garrison) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** The operator's own effect (特质, garrisons.json): trigger chip + description, compact. */
|
/** The operator's own effect (特质, garrisons.json): trigger chip + description, compact. */
|
||||||
function GarrisonBlock({ garrison, m }) {
|
export function GarrisonBlock({ garrison, m }) {
|
||||||
return html`<section class="dgarrison" aria-label=${t('特质')} data-garrison=${garrison.garrisonId || ''}>
|
return html`<section class="dgarrison" aria-label=${t('特质')} data-garrison=${garrison.garrisonId || ''}>
|
||||||
<div class="dgarrison__head">
|
<div class="dgarrison__head">
|
||||||
<span class="dgarrison__k">${t('特质')}</span>
|
<span class="dgarrison__k">${t('特质')}</span>
|
||||||
|
|||||||
@@ -488,3 +488,17 @@ export function skillTags(rec) {
|
|||||||
passive,
|
passive,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Compact skill preview; SP and duration come from the selected form's generated record. */
|
||||||
|
export function quickSkillTags(rec) {
|
||||||
|
const tags = skillTags(rec);
|
||||||
|
if (!rec) return { ...tags, recovery: '—', duration: '—' };
|
||||||
|
// ON_DEPLOY covers constant passives too. A finite duration distinguishes the deploy-and-expire skills;
|
||||||
|
// some older records carry it only in the blackboard. Never infer it from a localized description.
|
||||||
|
const seconds = Number(rec.duration) > 0 ? Number(rec.duration) : tags.passive ? Number(rec.bb?.duration) : 0;
|
||||||
|
const deployment = tags.passive && seconds > 0;
|
||||||
|
const recovery = deployment ? '—' : tags.passive ? t('被动') : tags.spKind === 'atk' ? t('攻回') : tags.spKind === 'def' ? t('受回') : t('自回');
|
||||||
|
const duration = rec.durationType === 'AMMO' ? t('弹药') : seconds > 0 ? `${seconds}s`
|
||||||
|
: tags.passive ? t('常驻') : Number(rec.duration) < 0 ? '∞' : t('瞬发');
|
||||||
|
return { ...tags, sp: deployment ? t('部署触发') : tags.sp, recovery, duration };
|
||||||
|
}
|
||||||
@@ -152,6 +152,7 @@ export function openLoadout(from = 'lobby', sel = null, tab = null) {
|
|||||||
data.load('local');
|
data.load('local');
|
||||||
data.load('backups');
|
data.load('backups');
|
||||||
data.load('effects'); // 0.2.2: the 练度 multipliers of the 局内数值 (effects.json CHAR_MAP)
|
data.load('effects'); // 0.2.2: the 练度 multipliers of the 局内数值 (effects.json CHAR_MAP)
|
||||||
|
data.load('garrisons'); // the 特质 at the top of the detail (PR #301)
|
||||||
loadoutStore.set({ open: true, from, ...(sel ? { sel } : {}), ...(tab === 'loadout' || tab === 'ownership' || tab === 'diy' ? { tab } : {}) });
|
loadoutStore.set({ open: true, from, ...(sel ? { sel } : {}), ...(tab === 'loadout' || tab === 'ownership' || tab === 'diy' ? { tab } : {}) });
|
||||||
}
|
}
|
||||||
export const closeLoadout = () => loadoutStore.set({ open: false });
|
export const closeLoadout = () => loadoutStore.set({ open: false });
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
// Quick loadout previews use the same generated records as the in-match detail, without changing a loadout.
|
||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { quickSkillTags } from '../../public/js/ui/loadoutModel.js';
|
||||||
|
|
||||||
|
const chess = JSON.parse(readFileSync(new URL('../../data/chess.json', import.meta.url), 'utf8'));
|
||||||
|
const operator = (name) => Object.values(chess).find((c) => c.name === name && !c.isGolden && !c.isDiy);
|
||||||
|
|
||||||
|
test('quick skill previews show the generated normal/elite SP and duration', () => {
|
||||||
|
const base = operator('空弦');
|
||||||
|
const elite = chess[base.goldenId];
|
||||||
|
for (const c of [base, elite]) {
|
||||||
|
for (const s of c.skills) {
|
||||||
|
const tags = quickSkillTags(s);
|
||||||
|
assert.equal(tags.recovery, '攻回');
|
||||||
|
assert.equal(tags.init, s.initSp);
|
||||||
|
assert.equal(tags.cost, s.spCost);
|
||||||
|
assert.equal(tags.duration, s.duration > 0 ? `${s.duration}s` : '瞬发');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deployment skills have no SP cycle, while permanent passives remain passive (in either language)', () => {
|
||||||
|
for (const name of ['缄默德克萨斯', '砾', '斯卡蒂', '野鬃']) {
|
||||||
|
const c = operator(name);
|
||||||
|
for (const form of [c, chess[c.goldenId]]) {
|
||||||
|
for (const s of form.skills.filter((s) => s.spType === 'ON_DEPLOY')) {
|
||||||
|
const tags = quickSkillTags({ ...s, desc: 'localized text', descRaw: '' });
|
||||||
|
assert.equal(tags.recovery, '—', `${name} S${s.index + 1}`);
|
||||||
|
assert.equal(tags.init, null);
|
||||||
|
assert.equal(tags.cost, null);
|
||||||
|
assert.equal(tags.duration, `${s.duration > 0 ? s.duration : s.bb.duration}s`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const passive = quickSkillTags(operator('星熊').skills[1]);
|
||||||
|
assert.equal(passive.recovery, '被动');
|
||||||
|
assert.equal(passive.duration, '常驻');
|
||||||
|
assert.equal(passive.cost, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ammo and infinite skills keep their duration semantics', () => {
|
||||||
|
assert.equal(quickSkillTags({ skillType: 'MANUAL', spType: 'INCREASE_WITH_TIME', durationType: 'AMMO' }).duration, '弹药');
|
||||||
|
assert.equal(quickSkillTags({ skillType: 'MANUAL', spType: 'INCREASE_WITH_TIME', duration: -1 }).duration, '∞');
|
||||||
|
assert.equal(quickSkillTags(null).recovery, '—');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('loadout attributes use the in-match block and keep obtain/rest triggers for both forms', async () => {
|
||||||
|
globalThis.fetch = async (url) => {
|
||||||
|
try {
|
||||||
|
const value = JSON.parse(readFileSync(new URL(`../../data/${String(url).split('/').pop()}`, import.meta.url), 'utf8'));
|
||||||
|
return { ok: true, json: async () => value };
|
||||||
|
} catch { return { ok: false, status: 404 }; }
|
||||||
|
};
|
||||||
|
const { data } = await import('../../public/js/data.js');
|
||||||
|
const { LoadoutGarrisons } = await import('../../public/js/screens/loadout.js');
|
||||||
|
const { GarrisonBlock } = await import('../../public/js/ui/detailPanel.js');
|
||||||
|
await data.loadAll('chess', 'garrisons');
|
||||||
|
function* walk(v) {
|
||||||
|
if (Array.isArray(v)) { for (const n of v) yield* walk(n); return; }
|
||||||
|
if (!v || typeof v !== 'object') return;
|
||||||
|
yield v;
|
||||||
|
yield* walk(v.props?.children);
|
||||||
|
}
|
||||||
|
for (const [name, trigger] of [['空弦', '<进入休整期时>'], ['星熊', '<获得时>'], ['缄默德克萨斯', '<进入休整期时>']]) {
|
||||||
|
const base = operator(name);
|
||||||
|
for (const rec of [base, chess[base.goldenId]]) {
|
||||||
|
const nodes = [...walk(LoadoutGarrisons({ chess: rec, m: null }))].filter((n) => n.type === GarrisonBlock);
|
||||||
|
assert.equal(nodes.length, rec.garrisonIds.length);
|
||||||
|
for (const node of nodes) {
|
||||||
|
const original = data.lookup('garrisons', rec.garrisonIds[0]);
|
||||||
|
assert.deepEqual(node.props.garrison, original);
|
||||||
|
const raw = [...walk(GarrisonBlock(node.props))].find((n) => n.type?.name === 'RichText').props.text;
|
||||||
|
assert.ok(raw.startsWith(trigger), `${name}: ${raw}`);
|
||||||
|
assert.equal(raw, original.descRaw || original.desc);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -256,7 +256,7 @@ test('place in the detail: after the skills, before the modules; the first .lo-s
|
|||||||
const mods = detail.indexOf('<section class="lo-sec lo-sec--mod">');
|
const mods = detail.indexOf('<section class="lo-sec lo-sec--mod">');
|
||||||
assert.ok(toggle > 0 && skills > toggle && stats > skills && mods > stats, 'skills (and their level toggle) → 局内数值 → modules');
|
assert.ok(toggle > 0 && skills > toggle && stats > skills && mods > stats, 'skills (and their level toggle) → 局内数值 → modules');
|
||||||
// one record resolution, shared with the card: chessLoadout, the card's block, trait and talent helpers — no stat arithmetic of its own
|
// one record resolution, shared with the card: chessLoadout, the card's block, trait and talent helpers — no stat arithmetic of its own
|
||||||
assert.match(src, /import \{ chessStatsBlock, traitText, chessTalents \} from '\.\.\/ui\/detailPanel\.js';/);
|
assert.match(src, /import \{ chessStatsBlock, traitText, chessTalents, GarrisonBlock \} from '\.\.\/ui\/detailPanel\.js';/);
|
||||||
assert.match(src, /import \{ chessLoadout \} from '\.\.\/ui\/gameLogic\.js';/);
|
assert.match(src, /import \{ chessLoadout \} from '\.\.\/ui\/gameLogic\.js';/);
|
||||||
const fn = src.slice(src.indexOf('export function statsPreview'), src.indexOf('export function LoadoutStats'));
|
const fn = src.slice(src.indexOf('export function statsPreview'), src.indexOf('export function LoadoutStats'));
|
||||||
assert.ok(!/composeStats|attr\b|maxHp|statsBase/.test(fn), 'no stat arithmetic in the screen');
|
assert.ok(!/composeStats|attr\b|maxHp|statsBase/.test(fn), 'no stat arithmetic in the screen');
|
||||||
|
|||||||
Reference in new issue
Block a user