feedback1 WE: 鸭爵 strategy enemies follow the official rules

- 圆仔 is unblockable (PRTS 天赋 "无法攻击/被阻挡"); it was blocked like any
  ground enemy because its kit only had the front guard.
- 鸭爵 runs at move speed x(1 + run.attack@move_speed) once hurt: +300 % for
  the strategy version, +400 % for the original (PRTS); it was x3 / x4.
- The swap also applies in the Final Assault and the Hidden Core (PRTS
  下半/PRTS盟约记录 鸭爵 备注 "在最终回合和隐秘核心回合中仍然生效"): any
  ground wave enemy but the leader, its parts, bounties and uncounted units
  can be swapped (the leader rounds' escorts are elites); each player of a
  boss field gets its own 0-2.
- The four swapped-in enemies cost 1 LP at the protection point
  ("但进入保护目标点将减少1点目标生命值"): build-data sets their lpr to 1
  instead of the database's roguelike 0 (Final Assault LP, detail card).
This commit is contained in:
sganggs committed 2026-10-03 01:45:54 +08:00
1 parent 7f134737ce
commit f1e7f7da1a
10 files changed
+262 -19

No files matched your search

+4 -3
View File
@@ -337,7 +337,7 @@ test('坎诺特 利滚利: leftover funds are kept; ≥ 5 left ⇒ +1 at round s
cover('band_cannot');
});
test('鸭爵 “神秘顾客”: from R5, 0–2 normal ground enemies in the last 40 % of every teammate\'s wave become 鸭爵 & co. worth 1 fund to the killer', () => {
test('鸭爵 “神秘顾客”: from R5, 0–2 ground enemies in the last 40 % of every teammate\'s wave become 鸭爵 & co. worth 1 fund to the killer', () => {
const p = DATA.bands.band_ducklord.buffs[0];
const ducks = p.bbStr.enemylist.split(',');
const normalKey = Object.values(DATA.enemies).find((e) => e.rank === 'NORMAL' && e.stats.motion === 'WALK').key;
@@ -360,8 +360,9 @@ test('鸭爵 “神秘顾客”: from R5, 0–2 normal ground enemies in the las
assert.ok(ev.spawns.some((sp) => sp.enemyKey === flyKey), 'flyers are never replaced');
}
assert.deepEqual(Object.keys(counts).map(Number).sort(), [0, 1, 2]);
// before R5, in boss rounds, or when nobody holds the band: nothing
for (const [r, kind, band] of [[4, 'normal', 'band_ducklord'], [5, 'boss', 'band_ducklord'], [5, 'normal', null]]) {
// before R5, in the 联防 phase (its enemies are leaks), or when nobody holds the band: nothing (the Final Assault and
// the Hidden Core swap too: test/match/feedback1-ducklord.test.js)
for (const [r, kind, band] of [[4, 'normal', 'band_ducklord'], [5, 'unite', 'band_ducklord'], [5, 'normal', null]]) {
h.ps('p_1').bandId = band;
m.round = r;
const ev = { input: p0.battleInput({ side: 'L', colOffset: 0 }), kind, round: r, spawns: wave() };
+20 -2
View File
@@ -1088,14 +1088,18 @@ for (const key of ['enemy_1501_demonk', 'enemy_10018_sgrobh']) {
}
for (const key of ['enemy_2001_duckmi', 'enemy_2001_duckmi_2']) {
test(`${nm(key)}: unblockable; runs ×${tb(key, 'run.attack@move_speed')} once hit`, () => {
// PRTS 鸭爵 "移动速度+400%" (run 4); the 鸭爵 strategy's version "受伤后移动速度+300%" (run 3): ×(1 + run)
test(`${nm(key)}: unblockable, no attack; runs ×${1 + tb(key, 'run.attack@move_speed')} once hit`, () => {
const h = arena();
h.step();
const e = put(h, key, [10, 7], { move: true });
const v = e.s.moveSpeed;
assert.ok(e.s.flags.unblockable);
assert.ok(e.profile.noAttack);
h.b.dealDamage(null, e, { amount: 1, type: 'true' });
approx(e.s.moveSpeed, v * tb(key, 'run.attack@move_speed'));
approx(e.s.moveSpeed, v * (1 + tb(key, 'run.attack@move_speed')));
h.b.dealDamage(null, e, { amount: 1, type: 'true' });
approx(e.s.moveSpeed, v * (1 + tb(key, 'run.attack@move_speed')), 1e-6, 'once');
});
}
@@ -1622,6 +1626,20 @@ test(`${nm('enemy_10097_crshd')} / ${nm('enemy_2085_skzjxd')}: physical / arts d
approx(h.b.dealDamage(back, y, { amount: 1000, type: 'true' }), 1000, 1e-6, 'true damage is never reduced');
});
for (const key of ['enemy_2085_skzjxd', 'enemy_2085_skzjxd_2']) {
test(`${nm(key)}: 无法攻击/被阻挡 (PRTS 天赋) — walks through a blocker, never attacks`, () => {
const h = arena({ units: [{ chessId: 't_wall', row: 10, col: 5 }], hooks: ['blocked'] });
h.step();
const e = put(h, key, [10, 7], { move: true, route: { motion: 'WALK', start: [10, 7], end: [10, 2], checkpoints: [] } });
assert.ok(e.s.flags.unblockable);
assert.ok(e.profile.noAttack);
h.runUntil(() => e.x < 4, 60);
assert.ok(e.x < 4, `passed the wall (x ${e.x})`);
assert.equal(h.hooksOf('blocked').length, 0);
assert.equal(e.stats.attacks, 0);
});
}
test(`${nm('enemy_10098_crhro')}: 重生 once after ${tb('enemy_10098_crhro', 'reborn.duration')} s at full HP`, () => {
const h = arena();
h.step();
+6 -1
View File
@@ -686,6 +686,11 @@ test('independent re-derivation of every chess and enemy stat from the raw offic
const db = new Map(raw('levels/enemydata/enemy_database.json').enemies.map((e) => [e.Key, e.Value]));
const ov = new Map((raw('levels/activities/act1autochess/level_autochess_enemy_data.json').enemyDbRefs || [])
.filter((r) => r.overwrittenData).map((r) => [r.id, r.overwrittenData]));
// the 鸭爵 strategy's swapped-in enemies cost 1 LP at the protection point (PRTS 卫戍协议:盟约 下半/PRTS盟约记录 鸭爵 备注
// "但进入保护目标点将减少1点目标生命值"; the database's lifePointReduce 0 is the roguelike rule)
const swapped = new Set(Object.values(act.effectBuffInfoDataDict).flat().filter((b) => b.key === 'round_start_all_player_change_enemy_2')
.flatMap((b) => b.blackboard.filter((kv) => kv.key === 'enemylist').flatMap((kv) => kv.valueStr.split(','))));
assert.equal(swapped.size, 4);
for (const e of Object.values(enemies)) {
const base = db.get(e.key).find((l) => l.level === 0).enemyData;
const o = ov.get(e.key);
@@ -700,7 +705,7 @@ test('independent re-derivation of every chess and enemy stat from the raw offic
maxHp: pick((x) => x.attributes?.maxHp, 0), atk: pick((x) => x.attributes?.atk, 0), def: pick((x) => x.attributes?.def, 0),
res: pick((x) => x.attributes?.magicResistance, 0), moveSpeed: pick((x) => x.attributes?.moveSpeed, 1),
bat: pick((x) => x.attributes?.baseAttackTime, 1), aspd: pick((x) => x.attributes?.attackSpeed, 100),
lpr: pick((x) => x.lifePointReduce, 1), massLevel: pick((x) => x.attributes?.massLevel, 0),
lpr: swapped.has(e.key) ? 1 : pick((x) => x.lifePointReduce, 1), massLevel: pick((x) => x.attributes?.massLevel, 0),
motion: pick((x) => x.motion, 'WALK'),
};
for (const [key, v] of Object.entries(exp)) {
+179
View File
@@ -0,0 +1,179 @@
// Player feedback after 0.1.0, workstream WE (report #7): the 鸭爵 strategy “神秘顾客” and its four enemies —
// "鸭爵策略带来的怪物圆仔错误的能被阻挡,鸭爵带来的其它几个敌人你也都复核下规则,数据对不对".
// Official: the band's enemylist names the act2 versions (enemy_*_2; enemy_database level 0) and PRTS 卫戍协议:盟约 下半/
// PRTS盟约记录 §策略 鸭爵 备注: "每回合将有0~2名敌人被替换为上述敌人之一,且上述敌人为特殊版本,相较于原版更弱(鸭爵 6000 生命,
// 受伤后移动速度+300%;高普尼克 11000 / 攻击 1600;流泪小子 4500,攻击附加3.5秒晕眩;圆仔 13000),但进入保护目标点将减少1点
// 目标生命值,且在最终回合和隐秘核心回合中仍然生效"; PRTS 圆仔 天赋 "无法攻击/被阻挡".
// Real match paths (the match harness in virtual time, real data, the real meta registry) and the real Battle.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { PHASE } from '../../shared/constants.js';
import { DATA, makeMatch, give, legalTileFor } from './harness.js';
import { FakeBattle } from './fakeBattle.js';
import { Battle } from '../../server/sim/Battle.js';
import { buildBossWave } from '../../server/match/waves.js';
const BAND = 'band_ducklord';
const PARAMS = DATA.bands[BAND].buffs.find((b) => b.key === 'round_start_all_player_change_enemy_2');
const DUCKS = PARAMS.bbStr.enemylist.split(',');
const YUANZAI = 'enemy_2085_skzjxd_2';
const DUCK = 'enemy_2001_duckmi_2';
const QUIET = { info() {}, debug() {}, warn() {}, error() {} };
const TANKS = Object.values(DATA.chess).filter((c) => c.visible && !c.isGolden && c.profession === 'TANK' && c.tier <= 2).map((c) => c.chessId).sort();
/** A real Battle of a match's battle options (the match itself runs FakeBattles in these tests). */
const realBattle = (m, opts) => new Battle({ data: m.ds, content: m.battleContent, logger: QUIET, ...opts });
/** Solo match at PREP of round `r` holding the 鸭爵 band (FakeBattle rounds before). */
function soloAt(r, seed) {
const h = makeMatch({ mode: 'solo', difficulty: 'NORMAL', seed, fake: true }).start();
h.toPrep(r, { band: BAND });
const ps = h.ps('p_0');
assert.equal(ps.bandId, BAND);
return { h, m: h.m, ps };
}
/** The player's normal-battle options until the band swapped a `key` into the real wave (the real onBattleStart path). */
function optsWith(m, ps, key, tries = 400) {
for (let i = 0; i < tries; i++) {
const opts = m._normalOpts(ps);
const spec = opts.spawns.find((s) => s.enemyKey === key);
if (spec) return { opts, spec };
}
assert.fail(`no ${key} in ${tries} battles`);
}
test('#7 圆仔 brought by 鸭爵 is never blocked: it walks past a board full of defenders (real R6 wave, real Battle)', () => {
const { h, m, ps } = soloAt(6, 7071);
h.setStage('act1autochess_m01');
for (const p of [...ps.board.values(), ...ps.hand.filter(Boolean)]) if (p.kind === 'chess') ps.returnCopies(p);
ps.board.clear();
ps.hand.fill(null);
// a defender (3 blocks) on every melee tile of the board: any blockable ground enemy meets one
let n = 0;
for (let k = 0; k < 40; k++) {
const id = TANKS.find((t) => m.pool.has(t) && m.pool.left(t) > 0);
const at = id && legalTileFor(m, ps, id);
if (!at) break;
give(m, ps, id, 'board', at);
n++;
}
assert.ok(n >= 6, `${n} defenders`);
const { opts, spec } = optsWith(m, ps, YUANZAI);
assert.deepEqual(spec.bounty, { coins: 1, ownerPlayerId: 'p_0' }, '1 fund to whoever knocks it out');
// the swapped-in 圆仔 alone, at once (its real route and round multipliers)
const b = realBattle(m, { ...opts, spawns: [{ ...spec, time: 0 }], timeLimit: 600 });
let yz = null;
let blocked = 0;
for (let t = 0; t < 600 * 30 && !b.finished; t++) {
b.step();
yz = yz || b.enemies.find((e) => e.defId === YUANZAI) || null;
if (yz && yz.alive && yz.blockedBy) blocked++;
}
assert.ok(yz, 'spawned');
assert.equal(blocked, 0, 'never blocked (ticks blocked)');
assert.ok(yz.s.flags.unblockable, 'PRTS 圆仔 天赋 "无法攻击/被阻挡"');
assert.equal(yz.stats.attacks, 0, 'never attacks');
const r = b.result();
const leaked = (r.perPlayer.p_0.leaked || []).filter((l) => l.enemyKey === YUANZAI);
assert.ok(leaked.length === 1 || !yz.alive, 'it either leaks or is knocked out on the way');
m.dispose();
});
test('#7 the four 鸭爵 enemies are the act2 versions: PRTS stats, 1 LP when they reach the protection point', () => {
const s = (k) => DATA.enemies[k].stats;
assert.deepEqual(DUCKS.slice().sort(), ['enemy_2001_duckmi_2', 'enemy_2002_bearmi_2', 'enemy_2034_sythef_2', 'enemy_2085_skzjxd_2']);
assert.equal(s('enemy_2001_duckmi_2').maxHp, 6000);
assert.equal(s('enemy_2002_bearmi_2').maxHp, 11000);
assert.equal(s('enemy_2002_bearmi_2').atk, 1600);
assert.equal(s('enemy_2034_sythef_2').maxHp, 4500);
assert.equal(DATA.enemies.enemy_2034_sythef_2.talents.bb['Combat.attack@stun'], 3.5);
assert.equal(s('enemy_2085_skzjxd_2').maxHp, 13000);
// "但进入保护目标点将减少1点目标生命值" (the database's lifePointReduce 0 is the roguelike 宝藏 rule)
for (const k of DUCKS) assert.equal(s(k).lpr, 1, k);
// the original (roguelike) versions keep the database value
for (const k of ['enemy_2001_duckmi', 'enemy_2002_bearmi', 'enemy_2034_sythef', 'enemy_2085_skzjxd']) assert.equal(s(k).lpr, 0, k);
});
test('#7 鸭爵 runs at +300 % move speed once hurt (PRTS: 受伤后移动速度+300%), never attacks, is never blocked', () => {
const { m, ps } = soloAt(5, 7072);
const { opts, spec } = optsWith(m, ps, DUCK);
const b = realBattle(m, { ...opts, spawns: [{ ...spec, time: 0 }], timeLimit: 600 });
let d = null;
for (let t = 0; t < 30 && !d; t++) { b.step(); d = b.enemies.find((e) => e.defId === DUCK) || null; }
assert.ok(d && d.s.flags.unblockable);
const v = d.s.moveSpeed;
b.dealDamage(null, d, { amount: 1, type: 'true' });
const run = DATA.enemies[DUCK].talents.bb['run.attack@move_speed'];
assert.equal(run, 3);
assert.ok(Math.abs(d.s.moveSpeed - v * (1 + run)) < 1e-9, `${d.s.moveSpeed} = ${v} × ${1 + run}`);
assert.ok(d.profile.noAttack);
m.dispose();
});
test('#7 the swap still happens in the Final Assault (escorts, never the leader or its parts), and a leaked swap costs the merged LP 1', () => {
let swapped = 0;
let lpChecked = false;
for (let seed = 1; seed <= 8 && (!swapped || !lpChecked); seed++) {
const h = makeMatch({ mode: 'solo', difficulty: 'FUNNY', seed: 7100 + seed, fake: true, script: (b) => (b.kind === 'boss' ? { bossDps: 1e9 } : {}) }).start();
const m = h.m;
h.drive(() => m.phase === PHASE.PREP && m.round === m.gd.bossRound, { band: BAND });
h.drive(() => m.phase === PHASE.FINAL_ASSAULT, { band: BAND });
const f = FakeBattle.instances.find((b) => b.kind === 'boss');
assert.ok(f, 'boss field');
const ducks = f.opts.spawns.filter((s) => DUCKS.includes(s.enemyKey));
assert.ok(ducks.length <= PARAMS.bb.max, `${ducks.length} swaps`);
assert.ok(f.opts.spawns.some((s) => s.tag === 'boss'), 'the leader stays');
for (const d of ducks) {
assert.equal(d.tag, 'duck');
assert.deepEqual(d.bounty, { coins: 1, ownerPlayerId: 'p_0' });
}
swapped += ducks.length;
if (ducks.length && !lpChecked) {
// the real boss battle (no operators): the swapped enemy walks into the goal and the team pool loses its lpr 1
const pool = { hp: 1e12, maxHp: 1e12, damage() { return 0; } };
const b = realBattle(m, { ...f.opts, sharedBoss: pool, spawns: [{ ...ducks[0], time: 0 }] });
const leaks = [];
b.on('enemyLeak', (ctx) => leaks.push(ctx.enemy));
for (let t = 0; t < 400 * 30 && !leaks.length; t++) b.step();
assert.equal(leaks.length, 1, 'leaked');
assert.equal(leaks[0].lpr, 1);
const lp = m.teamLp;
m._bossLeak(leaks[0]);
assert.equal(m.teamLp, lp - 1);
lpChecked = true;
}
m.dispose();
}
assert.ok(swapped > 0, 'some Final Assault had a swap');
assert.ok(lpChecked);
});
test('#7 the Hidden Core and pair boss fields: each player of the field gets 0–2 swaps among the escorts', () => {
const h = makeMatch({ mode: 'coop', difficulty: 'HARD', humans: 2, seed: 7201, fake: true }).start();
const m = h.m;
h.toPrep(1);
h.ps('p_0').bandId = BAND;
h.ps('p_1').bandId = 'band_bldsk';
const seen = { boss: 0, hidden: 0 };
for (const [kind, round] of [['boss', m.gd.bossRound], ['hidden', m.gd.hiddenRound]]) {
for (let k = 0; k < 40; k++) {
m.round = round;
const wave = buildBossWave(m.gd, m.rngWaves, m.factions, round, { bossId: kind === 'hidden' ? m.hiddenBossId : m.bossId, solo: false });
const spawns = wave.spawns.map((s) => ({ ...s, mods: s.mods ? { ...s.mods } : undefined }));
const before = spawns.reduce((n, s) => n + (s.count || 1), 0);
for (const [j, ps] of [h.ps('p_0'), h.ps('p_1')].entries()) {
const ev = { input: ps.battleInput({ side: j ? 'R' : 'L', colOffset: j ? 8 : 0 }), kind, round, spawns };
m.dispatch(ps, 'onBattleStart', ev);
}
const ducks = spawns.filter((s) => DUCKS.includes(s.enemyKey));
assert.ok(ducks.length <= 2 * PARAMS.bb.max, `${kind}: ${ducks.length}`);
assert.equal(spawns.reduce((n, s) => n + (s.count || 1), 0), before, 'same enemy count');
assert.ok(spawns.filter((s) => s.tag === 'boss').length >= 1);
for (const d of ducks) assert.ok(['p_0', 'p_1'].includes(d.bounty.ownerPlayerId));
seen[kind] += ducks.length;
}
}
assert.ok(seen.boss > 0 && seen.hidden > 0, JSON.stringify(seen));
m.dispose();
});