Files
ark-stronghold-protocol/test/sim/playtest5_blocking.test.js
sganggs 6464e4b46a Playtest #6 fixes: summons placed by hand, boss pool stall, bounties, 联防 leak counter, distinct offers, 机变 card text, phone buy tap, skill triggers, blocking, push force, 孤立, enemies, element bar
- #1 #2 skill / device summons (赫默's drone, 巫恋's doll, 凯瑟琳's devices) are hand cards placed by the player;
  placed summons deploy once at battle start, then a skill summon re-appears at its tile on each cast (the user's call)
- #3 叙拉古 隐匿 ends on time (32 + 0.4 × layers s); 忍冬 S3 迷彩 lasts until her next cast (official), kept apart from 隐匿
- #4 multi-round bounty cards last two battles (the user's call; MULTI_ROUND_BOUNTY_BATTLES), blue / red card text
- #5 the shared boss pool's float dust (≈3.6e-12 HP) stalled Final Assault leaders at 0 %; < 1 HP now clears the pool,
  and the team LP reaching 0 first is a defeat (PRTS)
- #6 #10 机变 cards show their official effect text; an invisible 详情 button no longer swallows the confirming tap on phones
- #7 live ×N counter of a leaker's enemies still on the 联防 field (LP loss stays capped at 10)
- #8 #15 #16 skill triggers per 技能策略: skill-range triggers, injured-ally heals (古米, 塞雷娅), tank take-damage
  triggers with a 3 s cooldown; 新约能天使 S2 shields the highest-aggro operator in range
- #9 official per-enemy model scales (威龙)
- #11 element bar under the unit's HP bar (white = 元素值 left, element-coloured refill after a burst), floor rounding
- #12 枯朽战车 kit per PRTS; every ranged enemy reaches its target's 0.25 collider
- #13 恐惧 wander (萨科塔之翼 at half HP, operator fears make enemies flee) with re-planning after pushes
- #14 push / pull distance by 力度 − 重量 (PRTS table); #17 every blocker attacks what it blocks; ASPD floor 20
- #18 炎佑 is 孤立: no heals or ally buffs
- #19 every free pick-one chess offer (promotion reward, 信标, 寻呼模块) shows different operators
- DESIGN §20 and the normative lines, SIM / META / DATA / PLAYING updated; 2747 unit tests and all 27 browser suites
  green; dev recordings regenerated
2026-10-01 19:08:23 +08:00

276 lines
16 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// User playtest #5 item 4 — "如果敌人被卡在两个干员之间的话,靠后的干员死亡敌人会直接走掉,而不是换成靠前面的干员接替阻挡".
//
// Official rule (PRTS 游戏数据基础 §阻挡半径, 作战机制 §碰撞体积与位置识别 "中点判定 … 案例: 阻挡"): an unblocked ground
// enemy whose position lies within the ground block radius 0.7071 (squared 0.49999037) of a ground operator's centre is
// blocked by it while the operator has free block capacity — checked continuously, so an enemy that overlaps an
// operator when its blocker dies (or when the operator's capacity frees up) is taken over at once. Flying enemies:
// air block radius 0.8944 (起飞 / blockFly units); devices (阻隔工事 / 障碍物): 0.4472.
//
// Flat stage: lower-gate enemies walk row 9 from the gate (9,10) to the goal (9,2), i.e. towards smaller columns.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { makeBattle, chessRec, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
import { BLOCK_RADIUS } from '../../server/sim/constants.js';
import { getDefaultSource } from '../../server/sim/simdata.js';
const FRONT = 'test_front_a', BACK = 'test_back_a';
const op = (id, blockCnt = 1) => chessRec({ id, stats: { atk: 0, blockCnt, maxHp: 1e6, respawnTime: 999 }, skill: null });
const foe = (key, o = {}) => enemyRec({ key, hp: 1e7, speed: 1, ...o });
function lane(units, enemies, defs = {}) {
return makeBattle({
defs: {
chess: { [FRONT]: op(FRONT, defs.frontBlock ?? 1), [BACK]: op(BACK, defs.backBlock ?? 1), ...(defs.chess || {}) },
enemies: { enemy_x: foe('enemy_x'), enemy_e: foe('enemy_e'), enemy_heavy: foe('enemy_heavy', { blockCnt: 2 }), ...(defs.enemies || {}) },
},
units, enemies, timeLimit: 200, content: 'none',
});
}
const kill = (h, u) => h.b.loseHp(u, 1e12);
test('the official block radii are the PRTS numbers', () => {
assert.ok(Math.abs(BLOCK_RADIUS.ground - 0.7071) < 1e-3);
assert.ok(Math.abs(BLOCK_RADIUS.fly - 0.8944) < 1e-3);
assert.ok(Math.abs(BLOCK_RADIUS.device - 0.4472) < 1e-3);
});
test('#4: the enemy between two operators is taken over by the front one when its blocker (the rear one) dies', () => {
// front (9,6) blocks X; E walks through the full front operator and is blocked by the rear one (9,5) — it now
// overlaps both. X dies (front has room again), then the rear blocker dies: E must stay, blocked by the front one.
const h = lane([{ chessId: FRONT, row: 9, col: 6 }, { chessId: BACK, row: 9, col: 5 }],
[{ key: 'enemy_x', time: 0 }, { key: 'enemy_e', time: 1 }]);
const front = h.unit(FRONT), back = h.unit(BACK);
const x = () => h.b.enemies.find((e) => e.defId === 'enemy_x');
const e = () => h.b.enemies.find((q) => q.defId === 'enemy_e');
assert.ok(h.runUntil(() => x()?.blockedBy === front && e()?.blockedBy === back, 30), 'X blocked by the front, E by the rear operator');
const ex = e().x;
assert.ok(Math.hypot(ex - 6, e().y - 9) < BLOCK_RADIUS.ground, `E overlaps the front operator too (x=${ex.toFixed(3)})`);
kill(h, x());
h.step(2);
assert.equal(e().blockedBy, back, 'E stays with its blocker while the rear operator lives');
kill(h, back);
h.step(2);
assert.equal(e().blockedBy, front, 'the front operator takes E over');
h.run(5);
assert.equal(e().blockedBy, front);
assert.ok(Math.abs(e().x - ex) < 1e-6, 'E did not walk on');
checkInvariants(h.b);
});
test('#4: a withdrawn or stunned blocker hands its enemy to the operator it still touches', () => {
for (const how of ['retreat', 'stun']) {
const h = lane([{ chessId: FRONT, row: 9, col: 6 }, { chessId: BACK, row: 9, col: 5 }],
[{ key: 'enemy_x', time: 0 }, { key: 'enemy_e', time: 1 }]);
const front = h.unit(FRONT), back = h.unit(BACK);
const x = () => h.b.enemies.find((e) => e.defId === 'enemy_x');
const e = () => h.b.enemies.find((q) => q.defId === 'enemy_e');
assert.ok(h.runUntil(() => x()?.blockedBy === front && e()?.blockedBy === back, 30));
kill(h, x());
if (how === 'retreat') h.b.retreat(back); else h.b.applyStatus(back, 'stun', { duration: 5 });
h.step(2);
assert.equal(e().blockedBy, front, how);
checkInvariants(h.b);
}
});
test('#4: the rear blocker dies while the front operator is still full — the enemy walks on (no room)', () => {
const h = lane([{ chessId: FRONT, row: 9, col: 6 }, { chessId: BACK, row: 9, col: 5 }],
[{ key: 'enemy_x', time: 0 }, { key: 'enemy_e', time: 1 }]);
const front = h.unit(FRONT), back = h.unit(BACK);
const x = () => h.b.enemies.find((e) => e.defId === 'enemy_x');
const e = () => h.b.enemies.find((q) => q.defId === 'enemy_e');
assert.ok(h.runUntil(() => x()?.blockedBy === front && e()?.blockedBy === back, 30));
kill(h, back);
h.step(2);
assert.equal(e().blockedBy, null, 'no room at the front → E walks on');
const x0 = e().x;
h.run(1);
assert.ok(e().x < x0 - 0.2, 'E is walking again');
checkInvariants(h.b);
});
test('#4: an operator whose capacity frees up blocks an enemy that still overlaps it (past its tile, inside 0.7071)', () => {
const h = lane([{ chessId: FRONT, row: 9, col: 6 }], [{ key: 'enemy_x', time: 0 }, { key: 'enemy_e', time: 1 }]);
const front = h.unit(FRONT);
const x = () => h.b.enemies.find((e) => e.defId === 'enemy_x');
const e = () => h.b.enemies.find((q) => q.defId === 'enemy_e');
assert.ok(h.runUntil(() => x()?.blockedBy === front, 30));
// E walks through the full operator; free the capacity once E has left the operator's tile (x < 5.5) but is still
// within the block radius
assert.ok(h.runUntil(() => e() && e().x < 5.42, 30));
assert.equal(e().blockedBy, null, 'passing through while full');
kill(h, x());
h.step(1);
assert.equal(e().blockedBy, front, 'taken as soon as the operator has room');
checkInvariants(h.b);
});
test('#4: pass-through when full — an enemy that never finds room walks past the operator', () => {
const h = lane([{ chessId: FRONT, row: 9, col: 6 }], [{ key: 'enemy_x', time: 0 }, { key: 'enemy_e', time: 1 }]);
const front = h.unit(FRONT);
const e = () => h.b.enemies.find((q) => q.defId === 'enemy_e');
assert.ok(h.runUntil(() => e() && e().x < 4.5, 40), 'E walked past the full operator');
assert.equal(e().blockedBy, null);
assert.equal(front.blocking.length, 1);
checkInvariants(h.b);
});
test('#4: contact happens at the block radius (the enemy stops ~0.71 tile from the operator, outside its tile)', () => {
const h = lane([{ chessId: FRONT, row: 9, col: 6 }], [{ key: 'enemy_x', time: 0 }]);
const front = h.unit(FRONT);
const x = () => h.b.enemies.find((e) => e.defId === 'enemy_x');
assert.ok(h.runUntil(() => x()?.blockedBy === front, 30));
const d = Math.hypot(x().x - 6, x().y - 9);
assert.ok(d <= BLOCK_RADIUS.ground + 1e-9 && d > BLOCK_RADIUS.ground - 0.05, `blocked at contact (d=${d.toFixed(3)})`);
checkInvariants(h.b);
});
test('#4: an operator beside the lane (one row off) never touches the enemies walking it', () => {
const h = lane([{ chessId: FRONT, row: 10, col: 6 }], [{ key: 'enemy_x', time: 0 }]);
const x = () => h.b.enemies.find((e) => e.defId === 'enemy_x');
assert.ok(h.runUntil(() => x() && x().x < 4, 40));
assert.equal(h.hooksOf('blocked').length, 0);
});
test('#4: block weight — a 2-weight enemy needs 2 free block', () => {
const h1 = lane([{ chessId: FRONT, row: 9, col: 6 }], [{ key: 'enemy_heavy', time: 0 }]);
const heavy1 = () => h1.b.enemies.find((e) => e.defId === 'enemy_heavy');
assert.ok(h1.runUntil(() => heavy1() && heavy1().x < 4.5, 40), 'block 1 cannot hold a 2-weight enemy');
assert.equal(heavy1().blockedBy, null);
const h2 = lane([{ chessId: FRONT, row: 9, col: 6 }], [{ key: 'enemy_heavy', time: 0 }], { frontBlock: 2 });
assert.ok(h2.runUntil(() => h2.b.enemies[0]?.blockedBy === h2.unit(FRONT), 30), 'block 2 holds it');
});
test('#4: of two free operators in contact the nearer one blocks', () => {
// an enemy standing still between (9,6) and (9,5), nearer to (9,5)
const h = lane([{ chessId: FRONT, row: 9, col: 6 }, { chessId: BACK, row: 9, col: 5 }], [],
{ enemies: { enemy_still: foe('enemy_still', { speed: 0 }) } });
h.step(1);
const e = h.spawn('enemy_still', { pos: [9, 5.4] });
h.step(1);
assert.equal(e.blockedBy, h.unit(BACK));
checkInvariants(h.b);
});
test('#4: a blocker attacks the enemy it blocks even when it stands outside its range (可以选择且优先选择阻挡单位)', () => {
// facing LEFT (away from the gate): its range (9,6),(9,5) never holds the enemy blocked at x ≈ 6.71 (tile (9,7))
const h = makeBattle({
defs: { chess: { t_back: chessRec({ id: 't_back', stats: { atk: 100, blockCnt: 1, maxHp: 1e6 }, skill: null }) }, enemies: { enemy_x: foe('enemy_x') } },
units: [{ chessId: 't_back', row: 9, col: 6, dir: 'LEFT' }], enemies: [{ key: 'enemy_x', time: 0 }], timeLimit: 60,
hooks: ['damaged'], captureNoisy: true,
});
const u = h.unit('t_back');
const x = () => h.b.enemies[0];
assert.ok(h.runUntil(() => x()?.blockedBy === u, 30));
assert.ok(!u.rangeKeySet.has(Math.round(x().y) * 21 + Math.round(x().x)), 'outside its range');
h.run(3);
assert.ok(h.hooksOf('damaged').some((c) => c.source === u && c.target === x()), 'it hits its blocked enemy');
});
test('#4 (revised by the user after playtest #6): a ranged operator on a melee tile also hits and casts for the enemy it holds behind it', () => {
// The playtest #5 QA had made blocked-first melee-only (PRTS 索敌的概念 "阻挡(近战限定)"); the user's rule after
// playtest #6 — "阻挡了就一定要能打到": officially the collision pushes a blocked enemy to its blocker's front, so
// whatever blocks an enemy can hit it. Facing LEFT, the range (9,6),(9,5) never holds the enemy blocked at x ≈ 6.71;
// the ranged blocker hits it and casts its DEFAULT skill like a melee one (帮助: the blocked enemy satisfies the
// target condition).
const run = (id, profession) => {
const h = makeBattle({
defs: { chess: { [id]: chessRec({ id, profession, stats: { atk: 100, blockCnt: 1, maxHp: 1e6 }, skill: { spCost: 1, initSp: 1, duration: 5 } }) }, enemies: { enemy_x: foe('enemy_x') } },
units: [{ chessId: id, row: 9, col: 6, dir: 'LEFT' }], enemies: [{ key: 'enemy_x', time: 0 }], timeLimit: 60,
content: 'generic', kits: { [id]: () => ({ skill: { kind: 'duration', duration: 5 } }) }, hooks: ['damaged'], captureNoisy: true,
});
const u = h.unit(id);
const x = () => h.b.enemies[0];
assert.ok(h.runUntil(() => x()?.blockedBy === u, 30), `${profession} blocks`);
assert.ok(!u.rangeKeySet.has(Math.round(x().y) * 21 + Math.round(x().x)), 'outside its range');
h.run(3);
checkInvariants(h.b);
return { hits: h.hooksOf('damaged').filter((c) => c.source === u && c.target === x()).length, casts: u.skill.activations, still: x().blockedBy === u };
};
for (const [id, profession] of [['t_rg', 'SNIPER'], ['t_ml', 'WARRIOR']]) {
const r = run(id, profession);
assert.ok(r.hits > 0, `${profession}: hits the enemy it blocks behind it`);
assert.equal(r.casts, 1, `${profession}: and casts its skill`);
assert.ok(r.still, `${profession}: it still holds the enemy`);
}
});
test('#4: "melee" is the deploy position — a 领主 with a ranged attack (银灰) still hits the enemy it holds behind it', () => {
// data position MELEE, attackKind ranged (领主 / 哨戒铁卫 / 要塞 / 钩索师 …): a melee unit for the blocked-first selector
const id = 'chess_char_4_22_a';
const h = makeBattle({
defs: { enemies: { enemy_x: foe('enemy_x') } }, timeLimit: 60, hooks: ['damaged'], captureNoisy: true,
units: [{ chessId: id, row: 9, col: 6, dir: 'LEFT' }], enemies: [{ key: 'enemy_x', time: 0 }],
});
const u = h.unit(id);
const x = () => h.b.enemies[0];
assert.equal(u.def.position, 'MELEE');
assert.equal(u.profile.attack, 'ranged', 'a ranged attack profile');
assert.ok(h.runUntil(() => x()?.blockedBy === u, 30), 'blocks');
assert.ok(!u.rangeKeySet.has(Math.round(x().y) * 21 + Math.round(x().x)), 'outside its range (behind it)');
h.run(4);
assert.ok(h.hooksOf('damaged').some((c) => c.source === u && c.target === x()), 'it hits the enemy it blocks');
});
test('#4 (revised after playtest #6): blocked-first holds for every blocker — a ranged blocker keeps shooting the enemy it blocks', () => {
// range (9,5)–(9,7) facing RIGHT: the walker it blocks stands on (9,7); a flyer passes over the operator towards the
// goal. Melee or ranged, the blocker keeps hitting the enemy it blocks (user's rule: the blocked enemy stands in front
// of its blocker officially) — the ranged one no longer switches to the flyer nearer the goal.
const run = (id, o) => {
const h = makeBattle({
defs: {
chess: { [id]: chessRec({ id, rangeGrid: [[0, -1], [0, 0], [0, 1]], stats: { atk: 1, bat: 0.2, blockCnt: 1, maxHp: 1e6 }, skill: null, ...o }) },
enemies: { enemy_x: foe('enemy_x'), enemy_fl: foe('enemy_fl', { motion: 'FLY' }) },
},
units: [{ chessId: id, row: 9, col: 6 }], enemies: [{ key: 'enemy_x', time: 0 }, { key: 'enemy_fl', time: 5, route: 2 }], timeLimit: 60,
content: 'none', hooks: ['damaged'], captureNoisy: true,
});
const u = h.unit(id);
const walker = () => h.b.enemies.find((e) => e.defId === 'enemy_x');
const flyer = () => h.b.enemies.find((e) => e.defId === 'enemy_fl');
assert.ok(h.runUntil(() => walker()?.blockedBy === u, 30), 'the walker is blocked');
assert.ok(h.runUntil(() => flyer() && flyer().x < 4.4, 30), 'the flyer passed');
assert.equal(walker().blockedBy, u);
return { fl: h.hooksOf('damaged').filter((c) => c.source === u && c.target === flyer()).length, wk: h.hooksOf('damaged').filter((c) => c.source === u && c.target === walker()).length };
};
for (const [id, o] of [['t_rg2', { profession: 'SNIPER' }], ['t_ml2', { profession: 'WARRIOR', attackKind: 'melee', canHitFly: true }]]) {
const r = run(id, o);
assert.equal(r.fl, 0, `${o.profession}: kept to its blocked enemy`);
assert.ok(r.wk > 0, `${o.profession}: hit the walker it blocks`);
}
});
test('#4: a skywalker (起飞: blockFly) blocks flyers at the air block radius 0.8944', () => {
const h = makeBattle({
defs: { chess: { t_sky: chessRec({ id: 't_sky', subProfessionId: 'skywalker', stats: { atk: 0, blockCnt: 2, maxHp: 1e6 }, skill: null }) },
enemies: { enemy_fl: foe('enemy_fl', { motion: 'FLY' }) } },
units: [{ chessId: 't_sky', row: 9, col: 6 }], enemies: [{ key: 'enemy_fl', route: 2 }], timeLimit: 60,
});
const sky = h.unit('t_sky');
const f = () => h.b.enemies[0];
assert.ok(h.runUntil(() => f()?.blockedBy === sky, 30), 'the flyer is blocked');
const d = Math.hypot(f().x - 6, f().y - 9);
assert.ok(d <= BLOCK_RADIUS.fly + 1e-9 && d > BLOCK_RADIUS.ground, `at the air radius (d=${d.toFixed(3)})`);
checkInvariants(h.b);
});
test('#4: 瑕光 S2 puts the enemy she blocks to sleep although it stands outside her tile (PRTS: 自身这格内…及自身阻挡的敌人)', () => {
const ds = getDefaultSource();
const id = 'chess_char_3_12_a';
const skillIndex = ds.rawChess(id).skills.find((s) => s.skillId === 'skchr_blemsh_2').index;
const h = makeBattle({
// (a MANUAL 重装 skill: cast by the blocked enemy's hit — TAKE_DAMAGE)
defs: { enemies: { enemy_x: foe('enemy_x', { atk: 100, bat: 1 }) } }, timeLimit: 60,
units: [{ chessId: id, row: 9, col: 6, skillIndex }], enemies: [{ key: 'enemy_x', time: 0 }],
});
const u = h.unit(id);
const x = () => h.b.enemies[0];
assert.ok(h.runUntil(() => x()?.blockedBy === u, 30));
assert.notEqual(Math.round(x().x), 6, 'the blocked enemy is on the tile in front of her');
u.skill.gainSp(u.skill.spCost * u.skill.maxCharges + 1, 'test');
assert.ok(h.runUntil(() => u.skill.active, 5));
h.step();
assert.ok(x().s.flags.sleep, 'asleep');
});