diff --git a/docs/SIM.md b/docs/SIM.md index 5ca88cfb..cfbad318 100644 --- a/docs/SIM.md +++ b/docs/SIM.md @@ -715,7 +715,7 @@ registration order. `battle.off(handle)` / `battle.off(name, fn)` / `battle.offO | `dollSwitch` | `{ unit, reason, done }` | content switches a 傀儡师 to its <替身> now (归溟幽灵鲨 S2 "技能结束后立刻切换为<替身>": no lethal HP loss); its trait does it unless it already is one or is not on the field, and sets `done` | | `kill` | `{ killer, victim }` | victim HP reached 0 (a handler may revive by restoring HP) | | `death` | `{ unit, reason:'killed'|'leak'|'retreat'|'merchant'|'expired'|'forcedExit', killer }` | unit removed (`'forcedExit'`: an operator entering 联防 knocked out, §1.1) | -| `skillStart` / `skillEnd` | `{ unit, skill, reason }` | mutate `skill.ammoLeft` / `skill.timeLeft` in skillStart | +| `skillStart` / `skillEnd` | `{ unit, skill, reason }` | mutate `skill.ammoLeft` / `skill.timeLeft` in skillStart (bullets added there raise `skill.ammoMax`, the ammo bar's full mark) | | `ammoUsed` | `{ unit, left, skill }` | per ammo consumed | | `spGain` | `{ unit, amount, reason:'time'|'attack'|'hurt'|'init'|…, skill }` | mutable `amount` (time gains fire every tick) | | `beforeStatus` | `{ source, target, status, duration, value, cancel }` | set `cancel` (e.g. 浓缩嗅盐) | @@ -825,7 +825,8 @@ or guard with a per-unit flag while dealing it. When the guard trips, the logged free heals). - Runtime helpers on `unit.skill`: `activate(reason, {free})`, `end(reason)`, `stop()`, `addAmmo(n)`, `extend(s)`, `addCharge(n)`, `gainSp(n, reason)`, `addTriggerRange(fn)`; fields `sp, spCost (= floor(base×spCostMul + spCostFlat)), spCostMul, charges, - maxCharges, active, timeLeft, ammoLeft, activations, kind, rule, bb`. + maxCharges, active, timeLeft, ammoLeft, ammoMax, activations, kind, rule, bb` (`ammoMax`: the most bullets the running ammo skill + has held — set at activation, raised by skillStart additions and `addAmmo` above it; 0 when inactive). ### 7.2 Kits @@ -1147,7 +1148,8 @@ Unknown subprofessions fall back to the profession default (test `professions.te ## 9. Wire format (snapshot.js, DESIGN §8.2) - `snapshot()` → `{ fieldId, t, units: [[id, x, y, hp, maxHp, sp, spMax, flags, anim]], dp, killed, total, dps?, boss?, down?, elem? }`. - `sp/spMax` show remaining duration/ammo as a draining bar while a timed skill is active. Units in DIE state stay 0.8 s. + `sp/spMax` show remaining duration/ammo as a draining bar while a timed skill is active (ammo: `ammoLeft / ammoMax`, the + activation's real total — 拉特兰's and 逃犯引渡手续's extra bullets included, community report #35). Units in DIE state stay 0.8 s. `down: [[id, respawnAt, respawnTime, state, row, col]]` (only when non-empty) = operators lying down waiting to redeploy (`Battle.isDown(u)`: reason `'killed'` or `FORCED_EXIT` (§1.1 carryState `down`), or a forced exit `'retreat'` / `'merchant'` (GitHub #60) — every removal but the 突袭 `'raid'` —, not removed for good, deployed at least once, a finite diff --git a/server/sim/skills.js b/server/sim/skills.js index d2989afd..7e709a11 100644 --- a/server/sim/skills.js +++ b/server/sim/skills.js @@ -97,6 +97,7 @@ export class SkillRuntime { this.active = false; this.timeLeft = 0; this.ammoLeft = 0; + this.ammoMax = 0; // ammo kind: the most bullets this activation held (snapshot's draining bar) this.pending = false; // instant/charges: next attack uses spec.attack this.activations = 0; this.lastStart = -Infinity; @@ -167,6 +168,7 @@ export class SkillRuntime { this.pending = false; this.timeLeft = 0; this.ammoLeft = 0; + this.ammoMax = 0; this.charges = 0; this.sp = 0; this._trigKeys = null; @@ -401,6 +403,7 @@ export class SkillRuntime { this.active = true; this.timeLeft = this.kind === 'duration' ? Math.max(0.01, this.duration) : (this.kind === 'ammo' && this.duration > 0 ? this.duration : Infinity); this.ammoLeft = this.kind === 'ammo' ? Math.max(1, this.ammo) : 0; + this.ammoMax = this.ammoLeft; this._applyMods(); } else { // instant / charges @@ -413,6 +416,9 @@ export class SkillRuntime { u.skillAnimUntil = b.time + 0.5; this._call('onStart', { reason }); if (b._hooks.skillStart) b.emit('skillStart', { unit: u, skill: this, reason }); + // bullets added in skillStart (拉特兰's ×(1.05 + 0.015 × layers), 逃犯引渡手续, talents): the bar's full mark + // (community report #35: the extra bullets sat above a full bar until fewer than the base count were left) + if (this.active && this.ammoLeft > this.ammoMax) this.ammoMax = this.ammoLeft; if (!this.isTimed && !this.pending) this.end('instant'); return true; } @@ -459,6 +465,7 @@ export class SkillRuntime { this.pending = false; this.timeLeft = 0; this.ammoLeft = 0; + this.ammoMax = 0; const n = this.activations; this._call('onEnd', { reason }); if (!this.active && this.activations === n) this._removeMods(); @@ -481,7 +488,12 @@ export class SkillRuntime { // ---- helpers for content ------------------------------------------------------------------------------- // (non-finite arguments are ignored: a NaN timer/ammo count would keep the skill active forever) - addAmmo(n) { if (this.active && this.kind === 'ammo' && Number.isFinite(n)) this.ammoLeft += n; } + // (bullets added above the activation's most so far raise the bar's full mark: the bar drains one bullet at a time) + addAmmo(n) { + if (!this.active || this.kind !== 'ammo' || !Number.isFinite(n)) return; + this.ammoLeft += n; + if (this.ammoLeft > this.ammoMax) this.ammoMax = this.ammoLeft; + } extend(seconds) { if (this.active && Number.isFinite(this.timeLeft) && Number.isFinite(seconds)) this.timeLeft += seconds; } addCharge(n = 1) { if (!Number.isFinite(n)) return; this.charges = Math.max(0, Math.min(this.maxCharges, this.charges + n)); if (this.charges >= this.maxCharges) this.sp = this.spCost; } stop() { this.end('stopped'); } diff --git a/server/sim/snapshot.js b/server/sim/snapshot.js index 058770cb..2b0c86a5 100644 --- a/server/sim/snapshot.js +++ b/server/sim/snapshot.js @@ -92,8 +92,9 @@ export function unitTuple(u, t) { const spMax = sk && !sk.noSkill ? sk.spCost : 0; let sp = sk && !sk.noSkill ? sk.sp : 0; if (sk && sk.active && sk.isTimed) { - // show remaining duration/ammo as a draining bar - if (sk.kind === 'ammo') sp = spMax * (sk.ammoLeft / Math.max(1, sk.ammo)); + // show remaining duration/ammo as a draining bar — ammo out of the activation's real total (base + bullets added: + // 拉特兰, 逃犯引渡手续, refills; community report #35), so every bullet shortens it + if (sk.kind === 'ammo') sp = spMax * (sk.ammoLeft / Math.max(1, sk.ammoMax || sk.ammo || 0, sk.ammoLeft)); else if (Number.isFinite(sk.timeLeft) && sk.duration > 0) sp = spMax * (sk.timeLeft / sk.duration); } // hp is rounded up (a living unit never shows 0) but never above the rounded max HP diff --git a/test/sim/feedback3-ammo-bar.test.js b/test/sim/feedback3-ammo-bar.test.js new file mode 100644 index 00000000..517a9432 --- /dev/null +++ b/test/sim/feedback3-ammo-bar.test.js @@ -0,0 +1,61 @@ +// test/sim/feedback3-ammo-bar.test.js — community report #35 (0.1.3): "拉特兰盟约叠层之后蕾缪安3技能锁定敌人消耗的子弹不会实时让技力条一格 +// 一格减少,而是最后突然消耗". 拉特兰's skillStart multiplies the bullets (×(1.05 + 0.015 × layers), floored) and 逃犯引渡手续 adds +// one, but the snapshot drew ammoLeft / skill.ammo (the base 5) and the client clamps at 1: the bar stayed full until +// fewer than 5 bullets were left. Now the draining bar is ammoLeft / ammoMax — the most bullets this activation held +// (set at activation, raised by skillStart additions and addAmmo) — so it starts full and every bullet shortens it. + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { makeBattle, enemyRec, checkInvariants } from '../helpers/battleHarness.js'; +import { unitTuple } from '../../server/sim/snapshot.js'; + +const DEFS = { enemies: { enemy_dummy: enemyRec({ key: 'enemy_dummy', hp: 1e7, speed: 0 }) } }; +const bar = (u, t) => { const [, , , , , sp, spMax] = unitTuple(u, t); return sp / spMax; }; + +test('蕾缪安 S3 with 30 拉特兰 layers: the bar starts full and loses one bullet\'s share per lock (base 5 + extras)', () => { + const seen = []; + const h = makeBattle({ + defs: DEFS, timeLimit: 200, seed: 7, + units: [{ chessId: 'chess_char_6_01_a', row: 10, col: 3 }], + enemies: [{ key: 'enemy_dummy', pos: [10, 5] }], + bonds: { lateranoShip: { count: 3, active: true, tier: 1, layers: 30 } }, + setup(b) { + b.on('ammoUsed', ({ unit, left, skill }) => seen.push({ left, max: skill.ammoMax, bar: bar(unit, b.time) }), { priority: -999 }); + }, + }); + const u = h.unit('chess_char_6_01_a'); + assert.equal(u.skill.ammo, 5, 'S3: 5 bullets (attack@trigger_time)'); + assert.ok(h.runUntil(() => u.skill.active, 80)); + assert.ok(h.runUntil(() => !u.skill.active, 30)); + assert.ok(seen.length >= 7, `${seen.length} bullets: 5 × 1.5 floored, + 逃犯引渡手续`); + const full = seen[0].left + 1; + for (const s of seen) assert.equal(s.max, full, 'the full mark is the activation\'s real total'); + seen.forEach((s, i) => assert.ok(Math.abs(s.bar - (full - 1 - i) / full) < 0.01, `bullet ${i + 1}: bar ${s.bar.toFixed(3)} = ${full - 1 - i}/${full}`)); + assert.ok(seen[0].bar < 1 - 0.1, 'the first lock already shortens the bar (it used to stay full until < 5 were left)'); + assert.equal(u.skill.ammoMax, 0, 'reset when the skill ends'); + checkInvariants(h.b); +}); + +test('the full mark follows bullets added mid-skill only above it; without extras the bar is unchanged (n / base)', () => { + const h = makeBattle({ + defs: DEFS, timeLimit: 200, seed: 7, + units: [{ chessId: 'chess_char_6_01_a', row: 10, col: 3 }], + enemies: [{ key: 'enemy_dummy', pos: [10, 5] }], + }); + const u = h.unit('chess_char_6_01_a'); + h.step(1); + u.skill.activate('test', { free: true }); + const sk = u.skill; + assert.equal(sk.ammoMax, sk.ammoLeft); + const base = sk.ammoMax; + assert.ok(Math.abs(bar(u, h.b.time) - 1) < 1e-9, 'full at the start'); + sk.ammoLeft -= 2; // two locks + assert.ok(Math.abs(bar(u, h.b.time) - (base - 2) / base) < 0.01); + sk.addAmmo(1); // a refill under the mark: the mark stays + assert.equal(sk.ammoMax, base); + sk.addAmmo(3); // above it: the mark rises, the bar is full again + assert.equal(sk.ammoMax, base + 2); + assert.ok(Math.abs(bar(u, h.b.time) - 1) < 1e-9); + sk.end('test'); + assert.equal(sk.ammoMax, 0); +});