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
This commit is contained in:
sganggs committed 2026-10-01 19:08:23 +08:00
1 parent e6628f0cfe
commit 6464e4b46a
138 files changed
+7946 -1379

No files matched your search

+46 -5
View File
@@ -23,6 +23,7 @@
import { Battle } from './Battle.js';
import { toDataSource, withUnitLoadouts } from './simdata.js';
import { BOSS_POOL_MIN_HP } from './constants.js';
export const SPEC_VERSION = 1;
@@ -133,10 +134,14 @@ export function createBattleFromSpec(spec, dataSource, opts = {}) {
/**
* Client-side view of the shared boss HP pool (DESIGN §14 b.pool): the server owns the pool; this field adds its own
* damage locally and shows `server hp − local damage the server has not acknowledged yet`.
* damage(playerId, amount) called by the sim; returns the damage dealt (≤ the remaining displayed hp)
* damage(playerId, amount) called by the sim; returns the damage dealt (≤ the remaining displayed hp; the hit that
* would leave less than BOSS_POOL_MIN_HP (1) takes the rest)
* sync(serverHp, ackedCum) a b.pool broadcast: the server's hp and how much of THIS field's cumulative damage
* (`cum`) it has already counted
* cum / byPlayer cumulative damage of this field (reported as b.progress.bossDmg / .by)
* `hp` below 1 reads 0 (the leader is down; user playtest #6 item 5): the difference of the server's float hp and the
* local counters can leave dust (3.6e-12) smaller than half an ulp of `cum`, which no hit could remove — `cum += dust`
* changes nothing — so the leader stood at "0 HP" and the field never ended.
*/
export class LocalBossPool {
constructor(maxHp, hp = maxHp) {
@@ -149,7 +154,10 @@ export class LocalBossPool {
this.byPlayer = {};
}
get hp() { return Math.max(0, this.serverHp - Math.max(0, this.cum - this.acked)); }
get hp() {
const h = this.serverHp - Math.max(0, this.cum - this.acked);
return h < BOSS_POOL_MIN_HP ? 0 : h;
}
/** Only used by the sim's fallback path when damage() throws. */
set hp(v) {
@@ -161,7 +169,7 @@ export class LocalBossPool {
const a = Number(amount);
const left = this.hp;
if (!Number.isFinite(a) || a <= 0 || left <= 0) return 0;
const dealt = Math.min(left, a);
const dealt = left - a < BOSS_POOL_MIN_HP ? left : a;
this.cum += dealt;
if (playerId != null) this.byPlayer[playerId] = (this.byPlayer[playerId] || 0) + dealt;
return dealt;
@@ -198,9 +206,37 @@ export function attachLpMeter(battle) {
return meter;
}
/**
* 联防 (user playtest #6 item 7): each source player's enemies still standing on a unite field — not spawned yet,
* alive, or already through the objective again — i.e. what settlement would charge them if the battle ended now
* (server/match/unite.js uniteSurvivors: the unite result's counted leaks + unspawned entries by `sourcePlayerId`;
* alive enemies become leaks on a timeout, keyed like Battle._recordLeak). It falls as the helpers strike them down and
* rises when one splits or summons (content-spawned children inherit the parent's sourcePlayerId).
* `{ [playerId]: n }` (a player with none left is absent), or null for a battle without the sim's state (a stand-in).
* @returns {Record<string, number> | null}
*/
export function uniteLeft(battle) {
if (!battle) return null;
const out = {};
const add = (pid) => { if (typeof pid === 'string' && pid) out[pid] = (out[pid] || 0) + 1; };
if (battle.finished && typeof battle.result === 'function') {
const r = battle.result();
if (!r || !r.perPlayer) return null;
for (const pp of Object.values(r.perPlayer)) for (const l of (pp && pp.leaked) || []) if (l && l.counted !== false) add(l.sourcePlayerId);
for (const u of r.unspawned || []) if (u) add(u.sourcePlayerId);
return out;
}
if (!battle._perPlayer || !Array.isArray(battle._pending) || !Array.isArray(battle.enemies)) return null;
for (const p of battle._pending) if (p) add(p.sourcePlayerId);
for (const e of battle.enemies) if (e && e.alive && e.counted) add(e.sourcePlayerId ?? e.ownerId);
for (const pp of Object.values(battle._perPlayer)) for (const l of (pp && pp.leaked) || []) if (l && l.counted !== false) add(l.sourcePlayerId);
return out;
}
/**
* Progress numbers of a battle for b.progress / the teammates' waiting UI: game time, kills, total, counted leaks
* (normal / unite) and the boss pool damage of this field.
* (normal / unite), the boss pool damage of this field and — unite fields — `left` (uniteLeft: each leaker's enemies
* still standing).
*/
export function battleProgress(battle) {
const r = battle && typeof battle.result === 'function' && battle.finished ? battle.result() : null;
@@ -209,7 +245,7 @@ export function battleProgress(battle) {
for (const k of Object.keys(pp)) for (const l of pp[k].leaked || []) if (l && l.counted !== false) leaks++;
const pool = battle && battle.sharedBoss;
const gt = Number(battle && battle.time) || 0;
return {
const out = {
gt: Math.round(gt * 1000) / 1000,
killed: Math.max(0, Math.trunc(Number(battle && battle.killed) || 0)),
total: Math.max(0, Math.trunc(Number(battle && battle.total) || 0)),
@@ -217,6 +253,11 @@ export function battleProgress(battle) {
bossDmg: pool && Number.isFinite(pool.cum) ? pool.cum : 0,
done: !!(battle && battle.finished),
};
if (battle && battle.kind === 'unite') {
const left = uniteLeft(battle);
if (left) out.left = left;
}
return out;
}
const r4 = (v) => Math.round((Number(v) || 0) * 1e4) / 1e4;