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:
1 parent
e6628f0cfe
commit
6464e4b46a
138 files changed
+7946
-1379
No files matched your search
+96
-15
@@ -66,7 +66,9 @@
|
||||
// boss fields in real time without snapshots (they share the pool). A silent authority (deadline = time limit + 15 s)
|
||||
// or a disconnected one loses the field to the server, which re-simulates the spec; an implausible b.result is
|
||||
// replaced by the server's own simulation (fields.js validateClientResult). The shared boss pool, the team LP and the
|
||||
// overtime drain live here (b.pool ≤ 4 Hz, b.end; m.public follows a boss fight at ~1 Hz). No b.snap / b.ev is sent in
|
||||
// overtime drain live here (b.pool ≤ 4 Hz, b.end; m.public follows a boss fight at ~1 Hz); the first end condition the
|
||||
// server registers decides the Final Assault (pool 0 → victory, team LP 0 → defeat; nothing reported after a defeat is
|
||||
// credited — _finalEnding, user playtest #6 item 5). No b.snap / b.ev is sent in
|
||||
// this mode; g.watch hands out specs. battleIds are `<seed36>[-<matchNo36>].<round>.<seq>[.<fieldId>]` (≤ 64 chars):
|
||||
// unique across the matches of a room, so a report that crossed into the next match is ignored like any stale one; a
|
||||
// duplicate b.result (a browser re-sending one it believes lost) is answered ok and changes nothing.
|
||||
@@ -74,7 +76,11 @@
|
||||
// clock and the server pacers; m.public.paused; resume shifts every clock / deadline by the pause.
|
||||
// Live LP (user playtest #3 item 2): during COMBAT / 联防 m.public players[].pendingLp = min(lpCapPerRound, the counted
|
||||
// leaks of the player's own battle so far) (_pendingLpView; omitted when 0) — the teammates' rows of the team panel
|
||||
// show lp − pendingLp; the settlement lands with the SETTLE view, where it is gone.
|
||||
// show lp − pendingLp; the settlement lands with the SETTLE view, where it is gone. 联防 (user playtest #6 item 7): a
|
||||
// leaker's players[].uniteLeft = its enemies still standing on the 联防 field (not spawned yet, alive, or through again;
|
||||
// uncapped, live: the authority's b.progress `left`, the server-run timeline or battle; exact once the field has its
|
||||
// result — _uniteLeft) and pendingLp = min(lpCapPerRound, uniteLeft): the counter falls as the helpers kill them (and
|
||||
// rises when one of them splits or summons — the children are billed to the same leaker).
|
||||
// User playtest #4: a match with a single human (独立模拟, or a 同盟 room started alone / with AI teammates) times no
|
||||
// phase outside its battles (soloUntimed); the co-op strategy draft has ONE countdown — BAND_TURN_SECONDS per turn,
|
||||
// published as m.public.deadline — AI seats pick at once and a turn that runs out takes the highlighted strategy
|
||||
@@ -124,15 +130,16 @@ import { PlayerState } from './PlayerState.js';
|
||||
import { buildDeployMap, boardOrder, pieceDir } from './board.js';
|
||||
import { bondList } from './bondsMeta.js';
|
||||
import { EffectDispatcher, getDefaultRegistry } from './effectsMeta.js';
|
||||
import { generateDraft, applyCard, cardView } from './choices.js';
|
||||
import { generateDraft, applyCard, cardView, bountyBattles, isMultiRoundBounty } from './choices.js';
|
||||
import { setupMatchWaves, buildNormalWave, buildBossWave, bountySpawns, withBounties, previewOf, weightedPick } from './waves.js';
|
||||
import { planUnite, uniteBattleOpts, uniteSurvivors } from './unite.js';
|
||||
import { pairPlayers, bossPoolHp, SharedBossPool, hiddenEligible, BOSS_HIT_STEPS } from './finalAssault.js';
|
||||
import {
|
||||
FieldRunner, DeadBattle, GAME_SPEED, snapFrame, runHeadless, timelineAt, HeadlessPacer, syntheticResult,
|
||||
validateClientResult, RESULT_GRACE_MS, BOSS_SILENCE_MS, HARD_CAP_SECONDS, HeadlessJob, HEADLESS_SLICE_MS, CATCHUP_TICKS_PER_INTERVAL,
|
||||
uniteBillBounds,
|
||||
} from './fields.js';
|
||||
import { buildBattleSpec, createBattleFromSpec, resultDigest, compactResult as compactForVerify, battleProgress } from '../sim/spec.js';
|
||||
import { buildBattleSpec, createBattleFromSpec, resultDigest, compactResult as compactForVerify, battleProgress, uniteLeft } from '../sim/spec.js';
|
||||
import { CreditPool } from './finalAssault.js';
|
||||
import { buildResult } from './results.js';
|
||||
import { botPrepBeginSteps, botPrepEndSteps, botPickBand, botPickCard } from './bot.js';
|
||||
@@ -338,6 +345,10 @@ export class Match {
|
||||
this.watchers = new Map();
|
||||
this.lastResults = new Map();
|
||||
this.unitePlan = null;
|
||||
/** server-run 联防: the leakers' counts last published (_uniteTick) */
|
||||
this._uniteLeftKey = null;
|
||||
/** 联防: { plan, bounds } — per leaker the most survivors settlement can bill (_uniteLeft's clamp) */
|
||||
this._uniteBounds = null;
|
||||
this.teamLp = null;
|
||||
this.bossPool = null;
|
||||
this.hiddenLayerSum = 0;
|
||||
@@ -1465,10 +1476,11 @@ export class Match {
|
||||
|
||||
addBounty(ps, card) {
|
||||
if (!ps || !card || !this.gd.enemy(card.enemyKey)) return null;
|
||||
const rounds = Math.max(1, Math.min(99, Number.isInteger(card.rounds) ? card.rounds : 1));
|
||||
// a multi-round card lasts MULTI_ROUND_BOUNTY_BATTLES battles (choices.js; the user's call after playtest #6)
|
||||
const rounds = bountyBattles(card);
|
||||
const b = {
|
||||
id: `bounty:${this.nextUid()}`,
|
||||
card: { effectId: card.effectId ?? card.id ?? null, name: card.name ?? '悬赏', desc: card.desc ?? '', tier: card.tier ?? 1, coin: Math.max(0, Math.trunc(Number(card.coin) || 0)), payout: card.payout === 'perfect' ? 'perfect' : 'kill', rounds, enemyKey: card.enemyKey, count: Math.max(1, Math.min(20, Number.isInteger(card.count) ? card.count : 1)) },
|
||||
card: { effectId: card.effectId ?? card.id ?? null, name: card.name ?? '悬赏', desc: card.desc ?? '', tier: card.tier ?? 1, coin: Math.max(0, Math.trunc(Number(card.coin) || 0)), payout: card.payout === 'perfect' ? 'perfect' : 'kill', rounds, multiRound: isMultiRoundBounty(card), enemyKey: card.enemyKey, count: Math.max(1, Math.min(20, Number.isInteger(card.count) ? card.count : 1)) },
|
||||
roundsLeft: rounds,
|
||||
};
|
||||
ps.bounties.push(b);
|
||||
@@ -1822,7 +1834,9 @@ export class Match {
|
||||
this._defaultWatch();
|
||||
this.markPublic();
|
||||
this.tickerText(`联防阶段:${plan.helpers.map((p) => p.name).join('、')} 迎战突破防线的敌人`);
|
||||
this._uniteLeftKey = null;
|
||||
this.runner = new FieldRunner(this, this.fields, {
|
||||
onTick: (runner) => this._uniteTick(runner),
|
||||
onDone: (runner) => {
|
||||
if (this.phase !== PHASE.UNITE) return;
|
||||
const res = runner.resultOf(this.fields[0]);
|
||||
@@ -1944,14 +1958,21 @@ export class Match {
|
||||
* LP a player's own battle of this normal round will cost at settlement so far — settle()'s min(lpCapPerRound,
|
||||
* counted leaks) — for the teammates' live LP (m.public players[].pendingLp, user playtest #3 item 2; the own client
|
||||
* counts its local battle itself). COMBAT: the recorded result once every field is done, else the field's result, else
|
||||
* the authority's b.progress leaks (a server-run field reports none before its result is released); 联防: the own
|
||||
* battle's count — the most the 联防 can charge (settle: min(cap, survivors) ≤ min(cap, counted)). Omitted when 0 and
|
||||
* in every other phase (boss rounds charge the merged team LP live).
|
||||
* @returns {{ pendingLp?: number }}
|
||||
* the authority's b.progress leaks (a server-run field reports none before its result is released); 联防: a leaker's
|
||||
* enemies still standing on the 联防 field (_uniteLeft, uncapped in `uniteLeft`, user playtest #6 item 7), anyone
|
||||
* else's own battle count (0: they were perfect). Omitted when 0 and in every other phase (boss rounds charge the
|
||||
* merged team LP live).
|
||||
* @returns {{ pendingLp?: number, uniteLeft?: number }}
|
||||
*/
|
||||
_pendingLpView(ps) {
|
||||
if (!ps || !ps.alive || (this.phase !== PHASE.COMBAT && this.phase !== PHASE.UNITE)) return {};
|
||||
const counted = (r) => (r && Array.isArray(r.leaked) ? r.leaked.filter((l) => l && l.counted !== false).length : 0);
|
||||
// 联防: a leaker's enemies still standing on the 联防 field (uncapped), the loss capped like settle()
|
||||
const left = this._uniteLeft(ps);
|
||||
if (left != null) {
|
||||
const loss = Math.min(this.gd.lpCapPerRound, left);
|
||||
return loss > 0 ? { uniteLeft: left, pendingLp: loss } : { uniteLeft: left };
|
||||
}
|
||||
let n = 0;
|
||||
if (this.lastResults.has(ps.playerId)) n = counted(this.lastResults.get(ps.playerId));
|
||||
else if (this.phase === PHASE.COMBAT) {
|
||||
@@ -1963,6 +1984,58 @@ export class Match {
|
||||
return loss > 0 ? { pendingLp: loss } : {};
|
||||
}
|
||||
|
||||
/**
|
||||
* 联防 (user playtest #6 item 7; PRTS 卫戍协议/帮助 "防卫失败的玩家可通过上方信息栏确认自身所属敌人的剩余数量"): how many of
|
||||
* a leaker's enemies are still standing on the 联防 field — not spawned yet, alive, or through the objective again —
|
||||
* plus its leaks that could not re-enter: what settle() charges it (before the per-round cap) if the 联防 ended now.
|
||||
* It falls as the helpers strike them down and rises when one splits or summons (the children carry the leaker).
|
||||
* Live from the field (client run: the authority's b.progress `left`; server run: the headless timeline on the field
|
||||
* clock, or the streamed battle itself), clamped to what settlement can bill that leaker (fields.js uniteBillBounds:
|
||||
* sent in + the offspring bound, validateClientResult's budget); exact once the field has its result (unite.js
|
||||
* uniteSurvivors; a synthetic result charges the own leaks, as settle()). null for anyone but a leaker of the running
|
||||
* 联防.
|
||||
* @returns {number|null}
|
||||
*/
|
||||
_uniteLeft(ps) {
|
||||
const plan = this.unitePlan;
|
||||
if (this.phase !== PHASE.UNITE || !plan || !ps || !plan.leakers.includes(ps)) return null;
|
||||
const pid = ps.playerId;
|
||||
const f = this.fields.find((x) => x && x.kind === 'unite') || null;
|
||||
let res = null;
|
||||
if (f && f.cc) res = f.done ? f.result : null;
|
||||
else if (f && f.battle && f.battle.finished) { try { res = f.battle.result(); } catch { res = null; } }
|
||||
if (res && res.synthetic) {
|
||||
const own = this.lastResults.get(pid);
|
||||
return own && Array.isArray(own.leaked) ? own.leaked.filter((l) => l && l.counted !== false).length : 0;
|
||||
}
|
||||
if (res) return uniteSurvivors(plan, res).get(pid) || 0;
|
||||
const sent = plan.leaked.filter((l) => l.sourcePlayerId === pid).length;
|
||||
let live = null;
|
||||
if (f && f.cc) {
|
||||
if (f.mode === 'server' && f.timeline) {
|
||||
const sample = timelineAt(f.timeline, this._fieldElapsed(f));
|
||||
live = sample && sample[3] && typeof sample[3] === 'object' ? sample[3] : null;
|
||||
} else live = f.progress && f.progress.left && typeof f.progress.left === 'object' ? f.progress.left : null;
|
||||
} else if (f && f.battle) {
|
||||
try { live = uniteLeft(f.battle); } catch { live = null; }
|
||||
}
|
||||
if (!this._uniteBounds || this._uniteBounds.plan !== plan) this._uniteBounds = { plan, bounds: uniteBillBounds(plan.leaked, this.gd) };
|
||||
const bound = this._uniteBounds.bounds.get(pid) ?? sent;
|
||||
const standing = live ? Math.min(bound, Math.max(0, Math.trunc(Number(live[pid]) || 0))) : sent;
|
||||
return standing + (plan.notReentered.get(pid) || 0);
|
||||
}
|
||||
|
||||
/** Server-run 联防 (streaming mode): refresh m.public about once a game second when a leaker's count moved. */
|
||||
_uniteTick(runner) {
|
||||
const f = runner && runner.fields ? runner.fields[0] : null;
|
||||
if (!f || !f.battle || runner.ticks % 30 !== 0) return;
|
||||
let key = '';
|
||||
try { key = JSON.stringify(uniteLeft(f.battle)); } catch { key = ''; }
|
||||
if (key === this._uniteLeftKey) return;
|
||||
this._uniteLeftKey = key;
|
||||
this.markPublic();
|
||||
}
|
||||
|
||||
/** The connected human who simulates a field: lowest seat among its players (normal: the owner). */
|
||||
_authorityFor(f, exclude = null) {
|
||||
let best = null;
|
||||
@@ -2223,6 +2296,8 @@ export class Match {
|
||||
return OK;
|
||||
}
|
||||
if (Number.isFinite(msg.leaks)) p.leaks = Math.max(p.leaks, msg.leaks);
|
||||
// 联防: the leakers' enemies still standing (the latest report; clamped where it is read, _uniteLeft)
|
||||
if (f.kind === 'unite' && msg.left && typeof msg.left === 'object') p.left = { ...msg.left };
|
||||
this.markPublic();
|
||||
return OK;
|
||||
}
|
||||
@@ -2250,9 +2325,10 @@ export class Match {
|
||||
for (const pid of f.players) { const d = Number(result.perPlayer[pid] && result.perPlayer[pid].bossDamage) || 0; by[pid] = d; sum += d; }
|
||||
if (sum > f.bossAcked) this._creditBoss(f, sum, by);
|
||||
// the client emptied the pool as it saw it (server hp − its unacknowledged damage): what is left on the server is
|
||||
// float dust from summing the fields' reports in another order — the boss is down
|
||||
// float dust from summing the fields' reports in another order — the boss is down (pools never hold less than
|
||||
// BOSS_POOL_MIN_HP, finalAssault.js: this only catches a noise-level disagreement at that boundary)
|
||||
const pool = this.bossPool;
|
||||
if (result.reason === 'cleared' && pool && pool.hp > 0 && pool.hp < 1) pool.damage(f.players[0] ?? null, pool.hp);
|
||||
if (result.reason === 'cleared' && this._finalEnding !== 'forced' && pool && pool.hp > 0 && pool.hp < 1 + 1e-6) pool.damage(f.players[0] ?? null, pool.hp);
|
||||
// a boss field ends only when the shared pool is empty (the client saw it reach 0) or the match forced the end
|
||||
// (b.end): any other result — 'forced' / 'timeout' at t = 0, 'cleared' while the pool still holds — would stop
|
||||
// the pair's fight (and, with every field done, end the Final Assault as a defeat). The field is handed to the
|
||||
@@ -2399,7 +2475,9 @@ export class Match {
|
||||
_creditBoss(f, cum, by) {
|
||||
const pool = this.bossPool;
|
||||
const reported = Number(cum);
|
||||
if (!pool || !Number.isFinite(reported)) return;
|
||||
// the team LP ran out first: the run failed at that moment (PRTS "…使目标生命值扣除至0,则无视倒计时直接失败"), so
|
||||
// damage reported afterwards — in flight, or rounded up in the final b.result — changes nothing (user playtest #6)
|
||||
if (!pool || !Number.isFinite(reported) || this._finalEnding === 'forced') return;
|
||||
if (!f.bossReported || reported > f.bossReported.cum) f.bossReported = { cum: reported, by: by && typeof by === 'object' ? { ...by } : null };
|
||||
const c = Math.min(reported, this._bossDmgBudget(f));
|
||||
if (!(c > f.bossAcked)) return;
|
||||
@@ -2700,7 +2778,8 @@ export class Match {
|
||||
const wave = reuse ? this.bossWaves[i].wave : buildBossWave(this.gd, this.rngWaves, this.factions, this.round, { bossId, solo });
|
||||
// one spawn list per field, shared by the field's players' onBattleStart handlers (edit it in place)
|
||||
const spawns = wave.spawns.map((s) => ({ ...s, mods: s.mods ? { ...s.mods } : undefined }));
|
||||
// multi-round bounties ("之后的每场作战") follow their player into the boss field, on the player's half
|
||||
// bounties with battles left (a multi-round card lasts MULTI_ROUND_BOUNTY_BATTLES) follow their player into the
|
||||
// boss field, on the player's half
|
||||
g.forEach((ps, j) => {
|
||||
for (const b of bountySpawns(this.gd, this.round, wave, ps.bounties, ps.playerId, { solo: this.isSolo, side: j === 0 ? 'L' : 'R' })) spawns.push(b);
|
||||
});
|
||||
@@ -2874,7 +2953,9 @@ export class Match {
|
||||
if (pp && ps) this.dispatch(ps, 'onBattleResult', { result: pp, lpLoss: 0, perfect: !!pp.perfect, boss: true });
|
||||
}
|
||||
}
|
||||
const victory = this.bossPool.hp <= 0;
|
||||
// the end condition the server registered first decides (client-side combat: _endFinal — pool 0 → victory, team LP 0
|
||||
// → defeat); a boss field's final result may never turn a defeat into a victory (user playtest #6 item 5)
|
||||
const victory = this._finalEnding ? this._finalEnding === 'cleared' : this.bossPool.hp <= 0;
|
||||
this._syncTeamLp();
|
||||
this.deadline = 0;
|
||||
this.overtimeAt = 0;
|
||||
|
||||
+80
-23
@@ -5,7 +5,8 @@
|
||||
// * Hand (整备区) 10 slots filled right→left, temp (临时整备区) 5 slots. A full hand refuses buys / withdrawals,
|
||||
// except a purchase that completes a merge and a withdrawal whose own summon stack frees a slot. Passive gains (merge
|
||||
// results, grants, returned equipment) overflow into temp; temp blocks Ready ("直到溢出情况排除才可开始进行作战").
|
||||
// A temp piece is resolved (chess sold back to the pool, items / summon stacks destroyed) at the deadline of the
|
||||
// A temp piece is resolved (chess sold back to the pool, items destroyed, summon stacks removed — they come back at
|
||||
// the next round start, grantTokensFor) at the deadline of the
|
||||
// first prep in which the player could act on it (tempDue): a piece that overflowed during a prep before Ready
|
||||
// expires at that prep's end; one that arrived after Ready, at the prep end (<休整期结束时> grants), in COMBAT /
|
||||
// SETTLE (battle-result grants, merges, returned equipment) or at the next round start / 机变 stays visible and
|
||||
@@ -21,7 +22,8 @@
|
||||
// refreshes first), one toggle freezes all unsold slots until the next round start (a manual refresh while frozen
|
||||
// rerolls everything and the new slots stay frozen), level-up price = base − rounds elapsed (floor 0).
|
||||
// * Merge: 3 normal copies (风丸 2) on board/hand/temp → 1 elite to the hand; equipment returns to the hand; a
|
||||
// reward offer of 3 free chess of tier min(level+1, 6) is queued (pick 1, expires at prep end; an offer earned
|
||||
// reward offer of 3 different free chess of tier min(level+1, 6) is queued (never one operator twice — user
|
||||
// playtest #6 item 19; a short tier tops up from the tier below; pick 1, expires at prep end; an offer earned
|
||||
// after the prep — SETTLE / Final Assault effects — is kept for the next prep). The elite goes to the hand
|
||||
// (research 01 A1 "1 elite goes to the hand, not to a board tile"), overflow temp — also outside PREP (a
|
||||
// SETTLE merge's elite waits in temp for the next prep); only with the hand and temp both full does it take a
|
||||
@@ -30,9 +32,14 @@
|
||||
// absent; equipped items are otherwise locked: g.destroy refuses them),
|
||||
// 2 identical normal items (hand/temp/equipped) merge into the golden item in the hand, items are never sold
|
||||
// (destroy for 0). consume-on-equip items resolve through the effect registry and never take a slot.
|
||||
// * Tokens: placing an owner with placeable summons (tokens.json `placeable`: the talent summons 海嗣 / 狼群 / 流形)
|
||||
// sends one stack (deployLimit copies) to the hand; withdrawing/selling/merging the owner removes its tokens. A
|
||||
// skill's summon (赫默 医疗无人机, 巫恋 诅咒娃娃) is never a hand card: it appears in battle when the skill fires.
|
||||
// * Tokens (PRTS 卫戍协议/帮助 §战斗部署, user playtest #6): placing an owner with manually deployable summons
|
||||
// (tokens.json `placeable`: 赫默's 医疗探机 and 巫恋's 诅咒娃娃 with their S2, 凯瑟琳's 爬行号·防护单元, 海嗣 / 狼群 /
|
||||
// 流形) sends one stack (deployLimit copies — 凯瑟琳 2) to the hand, placed by hand like any piece (no deploy slot);
|
||||
// withdrawing/selling/merging the owner removes its tokens, moving it on the board (also when a summon dragged onto
|
||||
// it swaps it away) sends its placed summons back onto their stack ("移动干员时,其所属召唤物全部退场并重置至手牌区");
|
||||
// a summon stack removed from temp at a prep deadline comes back at the next round start (startRound tops every
|
||||
// board owner's summons up to the deploy limit, "干员所属召唤物会于下一回合返还"). In battle a skill's summon takes its
|
||||
// tile when the skill fires (sim/content/tokens.js dockSkillSummons).
|
||||
// * Facing (DESIGN §3, research 09 §1.2): every board piece has `dir` ∈ UP|RIGHT|DOWN|LEFT (server/sim/dir.js), set
|
||||
// by g.move {…, dir} (absent ⇒ RIGHT) and kept across rounds. g.move onto the piece's OWN tile re-orients it in
|
||||
// place; a swap keeps the occupant's dir; pieces put on the board by effects (a merge elite taking a consumed
|
||||
@@ -50,6 +57,7 @@ import { FIELD, tileKey, parseKey, inField, canPlace, positionClass, boardOrder,
|
||||
import { offsetTile } from '../sim/dir.js';
|
||||
import { computeBonds, bondList, bondSnapshot, activatedLayers } from './bondsMeta.js';
|
||||
import { itemKey } from './gamedata.js';
|
||||
import { bountyText } from './choices.js';
|
||||
|
||||
const HAND_SIZE = GEO.HAND_SIZE;
|
||||
const TEMP_SIZE = GEO.TEMP_SIZE;
|
||||
@@ -352,22 +360,46 @@ export class PlayerState {
|
||||
for (let i = 0; i < this.temp.length; i++) if (this.temp[i] && this.temp[i].kind === 'token' && this.temp[i].ownerUid === ownerUid) this.temp[i] = null;
|
||||
}
|
||||
|
||||
_hasTokensOf(ownerUid, tokenId) {
|
||||
for (const p of this.board.values()) if (p.kind === 'token' && p.ownerUid === ownerUid && p.id === tokenId) return true;
|
||||
for (const p of this.hand) if (p && p.kind === 'token' && p.ownerUid === ownerUid && p.id === tokenId) return true;
|
||||
for (const p of this.temp) if (p && p.kind === 'token' && p.ownerUid === ownerUid && p.id === tokenId) return true;
|
||||
return false;
|
||||
/**
|
||||
* An owner that changes its board tile (moved, swapped): its summons on the board go back onto its stack (PRTS
|
||||
* 卫戍协议/帮助 "移动干员时,其所属召唤物全部退场并重置至手牌区"); overflow temp when no stack or slot is left (a
|
||||
* stack lost there comes back at the next round start). `keep`: a summon the player just placed (the one dragged
|
||||
* onto its owner, which swapped the owner away) stays where it was put.
|
||||
*/
|
||||
_liftTokensOf(ownerUid, keep = null) {
|
||||
for (const [k, p] of [...this.board]) {
|
||||
if (p.kind !== 'token' || p.ownerUid !== ownerUid || p === keep) continue;
|
||||
this.board.delete(k);
|
||||
if (!this._returnToken(p, null, { allowTemp: true })) this.board.set(k, p); // nowhere to go: it stays put
|
||||
}
|
||||
}
|
||||
|
||||
/** Copies of one summon type an owner has (placed pieces + stacks in the hand / temp). */
|
||||
_tokenCountOf(ownerUid, tokenId) {
|
||||
const mine = (p) => !!p && p.kind === 'token' && p.ownerUid === ownerUid && p.id === tokenId;
|
||||
let n = 0;
|
||||
for (const p of this.board.values()) if (mine(p)) n += p.count || 1;
|
||||
for (const p of this.hand) if (mine(p)) n += p.count || 1;
|
||||
for (const p of this.temp) if (mine(p)) n += p.count || 1;
|
||||
return n;
|
||||
}
|
||||
|
||||
/**
|
||||
* Owner placed on the board: send its placeable summons to the hand (one stack per token type). Only talent summons
|
||||
* are placeable (gamedata.placeableTokens / tokens.json `placeable`); a summon a skill makes ("获得一个医疗无人机",
|
||||
* 赫默 S2) appears in battle when that skill fires (user playtest #4: not on the field from the start).
|
||||
* Owner on the board: send its placeable summons to the hand (one stack per token type, topped up to the deploy limit;
|
||||
* gamedata.placeableTokens / tokens.json `placeable`, user playtest #6) — those its equipped skill / module makes
|
||||
* (DESIGN §16: 赫默 S2 医疗无人机 a drone, 赫默 S1 none). Called when the owner is placed and at every round start, which
|
||||
* returns a stack removed from temp at the last prep deadline (PRTS 卫戍协议/帮助 §手牌区 "干员所属召唤物会于下一回合
|
||||
* 返还"); copies the owner still has (placed or stacked) are not granted again.
|
||||
*/
|
||||
grantTokensFor(owner) {
|
||||
for (const { tokenId, count } of this.gd.placeableTokens(owner.id)) {
|
||||
if (this._hasTokensOf(owner.uid, tokenId)) continue;
|
||||
const t = this.newPiece('token', tokenId, { count, ownerUid: owner.uid });
|
||||
const rec = owner && owner.kind === 'chess' ? this.gd.chess(owner.id) : null;
|
||||
if (!rec) return;
|
||||
for (const { tokenId, count } of this.gd.placeableTokens(owner.id, this.loadoutFor(rec))) {
|
||||
const missing = count - this._tokenCountOf(owner.uid, tokenId);
|
||||
if (missing <= 0) continue;
|
||||
const stack = [...this.hand, ...this.temp].find((p) => p && p.kind === 'token' && p.ownerUid === owner.uid && p.id === tokenId);
|
||||
if (stack) { stack.count = (stack.count || 1) + missing; continue; }
|
||||
const t = this.newPiece('token', tokenId, { count: missing, ownerUid: owner.uid });
|
||||
this.stow(t, { allowTemp: true });
|
||||
}
|
||||
}
|
||||
@@ -539,16 +571,23 @@ export class PlayerState {
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Queue a reward offer: `count` chess of tier min(level + offset, maxTier) at price 0 (pick 1). */
|
||||
/**
|
||||
* Queue a reward offer: `count` DIFFERENT chess of tier min(level + offset, maxTier) at price 0 (pick 1). Each is a
|
||||
* copy-weighted roll from the shared pool excluding the ones already drawn; a tier left without another chess tops
|
||||
* up from the tier below (user playtest #6 item 19: the official promotion reward never offers one operator twice —
|
||||
* the user's first-hand report; the normal shop's slots may repeat). The offer reserves no copies (the pick takes one).
|
||||
*/
|
||||
pushRewardOffer(source = 'merge', { tier = null, ids = null } = {}) {
|
||||
const ro = this.gd.rewardOffer();
|
||||
const t = Number.isInteger(tier) ? tier : Math.min(this.shop.level + ro.tierOffset, ro.maxTier);
|
||||
let list = Array.isArray(ids) ? ids.filter((id) => this.gd.chess(id)) : null;
|
||||
// an offer never shows one operator twice, whoever built the list (user playtest #6 item 19)
|
||||
let list = Array.isArray(ids) ? [...new Set(ids)].filter((id) => this.gd.chess(id)) : null;
|
||||
if (!list) {
|
||||
list = [];
|
||||
const fresh = (id) => !list.includes(id);
|
||||
for (let i = 0; i < ro.count; i++) {
|
||||
let id = null;
|
||||
for (let tt = t; tt >= 1 && !id; tt--) id = this.m.pool.roll(this.m.rngShop, { tier: tt });
|
||||
for (let tt = t; tt >= 1 && !id; tt--) id = this.m.pool.roll(this.m.rngShop, { tier: tt, filter: fresh });
|
||||
if (id) list.push(id);
|
||||
}
|
||||
}
|
||||
@@ -899,16 +938,19 @@ export class PlayerState {
|
||||
const occ = this.board.get(key) || null;
|
||||
if (occ === piece) return this._reorient(piece, dir);
|
||||
if (loc.area === 'board') {
|
||||
// board → board: move or swap (the occupant must be legal on the source tile and keeps its own facing)
|
||||
// board → board: move or swap (the occupant must be legal on the source tile and keeps its own facing); an
|
||||
// operator that changes its tile takes its summons off the board (back onto their stacks, _liftTokensOf)
|
||||
if (occ) {
|
||||
const [sr, sc] = parseKey(loc.key);
|
||||
if (!this._legal(occ, sr, sc)) return fail(ERR.BAD_TILE);
|
||||
this.board.set(loc.key, occ);
|
||||
if (occ.kind === 'chess') this._liftTokensOf(occ.uid);
|
||||
} else {
|
||||
this.board.delete(loc.key);
|
||||
}
|
||||
piece.dir = dir;
|
||||
this.board.set(key, piece);
|
||||
this._liftTokensOf(piece.uid);
|
||||
this.recompute();
|
||||
return OK;
|
||||
}
|
||||
@@ -966,6 +1008,8 @@ export class PlayerState {
|
||||
const occ = this.board.get(key) || null;
|
||||
if (occ === piece) return this._reorient(piece, dir);
|
||||
if (loc.area === 'board') {
|
||||
// board → board: move or swap; an operator swapped onto the summon's old tile changed its tile, so its other
|
||||
// summons go back onto their stacks like any moved operator (_liftTokensOf; the summon just placed stays)
|
||||
if (occ) {
|
||||
const [sr, sc] = parseKey(loc.key);
|
||||
if (!this._legal(occ, sr, sc)) return fail(ERR.BAD_TILE);
|
||||
@@ -975,6 +1019,7 @@ export class PlayerState {
|
||||
}
|
||||
piece.dir = dir;
|
||||
this.board.set(key, piece);
|
||||
if (occ && occ.kind === 'chess') this._liftTokensOf(occ.uid, piece);
|
||||
this.recompute();
|
||||
return OK;
|
||||
}
|
||||
@@ -1216,8 +1261,10 @@ export class PlayerState {
|
||||
|
||||
/**
|
||||
* Prep deadline: every temp piece due at this prep (tempDue ≤ prepsEnded) is resolved — a chess is sold back (its
|
||||
* pool copies return, its summons are removed), items and summon stacks are destroyed. Pieces that overflowed after
|
||||
* the player could no longer act on them (after Ready, at the prep end) are kept for the next prep.
|
||||
* pool copies return, its summons are removed), items are destroyed, the summon stack of an owner still on the board
|
||||
* is removed and comes back at the next round start (startRound → grantTokensFor; PRTS "干员所属召唤物会于下一回合
|
||||
* 返还"). Pieces that overflowed after the player could no longer act on them (after Ready, at the prep end) are kept
|
||||
* for the next prep.
|
||||
*/
|
||||
resolveTemp() {
|
||||
let changed = false;
|
||||
@@ -1252,6 +1299,8 @@ export class PlayerState {
|
||||
// Likewise reward offers of the last prep already expired at its end; what is still queued was earned after it —
|
||||
// a merge completed during SETTLE / the Final Assault (突变细胞, battle-result grants) — and is shown in this prep
|
||||
this.ready = false;
|
||||
// summon stacks removed from temp at the last prep deadline come back (PRTS 卫戍协议/帮助 §手牌区); full hand ⇒ temp
|
||||
for (const p of [...this.board.values()]) if (p.kind === 'chess') this.grantTokensFor(p);
|
||||
this.rollShop({ keepFrozen: true });
|
||||
this.shop.frozen = false;
|
||||
for (const s of this.shop.slots) if (s) s.frozen = false;
|
||||
@@ -1369,7 +1418,15 @@ export class PlayerState {
|
||||
out.push(v);
|
||||
}
|
||||
for (const b of this.bounties) {
|
||||
out.push({ id: b.id, name: b.card.name || '悬赏', desc: b.card.desc || '', iconKind: 'choice', iconId: b.card.effectId || 'bounty', counter: b.roundsLeft >= 90 ? null : b.roundsLeft });
|
||||
// the battles the bounty's enemies still come for (an official multi-round card: every battle, no counter) and
|
||||
// the card's official rich text (blue "下场作战" / "两场作战", red "每场"; a multi-round card reads as long as it
|
||||
// lasts — choices.js bountyText, MULTI_ROUND_BOUNTY_BATTLES) — user playtest #6 item 4
|
||||
const left = b.roundsLeft >= 90 ? null : b.roundsLeft;
|
||||
const eff = b.card.effectId ? this.gd.effect(b.card.effectId) : null;
|
||||
out.push({
|
||||
id: b.id, name: b.card.name || '悬赏', desc: bountyText((eff && eff.descRaw) || b.card.desc || '', b.card), iconKind: 'choice', iconId: b.card.effectId || 'bounty',
|
||||
counter: left, counterText: left == null ? '之后的每场作战' : `还剩 ${left} 场作战`,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
+55
-9
@@ -31,6 +31,8 @@
|
||||
// botPrepBeginSteps / botPrepEndSteps (and planLayoutSteps, arrangeSteps, createRehearsalSteps) are step
|
||||
// generators that yield between whole actions (never with a transient board) — the same actions in the same
|
||||
// order as the one-shot functions (runSteps), hence the same rng draws and decisions.
|
||||
// The summon cards of the placed operators (赫默's 医疗探机, 伺夜's 狼群 …; user playtest #6) are placed after
|
||||
// them on the best remaining tiles, 凯瑟琳's 支援装置 next to the best operator no device faces yet, facing it.
|
||||
// 5. equip items on the strongest deployed damage dealers (consume-on-equip items / Arts only with a handler)
|
||||
// 6. resolve the temp slots, keep one hand slot free, then Ready.
|
||||
// Placement quality (tools/matchrun sweeps, research-faithful waves): the planner beats random layouts by ≈ 8 points
|
||||
@@ -38,7 +40,8 @@
|
||||
|
||||
import { GEO } from '../../shared/constants.js';
|
||||
import { deriveSeed } from '../sim/rng.js';
|
||||
import { freeSlot, legalTiles, positionClass, parseKey, tileKey, FIELD, pieceDir, boardTileOf, BOSS_MIRROR_COL } from './board.js';
|
||||
import { ASPD_MIN } from '../sim/constants.js';
|
||||
import { freeSlot, legalTiles, canPlace, positionClass, parseKey, tileKey, FIELD, pieceDir, boardTileOf, BOSS_MIRROR_COL } from './board.js';
|
||||
import { rotateOffset, normDir, mirrorDir, oppositeDir } from '../sim/dir.js';
|
||||
import { itemKey } from './gamedata.js';
|
||||
import { computeBonds } from './bondsMeta.js';
|
||||
@@ -497,7 +500,7 @@ export function rangeTiles(rec, r, c, dir = 'RIGHT') {
|
||||
function dpsOf(rec) {
|
||||
const st = rec && rec.stats;
|
||||
if (!st || isHealer(rec)) return 0;
|
||||
const interval = Math.max(0.2, (st.bat || 1) * 100 / Math.max(10, st.aspd || 100));
|
||||
const interval = Math.max(0.2, (st.bat || 1) * 100 / Math.max(ASPD_MIN, st.aspd || 100));
|
||||
const d = (st.atk || 0) / interval;
|
||||
return rec.attackKind === 'none' ? d * 0.4 : d;
|
||||
}
|
||||
@@ -920,11 +923,7 @@ export function botPrepEnd(m, ps, job = null) {
|
||||
/** botPrepEnd as a step generator (see botPrepBeginSteps). */
|
||||
export function* botPrepEndSteps(m, ps, job = null) {
|
||||
if (!ps.alive || ps.ready) return;
|
||||
if (job && job.done && job.best !== job.plans[0]) {
|
||||
// summons placed around the default plan would sit on the new plan's tiles: back to their stacks, re-placed after
|
||||
liftTokens(ps);
|
||||
yield* applyPlanSteps(m, ps, job.chosen, job.best);
|
||||
}
|
||||
if (job && job.done && job.best !== job.plans[0]) yield* applyPlanSteps(m, ps, job.chosen, job.best);
|
||||
// 5. temp → hand / sell / destroy; keep one hand slot free for next round's merges
|
||||
resolveTemp(m, ps);
|
||||
if (freeSlot(ps.hand) < 0) sellWeakestHand(m, ps);
|
||||
@@ -1055,8 +1054,13 @@ export function* arrangeSteps(m, ps, { final = false, defer = false } = {}) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Put the chosen pieces on their planned tiles, fill what is still off the board, then place summons. */
|
||||
/**
|
||||
* Put the chosen pieces on their planned tiles, fill what is still off the board, then place summons. Placed summons go
|
||||
* back to their stacks first: they would sit on the plan's tiles (an operator moved onto one swaps it elsewhere), and a
|
||||
* 凯瑟琳 device left beside a tile its operator moved away from would face nothing (QA, playtest #6).
|
||||
*/
|
||||
function* applyPlanSteps(m, ps, chosen, target) {
|
||||
liftTokens(ps);
|
||||
// move pieces onto their targets (board → board moves swap; hand → board may swap an occupant back to the hand)
|
||||
for (let pass = 0; pass < 3; pass++) {
|
||||
let moved = false;
|
||||
@@ -1097,12 +1101,53 @@ function liftTokens(ps) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Summons that help the operator on the tile they face (range 1-1: their own tile + the one in front) instead of
|
||||
* covering the enemy path: 凯瑟琳's 爬行号·防护单元 (a hand piece since user playtest #6).
|
||||
*/
|
||||
const FRONT_SUPPORT_TOKENS = new Set(['token_10041_cathy_catsld']);
|
||||
/** Device tile offsets around an operator (behind it — enemies come from the gates on the right —, above, below, in front) and the facing that points back at it. */
|
||||
const SUPPORT_SPOTS = Object.freeze([[0, -1, 'RIGHT'], [1, 0, 'DOWN'], [-1, 0, 'UP'], [0, 1, 'LEFT']]);
|
||||
|
||||
/**
|
||||
* A tile + direction for a front-support device: next to the most valuable operator no device faces yet (blockers
|
||||
* first — they take the hits —, then DPS), pointing at it; null when none is free.
|
||||
*/
|
||||
function supportSpot(m, ps, p) {
|
||||
const rec = m.gd.token(p.id);
|
||||
if (!rec) return null;
|
||||
const map = ps.deployMap();
|
||||
const pos = positionClass(rec);
|
||||
const faced = new Set();
|
||||
for (const [k, q] of ps.board) {
|
||||
if (q.kind !== 'token' || !FRONT_SUPPORT_TOKENS.has(q.id)) continue;
|
||||
const [r, c] = parseKey(k);
|
||||
const [dr, dc] = rotateOffset(0, 1, pieceDir(q));
|
||||
faced.add(tileKey(r + dr, c + dc));
|
||||
}
|
||||
const ops = [...ps.board.entries()].filter(([k, q]) => q.kind === 'chess' && !faced.has(k)).map(([k, q]) => [k, m.gd.chess(q.id)]).filter(([, c]) => c)
|
||||
.sort((a, b) => Number(isBlocker(b[1])) - Number(isBlocker(a[1])) || dpsOf(b[1]) - dpsOf(a[1]) || (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0));
|
||||
for (const [k] of ops) {
|
||||
const [r, c] = parseKey(k);
|
||||
for (const [dr, dc, dir] of SUPPORT_SPOTS) {
|
||||
const t = tileKey(r + dr, c + dc);
|
||||
if (!ps.board.has(t) && canPlace(map, pos, r + dr, c + dc)) return [r + dr, c + dc, dir];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Placeable summons from the hand / temp onto the best free tiles (one per stack count). */
|
||||
function* placeTokensSteps(m, ps) {
|
||||
for (const p of [...ps.hand, ...ps.temp]) {
|
||||
if (!p || p.kind !== 'token') continue;
|
||||
for (let n = p.count || 1; n > 0; n--) {
|
||||
yield;
|
||||
if (FRONT_SUPPORT_TOKENS.has(p.id)) {
|
||||
const spot = supportSpot(m, ps, p);
|
||||
if (!spot || !tryDo(() => ps.move(p.uid, { area: 'board', row: spot[0], col: spot[1] }, spot[2]))) break;
|
||||
continue;
|
||||
}
|
||||
const plan = yield* planLayoutSteps(m, ps, [p], LAYOUT_PARAMS, { occupied: new Set(ps.board.keys()) });
|
||||
const k = plan.get(p.uid);
|
||||
if (!k) break;
|
||||
@@ -1156,7 +1201,8 @@ function resolveTemp(m, ps) {
|
||||
tryDo(() => ps.destroy(p.uid));
|
||||
}
|
||||
}
|
||||
// anything left (e.g. sells failed): final sweep — tokens cannot be sold, they are dropped with the temp slot
|
||||
// anything left (e.g. sells failed): final sweep — tokens cannot be sold, they are dropped with the temp slot (and
|
||||
// come back at the next round start, PlayerState.startRound)
|
||||
for (let i = 0; i < ps.temp.length; i++) {
|
||||
const p = ps.temp[i];
|
||||
if (!p) continue;
|
||||
|
||||
@@ -35,9 +35,9 @@ const paramsOf = (ctx, item) => {
|
||||
};
|
||||
|
||||
/** random chess sharing at least one bond with `bonds`, tier ≤ maxTier */
|
||||
function rollSameBond(ctx, bonds, maxTier) {
|
||||
function rollSameBond(ctx, bonds, maxTier, exclude = null) {
|
||||
const set = new Set(bonds);
|
||||
return ctx.rollChess({ maxTier, filter: (id) => { const c = ctx.gd.chess(id); return !!(c && Array.isArray(c.bonds) && c.bonds.some((b) => set.has(b))); } });
|
||||
return ctx.rollChess({ maxTier, filter: (id) => { const c = ctx.gd.chess(id); return !!(c && Array.isArray(c.bonds) && c.bonds.some((b) => set.has(b))) && !(exclude && exclude.includes(id)); } });
|
||||
}
|
||||
|
||||
const ITEM_HANDLERS = {
|
||||
@@ -127,7 +127,8 @@ const ITEM_HANDLERS = {
|
||||
const n = Math.max(1, int(paramsOf(ctx, ev.item).refresh_cnt, 3));
|
||||
const bonds = ctx.pieceBonds(ev.target.uid);
|
||||
const ids = [];
|
||||
for (let k = 0; k < n; k++) { const id = rollSameBond(ctx, bonds, ctx.shopLevel()); if (id) ids.push(id); }
|
||||
// a pick-one offer never shows one operator twice (user playtest #6 item 19); fewer cards when the pool runs out
|
||||
for (let k = 0; k < n; k++) { const id = rollSameBond(ctx, bonds, ctx.shopLevel(), ids); if (id) ids.push(id); }
|
||||
if (ids.length) ctx.offerChess(ids, { source: 'item' });
|
||||
},
|
||||
},
|
||||
@@ -141,7 +142,12 @@ const ITEM_HANDLERS = {
|
||||
const original = ctx.gd.baseIdOf(target.id);
|
||||
ctx.destroyPiece(target.uid);
|
||||
const ids = [];
|
||||
for (let k = 0; k < n; k++) { const id = ctx.rollChess({ tier }); if (id) ids.push(id); }
|
||||
// different operators of the target's tier, topped up from the tier below like the promotion reward (item 19)
|
||||
for (let k = 0; k < n; k++) {
|
||||
let id = null;
|
||||
for (let t = tier; t >= 1 && !id; t--) id = ctx.rollChess({ tier: t, filter: (x) => !ids.includes(x) });
|
||||
if (id) ids.push(id);
|
||||
}
|
||||
if (ids.length) ctx.offerChess(ids, { source: 'item', tier });
|
||||
// co-op: next prep, send the original chess to the teammate with the most members of its bonds
|
||||
const mates = ctx.teammates();
|
||||
|
||||
+59
-7
@@ -3,7 +3,13 @@
|
||||
//
|
||||
// Generation (generateDraft): the family is a weighted pick from choices.schedule[modeId].rounds[r].families; the card
|
||||
// count is `cards` (co-op 6, solo 3):
|
||||
// bounty 悬赏决策 distinct cards.bounty entries with tier ∈ bountyTiers whose enemy exists and is active in the mode
|
||||
// bounty 悬赏决策 distinct cards.bounty entries with tier ∈ bountyTiers whose enemy exists and is active in the mode,
|
||||
// draft cards only (`draftBounty`, choices.json `draft`: the PRTS 下半 记录 §机变阶段 "敌人轮选" table —
|
||||
// never 战术特训, which only 法术教鞭 creates, nor the hidden 鸭爵 set; the 7 multi-round "之后 / 后续
|
||||
// 的每场作战" cards stay in, as the table lists them); each card carries its official rich text
|
||||
// `descRaw` (the battles in blue "下场作战" / "两场作战"); a multi-round card lasts
|
||||
// MULTI_ROUND_BOUNTY_BATTLES battles and says so (`bountyBattles` / `bountyText`: the user's call
|
||||
// after playtest #6 — "我不记得有过多轮悬赏")
|
||||
// supply 道具补给 random normal EQUIP shop items with tier in supplyTiers [lo, hi] (duplicates allowed)
|
||||
// shop 机密商店 random normal EQUIP shop items of any tier I–VI (duplicates allowed); FREE — the official card
|
||||
// text is "无需消耗资金,获得装备补给" (research 01 A4/04 addendum), so the price is 0
|
||||
@@ -32,6 +38,38 @@ import { weightedPick } from './waves.js';
|
||||
|
||||
export const FAMILY_NAMES = { bounty: '悬赏决策', supply: '道具补给', shop: '机密商店', tactic: '战术决策' };
|
||||
|
||||
/**
|
||||
* Battles a multi-round bounty card lasts (data `rounds` 99, official text "之后 / 后续的<@ba.vdown>每场</>作战":
|
||||
* 山海众头目·多轮悬赏, 多轮悬赏·假想敌 ×6, 法术大师A2·多轮战术特训). The user does not remember any multi-round bounty
|
||||
* (playtest #6 answer, "我不记得有过多轮悬赏"): until that is confirmed otherwise every such card lasts two battles,
|
||||
* exactly like the "接下来两场作战" cards, and its text says so in the same blue (`bountyText`). `null` restores the
|
||||
* official "每场" (every later battle, red text) everywhere: the draft, 教鞭 / 神秘顾客, the bounty list and the effects
|
||||
* column (DESIGN §20).
|
||||
*/
|
||||
export const MULTI_ROUND_BOUNTY_BATTLES = 2;
|
||||
/** A multi-round bounty card ("之后 / 后续的每场作战"; choices.json `multiRound`, or data `rounds` ≥ 90). */
|
||||
export const isMultiRoundBounty = (c) => !!c && (c.multiRound === true || Number(c.rounds) >= 90);
|
||||
/** The battles a bounty card's enemies come for (MULTI_ROUND_BOUNTY_BATTLES for a multi-round card), 1–99. */
|
||||
export function bountyBattles(c) {
|
||||
if (isMultiRoundBounty(c) && Number.isInteger(MULTI_ROUND_BOUNTY_BATTLES)) return MULTI_ROUND_BOUNTY_BATTLES;
|
||||
const r = Number(c && c.rounds);
|
||||
return Math.max(1, Math.min(99, Number.isInteger(r) ? r : 1));
|
||||
}
|
||||
const N_ZH = ['', '一', '两', '三', '四', '五'];
|
||||
/**
|
||||
* A bounty text as the card lasts: a multi-round card's "之后的 / 后续每场作战" (rich `<@ba.vdown>每场</>` or plain) reads
|
||||
* "接下来<@ba.vup>两场作战</>" like the official two-battle cards while MULTI_ROUND_BOUNTY_BATTLES is set; any other
|
||||
* text is returned as is.
|
||||
*/
|
||||
export function bountyText(text, c) {
|
||||
if (typeof text !== 'string' || !text || !isMultiRoundBounty(c) || !Number.isInteger(MULTI_ROUND_BOUNTY_BATTLES)) return text;
|
||||
const n = MULTI_ROUND_BOUNTY_BATTLES;
|
||||
const battles = `${N_ZH[n] || n}场作战`;
|
||||
return text
|
||||
.replace(/(?:之后的|后续的?)<@ba\.vdown>每场<\/>作战/g, `接下来<@ba.vup>${battles}</>`)
|
||||
.replace(/(?:之后的|后续的?)每场作战/g, `接下来${battles}`);
|
||||
}
|
||||
|
||||
function scheduleFor(gd, round) {
|
||||
const sch = gd.choices.schedule && gd.choices.schedule[gd.modeId];
|
||||
const r = sch && sch.rounds && sch.rounds[String(round)];
|
||||
@@ -104,17 +142,31 @@ export function cardTargetBonds(gd, effectId) {
|
||||
return out.length ? out : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A bounty card the 悬赏决策 draft may offer (choices.json cards.bounty `draft`, tools/build-data.mjs
|
||||
* bountyDraftExclusion: the PRTS "敌人轮选" table — no 战术特训, no 鸭爵 set; user playtest #6 item 4). Data without the
|
||||
* flag (older builds, test fixtures) falls back to the kill bounties.
|
||||
*/
|
||||
export function draftBounty(c) {
|
||||
if (!c) return false;
|
||||
if (typeof c.draft === 'boolean') return c.draft;
|
||||
return c.payout !== 'perfect';
|
||||
}
|
||||
|
||||
function buildCards(gd, rng, family, n, sch, { stageId = null, bondAvailable = null } = {}) {
|
||||
const cardsData = gd.choices.cards || {};
|
||||
if (family === 'bounty') {
|
||||
const tiers = Array.isArray(sch.bountyTiers) && sch.bountyTiers.length ? sch.bountyTiers : [1, 2];
|
||||
const pool = (Array.isArray(cardsData.bounty) ? cardsData.bounty : []).filter((c) => c && tiers.includes(c.tier) && gd.enemy(c.enemyKey) && !gd.inactiveEnemies.has(c.enemyKey));
|
||||
const pool = (Array.isArray(cardsData.bounty) ? cardsData.bounty : []).filter((c) => c && draftBounty(c) && tiers.includes(c.tier) && gd.enemy(c.enemyKey) && !gd.inactiveEnemies.has(c.enemyKey));
|
||||
const pick = pool.slice();
|
||||
rng.shuffle(pick);
|
||||
return pick.slice(0, n).map((c) => ({
|
||||
kind: 'bounty', id: c.effectId, name: c.name, desc: c.desc || '', tier: c.tier, coin: c.coin, payout: c.payout,
|
||||
rounds: c.rounds, enemyKey: c.enemyKey, count: c.count,
|
||||
}));
|
||||
return pick.slice(0, n).map((c) => {
|
||||
const eff = typeof gd.effect === 'function' ? gd.effect(c.effectId) : null;
|
||||
return {
|
||||
kind: 'bounty', id: c.effectId, name: c.name, desc: bountyText(c.desc || '', c), descRaw: bountyText((eff && eff.descRaw) || null, c), tier: c.tier, coin: c.coin,
|
||||
payout: c.payout, rounds: bountyBattles(c), enemyKey: c.enemyKey, count: c.count,
|
||||
};
|
||||
});
|
||||
}
|
||||
if (family === 'supply' || family === 'shop') {
|
||||
let lo = 1;
|
||||
@@ -144,7 +196,7 @@ function buildCards(gd, rng, family, n, sch, { stageId = null, bondAvailable = n
|
||||
/** Public card view. */
|
||||
export function cardView(c) {
|
||||
const v = { idx: c.idx, kind: c.kind, id: c.id, name: c.name, desc: c.desc, tier: c.tier ?? null };
|
||||
if (c.kind === 'bounty') Object.assign(v, { coin: c.coin, payout: c.payout, rounds: c.rounds, enemyKey: c.enemyKey, count: c.count });
|
||||
if (c.kind === 'bounty') Object.assign(v, { descRaw: c.descRaw ?? null, coin: c.coin, payout: c.payout, rounds: c.rounds, enemyKey: c.enemyKey, count: c.count });
|
||||
if (c.kind === 'item') v.price = 0;
|
||||
if (c.kind === 'tactic') Object.assign(v, { team: c.team, tacticKind: c.tacticKind });
|
||||
return v;
|
||||
|
||||
+43
-7
@@ -18,14 +18,19 @@
|
||||
// HeadlessJob / runHeadless(battle) step a battle to its end — in wall-clock-bounded slices (bots, takeovers
|
||||
// under a real scheduler) or at once (verification, virtual time) — and keep a
|
||||
// progress timeline [[gt, killed, total]] for the teammates' waiting UI
|
||||
// (联防: [gt, killed, total, left] — left = spec.js uniteLeft, the leakers'
|
||||
// enemies still standing, for their live counter; user playtest #6 item 7)
|
||||
// HeadlessPacer real-time pacing of a dynamic set of server-run battles without snapshots
|
||||
// (boss fields that share the pool while other fields run on clients); a
|
||||
// takeover's fast-forward is spread over the pacing intervals
|
||||
// specBounds(spec, gd) / validateClientResult(spec, result, { gd })
|
||||
// a client's b.result is accepted only when it is plausible for its spec;
|
||||
// the returned result is rebuilt from whitelisted fields (never the raw object)
|
||||
// uniteBillBounds(spawns, gd) 联防: per leaker, the most survivors settlement can bill (sent in + the
|
||||
// offspring bound) — the live counter's clamp
|
||||
// syntheticResult(players, progress) stand-in when a boss field's client never reported
|
||||
import { TICK, SNAPSHOT_EVERY } from '../sim/constants.js';
|
||||
import { uniteLeft } from '../sim/spec.js';
|
||||
|
||||
export const MAX_TICKS_PER_INTERVAL = 8;
|
||||
export const INTERVAL_MS = 1000 / 30;
|
||||
@@ -251,20 +256,34 @@ export const HEADLESS_SLICE_MS = 8;
|
||||
export const CATCHUP_TICKS_PER_INTERVAL = 240;
|
||||
const perfNow = () => (globalThis.performance ? globalThis.performance.now() : Date.now());
|
||||
|
||||
/**
|
||||
* A timeline sample of a battle: [gt, killed, total], plus — 联防 — the leakers' enemies still standing
|
||||
* (spec.js uniteLeft; omitted when unknown).
|
||||
*/
|
||||
export function timelineSample(b) {
|
||||
const s = [Number(b && b.time) || 0, Number(b && b.killed) || 0, Number(b && b.total) || 0];
|
||||
if (b && b.kind === 'unite') {
|
||||
let left = null;
|
||||
try { left = uniteLeft(b); } catch { left = null; }
|
||||
if (left) s.push(left);
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
/**
|
||||
* A server-run battle stepped to its end, in one go or in wall-clock-bounded slices (a low-power host must not stall
|
||||
* its event loop for the ~0.1–1 s a whole battle takes, several times at once when bots fight): `run(budgetMs)` steps
|
||||
* until the battle ends (→ true) or the budget is used (→ false; checked every 16 ticks). `timeline` grows while it
|
||||
* runs ([[gt, killed, total]] every TIMELINE_EVERY game seconds, then the final state); `output()` once done gives
|
||||
* `{ battle, result, timeline, crashed }`. A throwing battle is force-ended, then replaced by a DeadBattle; the run is
|
||||
* bounded by HARD_CAP_SECONDS.
|
||||
* runs (timelineSample: [gt, killed, total(, left)] every TIMELINE_EVERY game seconds, then the final state);
|
||||
* `output()` once done gives `{ battle, result, timeline, crashed }`. A throwing battle is force-ended, then replaced by
|
||||
* a DeadBattle; the run is bounded by HARD_CAP_SECONDS.
|
||||
*/
|
||||
export class HeadlessJob {
|
||||
constructor(battle, { onError = null, players = [] } = {}) {
|
||||
this.battle = battle;
|
||||
this.onError = onError;
|
||||
this.players = players;
|
||||
this.timeline = [[0, 0, Number(battle && battle.total) || 0]];
|
||||
this.timeline = [timelineSample(battle)];
|
||||
this.every = Math.max(1, Math.round(TIMELINE_EVERY / TICK));
|
||||
this.cap = Math.ceil(HARD_CAP_SECONDS / TICK);
|
||||
this.n = 0;
|
||||
@@ -284,7 +303,7 @@ export class HeadlessJob {
|
||||
b.step();
|
||||
this.n++;
|
||||
k++;
|
||||
if (this.n % this.every === 0) this.timeline.push([Number(b.time) || 0, Number(b.killed) || 0, Number(b.total) || 0]);
|
||||
if (this.n % this.every === 0) this.timeline.push(timelineSample(b));
|
||||
if (timed && (k & 15) === 0 && !b.finished && now() - t0 >= budgetMs) return false;
|
||||
}
|
||||
if (!b.finished) b.forceEnd('timeout');
|
||||
@@ -307,7 +326,7 @@ export class HeadlessJob {
|
||||
for (const pid of this.players) perPlayer[pid] = emptyPerPlayer();
|
||||
result = { time: Number(b.time) || 0, reason: 'forced', perPlayer, killed: 0, total: 0, errors: 1, synthetic: true };
|
||||
}
|
||||
this.timeline.push([Number(b.time) || 0, Number(b.killed) || 0, Number(b.total) || 0]);
|
||||
this.timeline.push(timelineSample(b));
|
||||
this.done = true;
|
||||
this._out = { battle: b, result, timeline: this.timeline, crashed: this.crashed };
|
||||
}
|
||||
@@ -324,7 +343,7 @@ export function runHeadless(battle, opts = {}) {
|
||||
return job.output();
|
||||
}
|
||||
|
||||
/** Timeline sample at game time `gt`: [gt, killed, total] of the last sample ≤ gt. */
|
||||
/** Timeline sample at game time `gt`: [gt, killed, total(, left)] of the last sample ≤ gt. */
|
||||
export function timelineAt(timeline, gt) {
|
||||
if (!Array.isArray(timeline) || !timeline.length) return [0, 0, 0];
|
||||
let lo = 0, hi = timeline.length - 1;
|
||||
@@ -626,6 +645,23 @@ export function specBounds(spec, gd = null) {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 联防: the most enemies settlement can bill each leaker — what it sent in plus what those enemies can leave behind
|
||||
* (splits / summons within offspringPerParent, maxTotal without a data bound): validateClientResult's (key, leaker)
|
||||
* budgets summed per leaker. Bounds the live counter (Match._uniteLeft; user playtest #6 item 7).
|
||||
* @param {object[]} spawns the 联防 spawns (unite.js plan.leaked: { enemyKey, sourcePlayerId, … })
|
||||
* @returns {Map<string, number>} leaker id → bound
|
||||
*/
|
||||
export function uniteBillBounds(spawns, gd = null) {
|
||||
const B = specBounds({ spawns: Array.isArray(spawns) ? spawns : [] }, gd);
|
||||
const out = new Map();
|
||||
for (const [ks, n] of [...B.keySourceCounts, ...B.derivedSourceCounts]) {
|
||||
const pid = ks.slice(ks.lastIndexOf('|') + 1);
|
||||
if (pid) out.set(pid, (out.get(pid) || 0) + n);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const finiteIn = (v, lo, hi) => typeof v === 'number' && Number.isFinite(v) && v >= lo && v <= hi;
|
||||
const sameMods = (a, b) => {
|
||||
const ka = a && typeof a === 'object' ? Object.keys(a).filter((k) => a[k] !== undefined).sort() : [];
|
||||
|
||||
@@ -15,13 +15,19 @@
|
||||
// * Overtime: bossTurnHpReduceTime counts REAL seconds like the level's 120 s maxPlayTime (which runs out first; the
|
||||
// battle goes on): from 150 real s (300 game s on the 2× field clock) the team loses bossOvertimeDrainPerSec (1) LP
|
||||
// per real second (gamedata.js bossOvertimeDue); m.public.deadline = the 120 s countdown, m.public.overtimeAt = the
|
||||
// drain start. Minion / boss leaks cost their `lpr`; team LP 0 ⇒ defeat (all fields force-ended); pool 0 ⇒ victory.
|
||||
// drain start. Minion / boss leaks cost their `lpr`; team LP 0 ⇒ defeat (all fields force-ended; PRTS 卫戍协议:盟约 下半
|
||||
// "…使目标生命值扣除至0,则无视倒计时直接失败"); pool 0 ⇒ victory. The first of the two the server registers decides
|
||||
// (Match._finalEnding): a report that arrives after the team LP ran out credits nothing (user playtest #6 item 5).
|
||||
// * Pool HP is a float that never keeps float dust: the hit that would leave less than BOSS_POOL_MIN_HP (1) takes the
|
||||
// rest, so the pool is 0 or ≥ 1 and a leader shown at 0 HP is down (sim/constants.js; the browser's LocalBossPool
|
||||
// follows the same rule, server/sim/spec.js). Before, the per-player crediting of a report could leave 3.6e-12 that
|
||||
// the browser could never deal (user playtest #6 item 5).
|
||||
// * No IN_BATTLE layer gains (flags.layerGainsEnabled = false).
|
||||
// * Hidden Core eligibility (after an R14 win): difficulty in hiddenCore.difficulties, the mode has a hidden round,
|
||||
// Σ activated layers of the alive players measured at the end of the boss round's prep > threshold (solo 350 /
|
||||
// co-op 1200) and team LP > minTeamLpExclusive (1).
|
||||
|
||||
import { BOSS_ROW_OFFSET, COLS } from '../sim/constants.js';
|
||||
import { BOSS_ROW_OFFSET, COLS, BOSS_POOL_MIN_HP } from '../sim/constants.js';
|
||||
import { mirrorDir, normDir } from '../sim/dir.js';
|
||||
|
||||
export const BOSS_HIT_STEPS = [0.2, 0.5, 0.8];
|
||||
@@ -63,7 +69,10 @@ export function bossPoolHp(gd, bossId, aliveCount) {
|
||||
return Math.max(1, Math.round(base * tune));
|
||||
}
|
||||
|
||||
/** The one HP pool every boss field damages. `damage(playerId, amount)` is called by the sim. */
|
||||
/**
|
||||
* The one HP pool every boss field damages. `damage(playerId, amount)` is called by the sim (server-run fields) and by
|
||||
* Match._creditBoss (client reports, per player); the hit that would leave less than BOSS_POOL_MIN_HP takes the rest.
|
||||
*/
|
||||
export class SharedBossPool {
|
||||
constructor(hp, { onHit = null } = {}) {
|
||||
this.maxHp = Math.max(1, hp);
|
||||
@@ -76,8 +85,8 @@ export class SharedBossPool {
|
||||
damage(playerId, amount) {
|
||||
const a = Number(amount);
|
||||
if (!Number.isFinite(a) || a <= 0 || this.hp <= 0) return 0;
|
||||
const dealt = Math.min(this.hp, a);
|
||||
this.hp -= dealt;
|
||||
const dealt = this.hp - a < BOSS_POOL_MIN_HP ? this.hp : a;
|
||||
this.hp -= dealt; // exactly 0 when the rest is taken (x − x): no float dust
|
||||
if (playerId != null) this.byPlayer.set(playerId, (this.byPlayer.get(playerId) || 0) + dealt);
|
||||
if (this.onHit) {
|
||||
try { this.onHit(playerId, dealt); } catch { /* reported by the caller */ }
|
||||
|
||||
@@ -440,18 +440,28 @@ export class GameData {
|
||||
}
|
||||
|
||||
/**
|
||||
* Placeable (hand) tokens a chess sends to the hand when placed on the board: [{ tokenId, count }] — its TALENT
|
||||
* summons (tokens.json `placeable`: 海嗣 / 狼群 / 流形); a skill's summon (赫默's drone, 巫恋's doll) only appears in
|
||||
* battle when the skill fires (tools/build-data.mjs buildTokens).
|
||||
* Placeable (hand) tokens a chess sends to the hand when placed on the board: [{ tokenId, count }] — its manually
|
||||
* deployable summons (tokens.json `placeable`: 医疗探机, 诅咒娃娃, 海嗣, 狼群, 流形, 爬行号·防护单元; user playtest #6)
|
||||
* that the chess makes under `loadout` ({ skillIndex } from shared/protocol.js resolveLoadout; absent ⇒ its default
|
||||
* skill): the owner variant's `sources` (`bySkill[skillIndex]` for a non-default skill) name a talent or a skill —
|
||||
* 赫默 / 巫恋 on S1 make no drone / doll. `count` = the summon's deploy limit (PRTS 卫戍协议/帮助 "根据召唤物部署数量
|
||||
* 上限(非初始持有量),发送等量召唤物至手牌区": 凯瑟琳 2 of her 3 devices).
|
||||
*/
|
||||
placeableTokens(chessId) {
|
||||
placeableTokens(chessId, loadout = null) {
|
||||
const c = this.chess(chessId);
|
||||
if (!c || !Array.isArray(c.tokens)) return [];
|
||||
const out = [];
|
||||
for (const tid of c.tokens) {
|
||||
const t = this.token(tid);
|
||||
if (!t || t.kind !== 'summon' || t.displayType !== 'DEFAULT' || t.placeable !== true) continue;
|
||||
const count = posIntOr(t.deployLimit, 1);
|
||||
if (!t || t.kind !== 'summon' || t.placeable !== true) continue;
|
||||
const vs = t.variants && typeof t.variants === 'object' ? t.variants : {};
|
||||
const v = vs[chessId] ?? vs[String(chessId).replace(/_b$/, '_a')] ?? null;
|
||||
if (v) {
|
||||
const alt = loadout && Number.isInteger(loadout.skillIndex) && v.bySkill ? v.bySkill[loadout.skillIndex] : null;
|
||||
const src = Array.isArray(alt?.sources) ? alt.sources : Array.isArray(v.sources) ? v.sources : [];
|
||||
if (!src.includes('talent') && !src.includes('skill')) continue;
|
||||
}
|
||||
const count = posIntOr(v?.stats?.deployLimit, posIntOr(t.deployLimit, 1));
|
||||
out.push({ tokenId: tid, count: Math.min(count, 9) });
|
||||
}
|
||||
return out;
|
||||
|
||||
@@ -455,8 +455,9 @@ function hostAction(gd, wave, { fly, token = false }) {
|
||||
* alone when the host spawns nothing, e.g. a walker bounty in a FLY round), then the whole list is spread over the
|
||||
* action window: unit j at preDelay + j·max(W/total, 0.05·W). Returned here: the bounty specs only (a bounty's
|
||||
* consecutive units share a spec); the host action's own units are NOT re-timed — `withBounties` gives the exact
|
||||
* combined list. Multi-round bounties ("之后的每场作战", `rounds` > 1) spawn in every battle of the player while they
|
||||
* last — the Final Assault and the Hidden Core included (`side`: the player's half of the boss field, see routeByMotion).
|
||||
* combined list. Bounties of several battles (`rounds` > 1: "接下来两场作战", and the multi-round cards, which last
|
||||
* choices.js MULTI_ROUND_BOUNTY_BATTLES) spawn in every battle of the player while they last — the Final Assault and the
|
||||
* Hidden Core included (`side`: the player's half of the boss field, see routeByMotion).
|
||||
* @param {Array<{ id: string, card: object }>} bounties
|
||||
*/
|
||||
export function bountySpawns(gd, round, wave, bounties, playerId, { solo = false, side = null } = {}) { // eslint-disable-line no-unused-vars
|
||||
|
||||
Reference in new issue
Block a user