diff --git a/docs/design/match.md b/docs/design/match.md index a374946c..e1ef1ebe 100644 --- a/docs/design/match.md +++ b/docs/design/match.md @@ -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). -**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`). **干员持有 (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. diff --git a/public/css/screens/loadout.css b/public/css/screens/loadout.css index b497800d..abff46b8 100644 --- a/public/css/screens/loadout.css +++ b/public/css/screens/loadout.css @@ -236,6 +236,11 @@ .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-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 + .lo-sec { margin-top: .18rem; } .lo-sec__head { display: flex; align-items: center; gap: .12rem; min-height: max(.36rem, 22px); } diff --git a/public/i18n/en.json b/public/i18n/en.json index bbf29fcf..2e5ee1d3 100644 --- a/public/i18n/en.json +++ b/public/i18n/en.json @@ -1055,5 +1055,11 @@ "选择「{name}」的朝向": "Choose {name}'s facing", "选择朝向": "Choose a facing", "朝向:{dir}": "Facing: {dir}", - "开发版 · 不稳定,请勿用于公开服务器": "Development build · unstable, not for public servers" + "开发版 · 不稳定,请勿用于公开服务器": "Development build · unstable, not for public servers", + "攻回": "Offensive", + "受回": "Defensive", + "自回": "Auto", + "常驻": "Always", + "瞬发": "Instant", + "部署触发": "On deployment" } diff --git a/public/i18n/ja.json b/public/i18n/ja.json index 9986bbbe..4cececc1 100644 --- a/public/i18n/ja.json +++ b/public/i18n/ja.json @@ -1061,5 +1061,11 @@ "{files} 没有下载成功,这一页无法显示;已保存的设置不受影响。请刷新页面;仍不行时,请检查广告拦截插件和网络。": "{files}をダウンロードできなかったため、このページを表示できません。保存済みの設定に影響はありません。ページを更新してください。それでも解決しない場合は、広告ブロッカーとネットワークを確認してください。", "全员无伤!": "全員無傷!", "生命值减少 −{n}": "耐久値減少 −{n}", - "当前语言的界面文字为机器翻译,可能不够准确,欢迎在 GitHub 上指正。": "この言語の画面表示は機械翻訳のため、不正確な場合があります。GitHubでの修正提案を歓迎します。" + "当前语言的界面文字为机器翻译,可能不够准确,欢迎在 GitHub 上指正。": "この言語の画面表示は機械翻訳のため、不正確な場合があります。GitHubでの修正提案を歓迎します。", + "攻回": "攻撃", + "受回": "被撃", + "自回": "自動", + "常驻": "常時", + "瞬发": "即時", + "部署触发": "配置時" } diff --git a/public/i18n/ko.json b/public/i18n/ko.json index daf09d7f..72d11a73 100644 --- a/public/i18n/ko.json +++ b/public/i18n/ko.json @@ -1057,5 +1057,11 @@ "{files} 没有下载成功,这一页无法显示;已保存的设置不受影响。请刷新页面;仍不行时,请检查广告拦截插件和网络。": "{files} 다운로드에 실패하여 이 페이지를 표시할 수 없습니다. 저장된 설정에는 영향이 없습니다. 페이지를 새로고침해 주세요. 그래도 안 되면 광고 차단 확장 프로그램과 네트워크를 확인해 주세요.", "全员无伤!": "전원 무사!", "生命值减少 −{n}": "목표 HP 감소 −{n}", - "当前语言的界面文字为机器翻译,可能不够准确,欢迎在 GitHub 上指正。": "현재 언어의 인터페이스 텍스트는 기계 번역이므로 정확하지 않을 수 있습니다. GitHub에서 수정 제안을 환영합니다." + "当前语言的界面文字为机器翻译,可能不够准确,欢迎在 GitHub 上指正。": "현재 언어의 인터페이스 텍스트는 기계 번역이므로 정확하지 않을 수 있습니다. GitHub에서 수정 제안을 환영합니다.", + "攻回": "공격", + "受回": "피격", + "自回": "자동", + "常驻": "상시", + "瞬发": "즉시", + "部署触发": "배치 시" } diff --git a/public/i18n/zh-TW.json b/public/i18n/zh-TW.json index 24d9f4e9..73effd06 100644 --- a/public/i18n/zh-TW.json +++ b/public/i18n/zh-TW.json @@ -1059,5 +1059,11 @@ "游戏数据没有载入": "遊戲資料沒有載入", "{files} 没有下载成功,这一页无法显示;已保存的设置不受影响。请刷新页面;仍不行时,请检查广告拦截插件和网络。": "{files} 沒有下載成功,這一頁無法顯示;已儲存的設定不受影響。請重新整理頁面;仍不行時,請檢查廣告攔截擴充功能和網路。", "全员无伤!": "全員無傷!", - "生命值减少 −{n}": "生命值減少 −{n}" + "生命值减少 −{n}": "生命值減少 −{n}", + "攻回": "攻回", + "受回": "受回", + "自回": "自回", + "常驻": "常駐", + "瞬发": "瞬發", + "部署触发": "部署觸發" } diff --git a/public/js/screens/loadout.js b/public/js/screens/loadout.js index ae8e3a14..6f44aaea 100644 --- a/public/js/screens/loadout.js +++ b/public/js/screens/loadout.js @@ -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 { Img, RichText, UnitThumb } from '../ui/gameComponents.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 { data, useData, localAsset, DATA_FILES } from '../data.js'; import { useStore } from '../store.js'; @@ -172,6 +172,14 @@ function RosterCard({ m, chess, golden, entries, selected, onPick, notOwned = fa // ---- 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`
+ ${records.map((garrison) => html`<${GarrisonBlock} key=${garrison.garrisonId} garrison=${garrison} m=${m} />`)} +
` : null; +} + function SkillOption({ m, opt, on, level, onPick }) { const rec = level === 'elite' ? opt.elite || opt.normal : opt.normal || opt.elite; 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('恢复默认')}
+ <${LoadoutGarrisons} chess=${level === 'elite' && golden ? golden : chess} m=${m} />

${t('技能')}<${MicroLabel}>SKILL

@@ -433,7 +442,7 @@ export function DataMissing({ files }) { /** The overlay screen. */ 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 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 diff --git a/public/js/ui/detailPanel.js b/public/js/ui/detailPanel.js index 4aae1763..d88d8d84 100644 --- a/public/js/ui/detailPanel.js +++ b/public/js/ui/detailPanel.js @@ -315,7 +315,7 @@ export function garrisonTypeIconKey(garrison) { } /** The operator's own effect (特质, garrisons.json): trigger chip + description, compact. */ -function GarrisonBlock({ garrison, m }) { +export function GarrisonBlock({ garrison, m }) { return html`
${t('特质')} diff --git a/public/js/ui/loadoutModel.js b/public/js/ui/loadoutModel.js index b11713e1..8409f6ed 100644 --- a/public/js/ui/loadoutModel.js +++ b/public/js/ui/loadoutModel.js @@ -488,3 +488,17 @@ export function skillTags(rec) { 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 }; +} diff --git a/public/js/ui/loadoutSync.js b/public/js/ui/loadoutSync.js index 325edbe2..b7cd0aa3 100644 --- a/public/js/ui/loadoutSync.js +++ b/public/js/ui/loadoutSync.js @@ -152,6 +152,7 @@ export function openLoadout(from = 'lobby', sel = null, tab = null) { data.load('local'); data.load('backups'); 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 } : {}) }); } export const closeLoadout = () => loadoutStore.set({ open: false }); diff --git a/test/ui/loadout-quick.test.js b/test/ui/loadout-quick.test.js new file mode 100644 index 00000000..a75491d3 --- /dev/null +++ b/test/ui/loadout-quick.test.js @@ -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); + } + } + } +}); diff --git a/test/ui/loadout-stats.test.js b/test/ui/loadout-stats.test.js index 6be6bf72..e5949ae1 100644 --- a/test/ui/loadout-stats.test.js +++ b/test/ui/loadout-stats.test.js @@ -256,7 +256,7 @@ test('place in the detail: after the skills, before the modules; the first .lo-s const mods = detail.indexOf('
'); 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 - 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';/); 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');