diff --git a/docs/PLAYING.md b/docs/PLAYING.md index de6c004c..45ccbb54 100644 --- a/docs/PLAYING.md +++ b/docs/PLAYING.md @@ -60,7 +60,7 @@ - **晋升精锐**:3 名同名干员(无论是否部署)自动合成**精锐**(等级、技能、模组大幅提升;风丸只需 2 名),精锐不会再合成。如果被合成的干员里有**已经部署在战场上**的,精锐会直接出现在**那名干员的位置**(保持原来的朝向,不多占部署位;有两三名都在场上时,取最先部署的那名——最左一列优先,同一列从上到下);否则精锐进入整备区(满了进入临时整备区)。原先配发的装备都退回整备区,需要重新配发;被合成干员的召唤物随之移除,场上的精锐会把自己的召唤物送进整备区。点选一张能合成的商店卡片时,精锐将出现的那一格会亮起金色边框,同时打开的详情卡里也会写明去向(「可晋升:精锐干员将出现在作战区原位置 / 将进入整备区」;详情卡会自动停靠在不遮挡这一格的一侧)。在回合开始或结算时因策略、奖励等完成的合成,精锐出现时同样会播放金色的晋升特效。 - **晋级奖励**:每次晋升后获得一次特殊招募:从 3 名 **高一阶**(最高 VI)、**互不相同**的随机干员中**免费**挑 1 名(该阶剩余干员不足 3 名时由低一阶补足)。它不能刷新、不能冻结、本回合结束失效。 - **干员持有(补位)**:大厅、房间和「确认本局信息」里的「干员调配」按钮打开的界面有两个标签页:「干员调配」选择每名干员携带的技能和精锐的模组;「**干员持有**」可以把你没有的干员**下掉**(点一下卡片切换「持有 / 未持有」)。和官方一样只有 55 张「普通」棋子能下掉(界面按阶级列出本赛季商店里的 53 名,每张写着由谁替补、用几技能);74 张「预设」棋子(多是 1–2 阶的常见干员)无论是否持有都由本人上场,不在其中。默认全部持有。下掉的干员和官方一样**显示为替补干员**:商店和晋升奖励的卡片、整备区和临时整备区里的单位、作战区、结算界面的阵容,都是替补干员的头像、名字和模型(例如银灰显示为 Sharp),带一个小小的「替补」标记;详情卡显示替补干员的立绘、名字、技能、天赋和模组,名字下面写着「银灰的替补」。**盟约、特质、阶级、价格、合成仍按原干员**(卡片和详情卡上的盟约、特质就是原干员的),作战时的属性、技能、天赋、模组、攻击范围、近战位 / 远程位都是替补干员的(例如银灰 → Sharp 的 2 技能「亮剑」)。同一名替补干员可能替补好几名干员(Sharp 替补忍冬、银灰、隐德来希等),它们按各自的原干员分别合成:看阶级和盟约区分,点开详情卡能看到替补的是谁。同盟模拟中队友查看你的整备区和作战区、你的盟约成员时,看到的也是替补干员。替补的技能和模组由棋子固定,「干员调配」对它不生效;替补干员没有召唤物(下掉缪尔赛思就没有流形)。这是**局外设置**:下一局开始时生效,对局中修改只保存到下一局;同盟模拟中只影响你自己的棋子,队友的棋子按队友的设置。没有官方的好友借用:想用本人上场,改回「持有」即可。「全部持有」一键恢复,「导出 / 导入」可以把设置带到别的浏览器;大厅的「干员调配」按钮上会显示「替补 N」提醒你有几名干员被下掉。 -- **自选编队**:同一个界面的第三个标签页「**自选编队**」有 4 个名额:5 阶两个、6 阶两个。点名额打开选择列表,每个名额可以放一名**你持有的 6★ 干员**(棋子池里没有的那些,标「已持有」:3 个技能任选其一,精锐的模组任选其一或不装备;只在集成战略或生息演算里生效的模组不能选:ISW-A、SO-A / SO-B、RA-A)或一名**原型干员**(标「原型」:Sharp、Mechanist 等 9 名 6★,5 阶另有 6 名 4★ 预备干员;技能和模组与补位时相同,不能更改)。同一名原型干员可以同时放进一个 5 阶和一个 6 阶名额;你持有的干员只能放一个名额;同一阶的两个名额不能放同一人。列表只列出已经做好战斗逻辑的干员(原型干员全部可选,持有的 6★ 随战斗逻辑完成陆续加入)。对局中,自选干员**只出现在你自己的商店里**:调度中心升到 5 级后才会刷出 5 阶名额的干员,6 级后才会刷出 6 阶的;刷新概率和同阶干员一样,每个名额有自己的库存(5 阶 8 张、6 阶 5 张,不与队友共用),价格与同阶干员相同,晋升奖励里也可能出现;效果、奖励或机变卡「获得一名随机干员」时,也可能抽到你自己的自选干员(按那个效果自己的阶级规则,不看调度中心等级)。三张合成精锐,精锐时所选模组生效(5 阶 1 级、6 阶 3 级)。自选干员**没有特质**,盟约按干员的实际阵营分配(推进之王是维多利亚,煌同时是炎和维多利亚,没有对应阵营的是协防干员),和普通棋子一样计入盟约人数;本局禁用了它全部的盟约时,它不会出现在商店里(协防不会被禁用)。商店卡片、整备区、作战区和详情卡上都标着「自选」,详情卡显示所选的技能和模组。这同样是**局外设置**:下一局开始时生效,对局中修改只保存到下一局;AI 队友不使用自选;信标不会把自选干员送给队友。「导出 / 导入」「全部清空」可用,大厅的「干员调配」按钮上会显示「自选 N」。 +- **自选编队**:同一个界面的第三个标签页「**自选编队**」有 4 个名额:5 阶两个、6 阶两个。点名额打开选择列表,每个名额可以放一名**你持有的 6★ 干员**(棋子池里没有的那些,标「已持有」:3 个技能任选其一,精锐的模组任选其一或不装备;只在集成战略或生息演算里生效的模组不能选:ISW-A、SO-A / SO-B、RA-A)或一名**原型干员**(标「原型」:Sharp、Mechanist 等 9 名 6★,5 阶另有 6 名 4★ 预备干员;技能和模组与补位时相同,不能更改)。同一名原型干员可以同时放进一个 5 阶和一个 6 阶名额;你持有的干员只能放一个名额;同一阶的两个名额不能放同一人。列表只列出已经做好战斗逻辑的干员(原型干员全部可选,持有的 6★ 随战斗逻辑完成陆续加入)。对局中,自选干员**只出现在你自己的商店里**:调度中心升到 5 级后才会刷出 5 阶名额的干员,6 级后才会刷出 6 阶的;刷新概率和同阶干员一样,每个名额有自己的库存(5 阶 8 张、6 阶 5 张,不与队友共用),价格与同阶干员相同,晋升奖励里也可能出现;效果、奖励或机变卡「获得一名随机干员」时,也可能抽到你自己的自选干员(按那个效果自己的阶级规则,不看调度中心等级)。三张合成精锐,精锐时所选模组生效(5 阶 1 级、6 阶 3 级)。自选干员**没有特质**,盟约按干员的实际阵营分配(推进之王是维多利亚,煌同时是炎和维多利亚,没有对应阵营的是协防干员),和普通棋子一样计入盟约人数;本局禁用了它全部的盟约时,它不会出现在商店里(协防不会被禁用),开局的「确认本局信息」、选策略时的「本局信息」和对局中的「本局信息」都会在「本局禁用干员」下写明是哪一名。商店卡片、整备区、作战区和详情卡上都标着「自选」,详情卡显示所选的技能和模组。这同样是**局外设置**:下一局开始时生效,对局中修改只保存到下一局;AI 队友不使用自选;信标不会把自选干员送给队友。「导出 / 导入」「全部清空」可用,大厅的「干员调配」按钮上会显示「自选 N」。 - 每回合商店会自动刷新未冻结的格子。 - **「本回合首次主动刷新」按每名干员各自计算**(拉普兰德):她在本回合遇到的第一次主动刷新生效,在整备区里也算——先刷新、再买下拉普兰德,下一次刷新她仍会叠层;同一回合内用已经生效过的拉普兰德合成的精锐,本回合不再生效。 diff --git a/public/css/screens/briefing.css b/public/css/screens/briefing.css index b1ec678a..540bb3ae 100644 --- a/public/css/screens/briefing.css +++ b/public/css/screens/briefing.css @@ -89,6 +89,10 @@ .brief-banned__n { margin-left: auto; font-size: .22rem; color: var(--red-premium); } .brief-banned__grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(.6rem, 1fr)); gap: .08rem; } .brief-banned__grid .uthumb { --ut: .6rem; } +/* 0.2.0: the player's own 自选 pieces out of the shop this match (ui/matchInfo.js DiyBannedLine; m.private.diyBanned) */ +.brief-banned__diy { margin: .1rem 0 0; font-size: .14rem; line-height: 1.5; color: var(--text-dim, #b8c2bf); } +.diybanned__tag { display: inline-block; margin-right: .06rem; padding: .01rem .06rem; font-size: .12rem; font-weight: 700; + letter-spacing: .08em; background: var(--mint-500); color: var(--text-on-mint); vertical-align: .01rem; } .brief__foot { position: relative; z-index: 2; flex: none; display: flex; align-items: center; justify-content: flex-end; gap: .4rem; padding: .18rem .5rem .3rem; } .brief-ready { display: flex; align-items: center; gap: .16rem; } diff --git a/public/css/screens/draft.css b/public/css/screens/draft.css index 17fb8e6b..31d32cc8 100644 --- a/public/css/screens/draft.css +++ b/public/css/screens/draft.css @@ -130,4 +130,5 @@ .minfo-dlg .brief-banned__n { font-size: max(.22rem, 13px); } .minfo-dlg .brief-banned__grid { grid-template-columns: repeat(auto-fill, minmax(max(.6rem, 34px), 1fr)); gap: max(.08rem, 3px); } .minfo-dlg .brief-banned__grid .uthumb { --ut: max(.6rem, 34px); } +.minfo-dlg .brief-banned__diy { font-size: max(.14rem, 12px); } .minfo-dlg .t-dim { font-size: max(.16rem, 10px); } diff --git a/public/css/screens/game-panels.css b/public/css/screens/game-panels.css index ffc75b31..4589b71f 100644 --- a/public/css/screens/game-panels.css +++ b/public/css/screens/game-panels.css @@ -370,6 +370,10 @@ button.bpop__harmony:hover { border-style: solid; color: #fff; } .ibond__ban .icon { width: .12rem; height: .12rem; } .ibanned { display: flex; flex-wrap: wrap; gap: .06rem; } .ibanned__one { padding: 0; background: none; border: 0; } +/* 0.2.0: the player's own 自选 pieces out of the shop this match (ui/matchInfo.js DiyBannedLine) */ +.idiybanned { margin: .08rem 0 0; font-size: .13rem; line-height: 1.5; } +.idiybanned .diybanned__tag { display: inline-block; margin-right: .05rem; padding: 0 .05rem; font-size: .11rem; font-weight: 700; + background: var(--mint-500); color: var(--text-on-mint); } /* ---- 机变 overlay ---- */ .spov { position: absolute; inset: 0 0 0 2.8rem; z-index: 20; } diff --git a/public/i18n/en.json b/public/i18n/en.json index 1e53a5a8..c62acc4d 100644 --- a/public/i18n/en.json +++ b/public/i18n/en.json @@ -878,6 +878,7 @@ "阶": "Tier", "自选编队:{name}(只在你的商店出现)": "Custom Squad: {name} (only in your shop)", "自选": "Custom", + "{names}的盟约本局全部禁用,不会出现在你的商店": "Every Alliance of {names} is disabled this match: not in your shop", "自选编队:所选技能与模组,没有特质,盟约按所属阵营分配": "Custom Squad: the chosen skill and module, no Attribute, Alliances by its factions", "自选编队:{name}": "Custom Squad: {name}", "替补": "Stand-in", diff --git a/public/js/screens/bandDraft.js b/public/js/screens/bandDraft.js index 0bd162a4..76a84f2f 100644 --- a/public/js/screens/bandDraft.js +++ b/public/js/screens/bandDraft.js @@ -26,6 +26,7 @@ import { MatchInfoDialog, matchInfoModel } from '../ui/matchInfo.js'; import { actions, act } from '../ui/gameActions.js'; import { normalizeDraft, sortedPlayers } from '../ui/gameLogic.js'; import { useStore } from '../store.js'; +import { data } from '../data.js'; import { audio } from '../audio.js'; import { modeOffBonds, bandOffBonds, bandOffLine } from '../ui/gameLogic.js'; @@ -242,7 +243,7 @@ export function BandDraftScreen() { const turnSecs = clock ? secondsLeft(clock.deadline) : null; const turnLen = Number(pub?.draft?.turnSeconds) > 0 ? Math.round(pub.draft.turnSeconds) : null; // 本局信息: the briefing's blocks (built only while the dialog is open) and the draft's state under it - const info = infoOpen ? matchInfoModel(pub, { bonds: gd.list('bonds'), chess: gd.chess, mode }) : null; + const info = infoOpen ? matchInfoModel(pub, { bonds: gd.list('bonds'), chess: gd.chess, mode, priv, diyData: { chess: data.get('chess'), backups: data.get('backups') } }) : null; const infoStatus = infoOpen ? draftInfoStatus({ myPick, pickName: myPick ? gd.band(myPick)?.name : null, myTurn, turnName, secs: turnSecs, waiting: !solo && !draft.done }) : null; diff --git a/public/js/screens/briefing.js b/public/js/screens/briefing.js index 819f0640..0e6ff775 100644 --- a/public/js/screens/briefing.js +++ b/public/js/screens/briefing.js @@ -2,7 +2,8 @@ // abilities), stage (+ its pool: 战场固定 / 战场随机(共N张)), 特训敌人 factions (icon, name, description), the difficulty // tag, the ready count x/N with person pips, the 准备就绪 button (g.infoReady) and the countdown. The right column — 核心盟约 // / 附加盟约 rows of bond discs (disabled bonds greyed with the banned-member badge), the legend and the 本局禁用干员 -// avatars — is ui/matchInfo.js MatchInfo, the same blocks the strategy draft's 本局信息 dialog shows (screens/bandDraft.js). +// avatars, with the player's own 自选 pieces out of the shop (m.private.diyBanned) — is ui/matchInfo.js MatchInfo, the same +// blocks the strategy draft's 本局信息 dialog shows (screens/bandDraft.js). import { useState } from '../../vendor/hooks.module.js'; import { html, Button, Icon, MicroLabel } from '../ui/components.js'; @@ -14,12 +15,14 @@ import { actions } from '../ui/gameActions.js'; import { factionTypes, sortedPlayers, phaseTotalSeconds } from '../ui/gameLogic.js'; import { enemyIconUrl, factionIconUrl } from '../ui/assetUrls.js'; import { useStore } from '../store.js'; +import { data } from '../data.js'; const cx = (...p) => p.flat().filter(Boolean).join(' '); /** INFO_CHECK screen. */ export function BriefingScreen() { const pub = useStore((s) => s.match.public); + const priv = useStore((s) => s.match.private); const myId = useStore((s) => s.me.playerId); const solo = useStore((s) => s.room?.mode === 'solo') || String(pub?.modeId || '').includes('single'); const gd = useGameData(); @@ -31,7 +34,8 @@ export function BriefingScreen() { const me = players.find((p) => p.playerId === myId); const readyN = players.filter((p) => p.ready).length; const mode = gd.config?.modes?.[pub.modeId]; - const info = matchInfoModel(pub, { bonds: gd.list('bonds'), chess: gd.chess, mode }); + // (+ the own 自选 pieces this match leaves out of the shop: m.private.diyBanned) + const info = matchInfoModel(pub, { bonds: gd.list('bonds'), chess: gd.chess, mode, priv, diyData: { chess: data.get('chess'), backups: data.get('backups') } }); const boss = pub.bossId ? gd.boss(pub.bossId) : null; const bossEnemy = boss ? gd.enemy(boss.enemyKey) : null; const stage = pub.stageId ? gd.stage(pub.stageId) : null; diff --git a/public/js/ui/enemyDrawer.js b/public/js/ui/enemyDrawer.js index c5055374..810f99e3 100644 --- a/public/js/ui/enemyDrawer.js +++ b/public/js/ui/enemyDrawer.js @@ -10,7 +10,7 @@ import { html, Icon, Tabs, MicroLabel } from './components.js'; import { Img, UnitThumb, BondGlyph, BandIcon, RichText, GIcon } from './gameComponents.js'; import { groupEnemies, factionTypes, briefingBondTip } from './gameLogic.js'; -import { matchInfoModel } from './matchInfo.js'; +import { matchInfoModel, DiyBannedLine } from './matchInfo.js'; import { factionIconUrl } from './assetUrls.js'; import { data } from '../data.js'; @@ -60,9 +60,12 @@ function EnemiesTab({ pub, priv, onEnemy }) { } function InfoTab({ pub, priv, onChess, bandId = null, bandOwner = null }) { - const { bonds, banned, perBond, stateOf } = matchInfoModel(pub, { + const model = matchInfoModel(pub, { bonds: data.list('bonds'), chess: (id) => data.lookup('chess', id), mode: data.get('config')?.modes?.[pub?.modeId], + // the own 自选 pieces this match leaves out of the shop (m.private.diyBanned) — not while scouting a teammate + priv: bandOwner ? null : priv, diyData: { chess: data.get('chess'), backups: data.get('backups') }, }); + const { bonds, banned, perBond, stateOf } = model; const disabled = new Set(bonds.filter((b) => stateOf(b.bondId)).map((b) => b.bondId)); // while scouting a teammate's prep board the drawer shows THEIR 策略 in place of one's own (user playtest #2 item 2) const band = (bandId || priv?.bandId) ? data.lookup('bands', bandId || priv.bandId) : null; @@ -85,6 +88,7 @@ function InfoTab({ pub, priv, onChess, bandId = null, bandOwner = null }) { ${banned.length ? html`
${t('自选')} + ${t('{names}的盟约本局全部禁用,不会出现在你的商店', { names: list.map((x) => dn(x.name)) })}
`; +} + /** * One row of bond discs (核心盟约 / 附加盟约): a greyed disc (✕) for a bond in the drawn set D or one the mode never * activates, the red badge with its banned members, a red ring on an enabled bond that lost members; the tooltip says @@ -97,6 +115,7 @@ export function BannedOperators({ model }) { ${banned.length ? html`本局没有禁用干员
`} + <${DiyBannedLine} model=${model} /> `; } diff --git a/test/ui/feedback5-we2-diybanned.test.js b/test/ui/feedback5-we2-diybanned.test.js new file mode 100644 index 00000000..fb012891 --- /dev/null +++ b/test/ui/feedback5-we2-diybanned.test.js @@ -0,0 +1,70 @@ +// test/ui/feedback5-we2-diybanned.test.js — 0.2.0 WE2 #11: the player's own 自选 pieces left out of the shop because every +// bond of the operator is switched off this match (m.private.diyBanned) are named under 本局禁用干员 — the briefing, the +// strategy draft's 本局信息 dialog (ui/matchInfo.js) and the in-game 本局信息 tab (ui/enemyDrawer.js). +// Run: node --test test/ui/feedback5-we2-diybanned.test.js + +import { test } 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 G = await import('../../public/js/ui/gameLogic.js'); +const { matchInfoModel, DiyBannedLine } = await import('../../public/js/ui/matchInfo.js'); +const { data } = await import('../../public/js/data.js'); +const { setLang, addMessages } = await import('../../shared/i18n.js'); +const { DATA } = await import('../match/harness.js'); +await data.loadAll('chess', 'bonds', 'assets', 'backups'); + +const T5A = 'chess_char_5_diy1_a'; +const T6A = 'chess_char_6_diy1_a'; +const SIEGE = 'char_112_siege'; +const LING = 'char_2023_ling'; +const D = { chess: DATA.chess, backups: DATA.backups }; +const getChess = (id) => (Object.hasOwn(DATA.chess, id) ? DATA.chess[id] : null); +const priv = { + diy: { [T5A]: { charId: SIEGE, skillIndex: 2, uniEquipId: 'uniequip_002_siege' }, [T6A]: { charId: LING, skillIndex: 0, uniEquipId: null } }, + diyBanned: [T6A], +}; +const textOf = (v) => (Array.isArray(v) ? v.map(textOf).join('') : v == null || typeof v === 'boolean' ? '' : typeof v === 'object' ? textOf(v.props?.children) : String(v)); + +test('#11 diyBannedPieces: the banned slots with their operator\'s name, slot order; none without picks', () => { + assert.deepEqual(G.diyBannedPieces(priv, getChess, D), [{ slotId: T6A, charId: LING, name: DATA.backups.units[LING].name }]); + assert.deepEqual(G.diyBannedPieces({ diy: priv.diy, diyBanned: [] }, getChess, D), []); + assert.deepEqual(G.diyBannedPieces({ diyBanned: [T6A] }, getChess, D), [], 'no pick for the slot: nothing'); + assert.deepEqual(G.diyBannedPieces(null, getChess, D), []); +}); + +test('#11 the match-info model and its line: the own banned 自选 piece is named; nothing without priv / diyBanned', () => { + const pub = { bannedChess: [], drawnDisabledBonds: [], modeId: 'mode_multi_hard' }; + const model = matchInfoModel(pub, { bonds: [], chess: getChess, priv, diyData: D }); + assert.deepEqual(model.diyBanned.map((x) => x.slotId), [T6A]); + setLang('zh'); + const line = DiyBannedLine({ model }); + assert.ok(line, 'a line'); + const txt = textOf(line); + assert.ok(txt.includes('自选') && txt.includes(DATA.backups.units[LING].name) && txt.includes('不会出现在你的商店'), txt); + assert.equal(DiyBannedLine({ model: matchInfoModel(pub, { bonds: [], chess: getChess }) }), null, 'no priv: no line'); + assert.equal(DiyBannedLine({ model: matchInfoModel(pub, { bonds: [], chess: getChess, priv: { ...priv, diyBanned: [] }, diyData: D }) }), null); + // English: the msgid is in public/i18n/en.json + const en = JSON.parse(readFileSync(path.join(ROOT, 'public/i18n/en.json'), 'utf8')); + const id = '{names}的盟约本局全部禁用,不会出现在你的商店'; + assert.ok(en[id] && en[id].includes('{names}'), 'translated with its placeholder'); + addMessages('en', { [id]: en[id], 自选: en['自选'] }); + setLang('en'); + try { + const t2 = textOf(DiyBannedLine({ model })); + assert.ok(t2.includes('Custom') && t2.includes('not in your shop'), t2); + } finally { setLang('zh'); } +}); diff --git a/test/ui/match-info.test.js b/test/ui/match-info.test.js index 9e173e24..73aeccb4 100644 --- a/test/ui/match-info.test.js +++ b/test/ui/match-info.test.js @@ -193,17 +193,18 @@ test('the briefing, the strategy draft and the in-game 本局信息 tab all read const brief = read('public/js/screens/briefing.js'); assert.match(brief, /import \{ MatchInfo, matchInfoModel \} from '\.\.\/ui\/matchInfo\.js';/); assert.match(brief, /