feedback1b WG: 奥术 re-measured; bonds the mode never activates read 本局禁用
Player report after 0.1.0: "奥术盟约不生效". Measured on a real 险境 battle (Match._normalOpts) and the same battle from its BattleSpec (browser path): the bond works - member arts hits debuff the target x(1.2 + 0.01*L) for 3 s, later arts hits land multiplied, the 3-member tier gives x1.4 below 50 % HP, in-battle layer gains apply live, browser = server. What a player sees instead: 标准模拟 switches 奥术 off (official inactiveBondIdList, with 9 other bonds) while 深靛 洛洛 阿罗玛 夕 圣聆初雪 stay in the pool through their other bond; their cards showed 奥术 like any bond and the detail chip read "0/2 未激活" with three deployed. Shop / reward cards, the detail card's bond chips and the bond popup now mark such a bond 本局禁用 (gameLogic modeOffBonds over config.modes[modeId].inactiveBondIds). The PRTS 盟约记录 奥术 note (one instance per unit and target) is documented next to the one-effective-instance rule it fits (SIM.md, bond header).
This commit is contained in:
1 parent
7f134737ce
commit
b4fa21a0a6
11 files changed
+295
-39
No files matched your search
@@ -0,0 +1,86 @@
|
||||
// Player report after the public 0.1.0 release (relayed, untested by the user): "奥术盟约不生效". Measured on the real
|
||||
// product path — a 险境 match's own normal battle (Match._normalOpts: the player's board, bonds, loadouts, the round's
|
||||
// wave) and the same battle built from its BattleSpec as the browser does (spec.js createBattleFromSpec) — the bond
|
||||
// works: every member's arts damage puts the 奥术 debuff on its target (×(1.2 + 0.01·L) arts damage taken for 3 s, one
|
||||
// instance per target, DESIGN §5.3 / §20.10), later arts hits land multiplied, the 3-member tier raises it ×1.4 on
|
||||
// targets below 50 % HP, and the browser battle is identical. (What a player could see instead — 标准 never activates
|
||||
// 奥术 — is test/ui/feedback1b-bonds.test.js.)
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { makeMatch, give, legalTileFor } from '../match/harness.js';
|
||||
import { tileKey } from '../../server/match/board.js';
|
||||
import { buildBattleSpec, createBattleFromSpec } from '../../server/sim/spec.js';
|
||||
import { bondBb } from '../../server/sim/content/bonds/addon/battle.js';
|
||||
|
||||
const BB = bondBb('arcaneShip');
|
||||
const MEMBERS = ['chess_char_1_17_a', 'chess_char_2_10_a', 'chess_char_3_09_a']; // 深靛, 洛洛, 海霓
|
||||
|
||||
function setup(ids, seed = 7, round = 5) {
|
||||
const h = makeMatch({ mode: 'solo', difficulty: 'NORMAL', humans: 1, seed }).start();
|
||||
h.toPrep(1);
|
||||
h.ps('p_0').lp = 9999;
|
||||
h.toPrep(round);
|
||||
const m = h.m, ps = h.ps('p_0');
|
||||
for (const p of [...ps.board.values()]) if (p.kind === 'chess') ps.returnCopies(p);
|
||||
ps.board.clear();
|
||||
const used = new Set();
|
||||
for (const id of ids) { const t = legalTileFor(m, ps, id, used); used.add(tileKey(t[0], t[1])); give(m, ps, id, 'board', t); }
|
||||
ps.recompute();
|
||||
return { m, ps };
|
||||
}
|
||||
|
||||
/** Run a battle; per arts hit of an operator: the 奥术 multiplier on the target when it landed (0 = none). */
|
||||
function run(b, pid) {
|
||||
const hits = [];
|
||||
b.on('hit', (c) => {
|
||||
if (!c.dmg || c.dmg.type !== 'arts' || !c.source || c.source.kind !== 'op') return;
|
||||
const x = c.target.buffs.find((y) => y.key === 'bond:arcaneShip');
|
||||
c.dmg.arcane = x ? x.data.value : 0;
|
||||
c.dmg.taken = c.target.s.artsTakenMul;
|
||||
});
|
||||
b.on('damaged', (c) => { if (c.type === 'arts' && c.source && c.source.kind === 'op') hits.push({ mul: c.dmg.arcane, taken: c.dmg.taken, amount: c.amount }); });
|
||||
for (let n = 0; !b.finished && n < 30 * 400; n++) b.step();
|
||||
const pp = b.result().perPlayer[pid];
|
||||
return { hits, killed: pp.killed, dealt: Math.round(pp.damageDealt) };
|
||||
}
|
||||
|
||||
test('奥术 on a real 险境 battle: two members debuff their targets and later arts hits land ×(1.2 + 0.01·L); the browser battle is identical', () => {
|
||||
const { m, ps } = setup(MEMBERS.slice(0, 2));
|
||||
assert.deepEqual(ps.bonds.arcaneShip, { count: 2, active: true, tier: 1, layers: 0 });
|
||||
for (const L of [0, 50]) {
|
||||
const opts = m._normalOpts(ps);
|
||||
opts.players[0].bonds.arcaneShip.layers = L;
|
||||
const srv = run(m.newBattle(opts), ps.playerId);
|
||||
const want = BB.base_damage_scale + BB.damage_scale_per_stack * L;
|
||||
const boosted = srv.hits.filter((x) => x.mul > 0);
|
||||
assert.ok(srv.hits.length >= 30 && boosted.length >= srv.hits.length * 0.6, `L${L}: ${boosted.length}/${srv.hits.length} arts hits on a debuffed target`);
|
||||
for (const x of boosted) {
|
||||
assert.ok(Math.abs(x.mul - want) < 1e-9, `L${L}: debuff ×${x.mul}, want ×${want}`);
|
||||
assert.ok(x.taken >= want - 1e-9, `L${L}: the target took arts ×${x.taken}`);
|
||||
}
|
||||
// the browser path: the same spec, the same battle
|
||||
const spec = buildBattleSpec({ ...opts, stageId: m.stageId, battleId: 't', boss: null });
|
||||
const cli = run(createBattleFromSpec(spec, m.ds), ps.playerId);
|
||||
assert.deepEqual([cli.killed, cli.dealt, cli.hits.length], [srv.killed, srv.dealt, srv.hits.length], `L${L}: browser = server`);
|
||||
// without the bond the same battle has no debuffed hit
|
||||
const offOpts = m._normalOpts(ps);
|
||||
offOpts.players[0].bonds.arcaneShip = { ...offOpts.players[0].bonds.arcaneShip, active: false, tier: 0 };
|
||||
assert.equal(run(m.newBattle(offOpts), ps.playerId).hits.filter((x) => x.mul > 0).length, 0);
|
||||
}
|
||||
m.dispose();
|
||||
});
|
||||
|
||||
test('奥术 with 3 different members: targets below 50 % HP take ×1.4·(1.2 + 0.01·L) (+68 % at 0 layers); 海霓 adds layers live', () => {
|
||||
const { m, ps } = setup(MEMBERS);
|
||||
assert.equal(ps.bonds.arcaneShip.tier, 2);
|
||||
const res = run(m.newBattle(m._normalOpts(ps)), ps.playerId);
|
||||
const base = (L) => BB.base_damage_scale + BB.damage_scale_per_stack * L;
|
||||
const seen = res.hits.filter((x) => x.mul > 0).map((x) => x.mul);
|
||||
// L = 0 at the start; 海霓 "<战斗中>首次击倒敌人…【奥术】层数+3" raises it during the fight (bonds/addon live layers)
|
||||
const match = (v) => [0, 3].flatMap((L) => [['base', L, base(L)], ['low', L, BB.power_weak_scale * base(L)]]).find(([, , w]) => Math.abs(v - w) < 1e-9);
|
||||
for (const v of seen) assert.ok(match(v), `unexpected multiplier ×${v}`);
|
||||
const kinds = new Set(seen.map((v) => match(v).slice(0, 2).join(':')));
|
||||
for (const k of ['base:0', 'low:0', 'base:3', 'low:3']) assert.ok(kinds.has(k), `${k} seen (${[...kinds]})`);
|
||||
assert.ok(Math.abs(BB.power_weak_scale * base(0) - 1 - BB.base_damage_scale_show_ex) < 1e-9, 'the shown +68 %');
|
||||
m.dispose();
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
// Player report after the public 0.1.0 release (relayed, untested by the user): "奥术盟约不生效".
|
||||
// The bond works in battle (test/sim/feedback1b-arcane.test.js). What a player can see instead: 标准模拟 (FUNNY) is one
|
||||
// of the modes whose official modeDataDict inactiveBondIdList switches 奥术 off (with 拉特兰 阿戈尔 卡西米尔 灵巧 奇迹 投资人
|
||||
// 突袭 独行 绝技), yet 深靛 洛洛 阿罗玛 夕 圣聆初雪 stay in the pool through their other bond (server pool.js) and their cards
|
||||
// showed 奥术 like any bond: the detail card's chip read "奥术 0/2 未激活" with three 奥术 operators deployed (the server
|
||||
// leaves an inactive bond out of m.private.bonds) and the bond popup "在场 0/2 未激活". The cards, chips and popup now say
|
||||
// 本局禁用 for a bond the mode never activates (gameLogic modeOffBonds over config.modes[modeId].inactiveBondIds).
|
||||
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)), '../..');
|
||||
const read = (p) => readFileSync(path.join(ROOT, p), 'utf8');
|
||||
|
||||
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 { modeOffBonds } = await import('../../public/js/ui/gameLogic.js');
|
||||
const { BondChips } = await import('../../public/js/ui/detailPanel.js');
|
||||
const { ChessCard } = await import('../../public/js/ui/shopBar.js');
|
||||
const { BondPopup } = await import('../../public/js/ui/bondStrip.js');
|
||||
const { data, getMode } = await import('../../public/js/data.js');
|
||||
const { DATA, makeMatch, give, legalTileFor } = await import('../match/harness.js');
|
||||
const { tileKey } = await import('../../server/match/board.js');
|
||||
|
||||
await data.loadAll('chess', 'bonds', 'assets', 'config');
|
||||
|
||||
function* walk(v) {
|
||||
if (Array.isArray(v)) { for (const x of v) yield* walk(x); return; }
|
||||
if (!v || typeof v !== 'object') return;
|
||||
yield v;
|
||||
if (typeof v.type === 'function' && !v.props?.children) return;
|
||||
yield* walk(v.props?.children);
|
||||
}
|
||||
const hasClass = (v, c) => typeof v?.props?.class === 'string' && v.props.class.split(/\s+/).includes(c);
|
||||
const textOf = (v) => [...walk(v)].flatMap((n) => (Array.isArray(n.props?.children) ? n.props.children : [n.props?.children])).filter((x) => typeof x === 'string' || typeof x === 'number').join('');
|
||||
const chipOf = (vnode, bondId) => [...walk(vnode)].find((v) => v.props?.['data-bond'] === bondId);
|
||||
|
||||
const OFF_FUNNY = ['lateranoShip', 'egirShip', 'kazimierzShip', 'skillfulShip', 'arcaneShip', 'miraShip', 'investShip', 'raidShip', 'soloShip', 'suntShip'];
|
||||
|
||||
test('modeOffBonds: 标准 (single and multi) switches off the 10 official inactive bonds, 奥术 included; 险境+ none', () => {
|
||||
for (const id of ['mode_single_funny', 'mode_multi_funny']) assert.deepEqual([...modeOffBonds(getMode(id))].sort(), [...OFF_FUNNY].sort(), id);
|
||||
for (const id of ['mode_single_normal', 'mode_multi_hard', 'mode_single_abyss']) assert.equal(modeOffBonds(getMode(id)).size, 0, id);
|
||||
assert.equal(modeOffBonds(null).size, 0);
|
||||
});
|
||||
|
||||
test('a real 标准 match: 奥术 operators are buyable, three on the board never activate the bond — and the card chips say so', () => {
|
||||
const h = makeMatch({ mode: 'solo', difficulty: 'FUNNY', humans: 1, seed: 3 }).start();
|
||||
h.toPrep(1);
|
||||
const m = h.m, ps = h.ps('p_0');
|
||||
const members = DATA.bonds.arcaneShip.visibleMembers.filter((id) => m.pool.has(id) && !m.bannedChess.includes(id));
|
||||
assert.deepEqual(members.map((id) => DATA.chess[id].name), ['深靛', '洛洛', '阿罗玛', '夕', '圣聆初雪'], 'members kept in the pool by their other bond');
|
||||
ps.board.clear();
|
||||
const used = new Set();
|
||||
for (const id of members.slice(0, 3)) { const t = legalTileFor(m, ps, id, used); used.add(tileKey(t[0], t[1])); give(m, ps, id, 'board', t); }
|
||||
ps.recompute();
|
||||
assert.equal(ps.bonds.arcaneShip, undefined, 'official: the mode never activates 奥术');
|
||||
const priv = ps.privateView();
|
||||
assert.equal(priv.bonds.find((b) => b.bondId === 'arcaneShip'), undefined, 'no 奥术 entry in m.private.bonds');
|
||||
const off = modeOffBonds(getMode(m.modeId));
|
||||
// before: the chip computed 0 members from the missing entry — "奥术:在场 0/2,未激活" with 3 deployed
|
||||
const before = chipOf(BondChips({ bondIds: DATA.chess[members[0]].bonds, bonds: priv.bonds }), 'arcaneShip');
|
||||
assert.match(before.props.title, /在场 0\/2,未激活/);
|
||||
const chip = chipOf(BondChips({ bondIds: DATA.chess[members[0]].bonds, bonds: priv.bonds, off }), 'arcaneShip');
|
||||
assert.ok(hasClass(chip, 'is-off') && !hasClass(chip, 'is-active'));
|
||||
assert.equal(chip.props.title, '奥术:本局禁用(该盟约不会激活)');
|
||||
assert.match(textOf(chip), /本局禁用/);
|
||||
assert.ok(![...walk(chip)].some((v) => hasClass(v, 'dbond__count')), 'no member count');
|
||||
// the operator's enabled bond (精准) keeps its normal chip
|
||||
const preci = chipOf(BondChips({ bondIds: DATA.chess[members[0]].bonds, bonds: priv.bonds, off }), 'preciShip');
|
||||
assert.ok(!hasClass(preci, 'is-off'));
|
||||
m.dispose();
|
||||
});
|
||||
|
||||
test('shop / reward card: a mode-disabled bond tag is struck through with the 本局禁用 title; others unchanged', () => {
|
||||
const id = 'chess_char_1_17_a'; // 深靛: 奥术 / 精准
|
||||
const slot = { kind: 'chess', id, price: 1, basePrice: 1 };
|
||||
const tags = (offBonds) => [...walk(ChessCard({ slot, idx: 0, priv: { funds: 9, hand: [], board: [], temp: [] }, onBuy() {}, onDetail() {}, offBonds }))].filter((v) => hasClass(v, 'scard__bond'));
|
||||
const on = tags(modeOffBonds(getMode('mode_single_funny')));
|
||||
assert.equal(on.length, 2);
|
||||
assert.ok(hasClass(on[0], 'is-off'), '奥术 struck');
|
||||
assert.equal(on[0].props.title, '奥术:本局禁用(该盟约不会激活)');
|
||||
assert.ok(!hasClass(on[1], 'is-off'), '精准 enabled');
|
||||
assert.ok(tags(modeOffBonds(getMode('mode_single_normal'))).every((v) => !hasClass(v, 'is-off')), '险境: nothing off');
|
||||
assert.ok(tags(null).every((v) => !hasClass(v, 'is-off')), 'no set: as before');
|
||||
});
|
||||
|
||||
test('bond popup: 本局禁用 instead of 未激活, a note, and no misleading member count', () => {
|
||||
const pop = BondPopup({ bondId: 'arcaneShip', entry: null, priv: { board: [], hand: [] }, onClose() {}, off: true });
|
||||
const text = textOf(pop);
|
||||
assert.match(text, /本局禁用/);
|
||||
assert.match(text, /本模式下该盟约不会激活/);
|
||||
assert.doesNotMatch(text, /在场/);
|
||||
const plain = textOf(BondPopup({ bondId: 'arcaneShip', entry: null, priv: { board: [], hand: [] }, onClose() {} }));
|
||||
assert.match(plain, /未激活/);
|
||||
assert.match(plain, /在场/);
|
||||
});
|
||||
|
||||
test('the game screen hands the mode-disabled set to the shop bar, the detail card and the bond popup', () => {
|
||||
const src = read('public/js/screens/game.js');
|
||||
assert.match(src, /const offBonds = modeOffBonds\(getMode\(pub\?\.modeId\)\)/);
|
||||
assert.match(src, /<\$\{ShopBar\}[\s\S]*?offBonds=\$\{offBonds\}/);
|
||||
assert.match(src, /<\$\{DetailPanel\}[\s\S]*?offBonds=\$\{offBonds\}/);
|
||||
assert.match(src, /<\$\{BondPopup\}[\s\S]*?off=\$\{offBonds\.has\(bondPop\.bondId\)\}/);
|
||||
});
|
||||
Reference in new issue
Block a user