diff --git a/docs/SIM.md b/docs/SIM.md index a0040d45..4b4e6578 100644 --- a/docs/SIM.md +++ b/docs/SIM.md @@ -1341,7 +1341,7 @@ header of professions.js). | loopshooter | 回环射手 (user playtest #3): every attack throws a boomerang (`ai.js throwBoomerang`, projectile `'boomerang'`) out to the target at 15 tiles/s — it hits on arrival — and back to the thrower's current position at 3.75 tiles/s without damage (PRTS 跃跃 "投射物飞行速度15,返回时飞行速度3.75"); attacks only while holding it (every boomerang thrown caught — "必须回收全部回旋投掷物才可以进行下一次攻击", `unit.trait.boomerangsOut`) and with the attack cooldown ready, so the real interval is the longer of the two; a target dead mid-flight is not hit (it still flies to the last position and back); knocked out / withdrawn ⇒ lost, a redeployed thrower holds a fresh one; 跃跃 S2's extra boomerangs share the one flight (cnt hits); each catch fires `boomerangCaught`, and an attack profile's `boomerangOnward(ctx)` flies it on after the first hit before it turns back — ctx `hit` (a hit of the same attack), `comeBack(x, y)` once (a content error sends it back from the hit point): 娜仁图亚 S1 bounces, S2 dashes on and hits on its way back (kits/ops/op-narant.js) | | reaperrange | hits every enemy in range; ×1.5 (bb atk_scale) on the trait front grid (or its own line ahead) — both along its direction | | chain | chain N (trait text/bb max_target) with −15 % per jump (bb chain.atk_scale), 1.7-tile jumps (constants.js CHAIN_RADIUS, PRTS 溅射半径一览: 链术师 1.7; 1.8 until 0.1.1), sluggish on each hit | -| funnel | drone damage 20 % → +15 %/hit on the same target → 110 % (bb init/delta/max) | +| funnel | drone damage 20 % → +15 %/hit on the same target → 110 % (bb init/delta/max); a normal deployment resets target/scale and any per-target ramp map to init, including revival against the same living target; free `moveRedeploy` keeps the ramp (§22.3) | | mystic | 秘术师 (PRTS 分支特性信息 秘术师, GitHub #181): at its attack check (the attack ready, able to act, not disarmed) with no valid target — no target, or its kit's `canAttack` false (深靛 never picks a bound enemy) — it stores one energy (`storeEnergy`, up to bb times: 3, 深靛's MSC-X 4), an attack action: the attack interval restarts; a full store idles with the attack ready. The energies leave with its next attack that happens (`releaseEnergy` in `performAttack`, after `beforeAttack`: an attack cancelled before its shot keeps them) and land with the main hit, one arts attack hit each (`hitsFn` reads the hit's `info.energy`); a redeployment holds none [ASSUMED]. 维伊 / 黑键 plug their own `storeEnergy` (转置能量, elite energies) into the same check | | phalanx | no attack & DEF +200 %, RES +20 (bb) while the skill is off; while on, `rangeAoe`: each attack strikes every selectable enemy on its range at once (blocked enemies included; a stealthed one only when revealed or blocked), the same damage near and far, instant (`'beam'`) — "群体法术伤害" (secondary: Terra Wiki, Phalanx Caster: "attacks hit all enemies within their range"; supporting: PRTS 林 S3 备注 "单次普攻最多触发1次效果" — one normal attack can kill several — the same 锁定攻击范围 shape as the 轰击术师, and PRTS 溅射半径一览 documents no splash radius for it (it omits the 撼地者 too, so this is not proof); no primary source states a target cap; community report E3: it used to be one bolt + a 1.1 splash). 卡涅利安's charged S1 keeps the skill-off trait, 不攻击 included (kit `canAttack`, PRTS 备注) | | physician | heal the lowest HP% injured ally in range (a skill `targeting.maxTargets` widens any heal profile) | diff --git a/docs/design/engine.md b/docs/design/engine.md index ffe067f7..1716b38a 100644 --- a/docs/design/engine.md +++ b/docs/design/engine.md @@ -167,6 +167,12 @@ Profession traits (default behaviours implemented once in the engine, keyed by ` --- +Funnel drone ramps return to the configured initial scale on a normal deployment, including automatic revival +against the same living target ([PRTS branch traits, 驭械术师](https://prts.wiki/w/分支特性信息/data#驭械术师)). +The profession resets its target/scale and any per-target ramp map, leaving other trait state alone. A free +`moveRedeploy` keeps the ramp under the existing move carry decision (§22.3); uninterrupted attacks still ramp and +cap normally, and changing targets still starts at the initial scale. + ## 7. Content modules (server/sim/content + match/effectsMeta handlers) Each domain file `server/sim/content/{tokens,bonds,garrisons,items,bands,enemies,bosses,devices,choices}.js` exports `install(battle)` (battle side; called once per Battle by `content/index.js → installContent`) and `registerMeta(registry)` (prep side; called once at server boot by `match/effectsMeta.js`, API documented in `docs/META.md`). Kits live one per file in `content/kits/ops/` (`export default { [baseChessId]: (bb, chess, def) => Kit }`), listed in `content/kits/index.js` (guide: `content/kits/README.md`). Content must be **data-driven from blackboards** (numbers never hard-coded when a blackboard key exists). Every effect gets at least one unit test in `test/content/*.test.js` using the `test/helpers/battleHarness.js` harness (provided by sim-core). diff --git a/docs/history/0.2.4.md b/docs/history/0.2.4.md index 9f5c8917..a173ab36 100644 --- a/docs/history/0.2.4.md +++ b/docs/history/0.2.4.md @@ -274,3 +274,24 @@ enemy: picking emitted a unit click with no definition, instead of the tile clic engine-canvas regression retains the card/100 HP assertions and also covers another field refresh, live HP loss and closing the card when the crate is destroyed. Device metadata supplied by the new entry still wins; changing fields does not carry old devices across. Gameplay and allied targeting rules are unchanged. + +### 29.15 Funnel ramps on redeployment + +PRTS [branch traits, 驭械术师](https://prts.wiki/w/分支特性信息/data#驭械术师) resets drone damage to its initial +scale when the operator redeploys. The profession installer previously initialized the ramp only once, so a +knocked-out Rockrock revived by Ermengard could keep 110% against the same surviving enemy. Normal deployments now +reset the funnel target/scale and the per-target map used by Minimalist and Kjera. Other trait state is retained; +continuous attacks, target changes and the existing free-move carry decision (§22.3) keep their rules. No operator +kit or attack/chase calculation changes. + +The regression uses the production Match with seed 8, NORMAL solo mode and Ermengard: buy Caper in round 1, +upgrade in round 2, buy Rockrock in round 5 and leave both fighting normally. In round 6 a real enemy knocks out +Rockrock; automatic revival must start the same living target at 20%. Controlled Battle tests separately cover +normal/elite Rockrock and hidden Kjera, Minimalist/Kjera per-target maps, uninterrupted ramping, target changes, +other-unit deployments, unrelated trait state and free movement. + +Golden updates: `roster-026` reduces Minimalist's damage from 3936 to 2397 and updates the affected enemy, +player and snapshot totals; `solo-HARD-1` and `solo-ABYSS-1` reduce the AI lineup's damage after funnel +redeployments (boss damage decreases by 572 and 413 respectively). Their victory, round, RNG and error outcomes +are unchanged. These three scenarios now start each normal deployment with a fresh ramp instead of carrying +the previous deployment's per-target cache. diff --git a/server/sim/professions.js b/server/sim/professions.js index 4544581f..5684eeea 100644 --- a/server/sim/professions.js +++ b/server/sim/professions.js @@ -385,8 +385,15 @@ const installTactician = (battle, unit) => { }; const installFunnel = (battle, unit) => { - unit.trait.funnelTarget = null; - unit.trait.funnelScale = unit.profile.funnel?.init ?? 0.2; + // PRTS branch traits, funnel: redeployment resets the ramp even against the same living enemy. + const reset = () => { + unit.trait.funnelTarget = null; + unit.trait.funnelScale = unit.profile.funnel?.init ?? 0.2; + unit.trait.funnelMap?.clear(); // Minimalist / Kjera keep one ramp per target. + }; + reset(); + // A free move keeps the existing state under the owner's carry decision (DESIGN §22.3). + battle.on('deploy', (ctx) => { if (ctx.unit === unit && !ctx.move) reset(); }, { owner: unit }); }; /** diff --git a/test/golden/matches.json b/test/golden/matches.json index d03ea8ed..a7ed4513 100644 --- a/test/golden/matches.json +++ b/test/golden/matches.json @@ -246,7 +246,7 @@ "combat": "client", "boost": null, "setup": {"stageId":"act1autochess_m03","bossId":"boss_5","hiddenBossId":"boss_8","factions":["TIMES","DOT","INVISIBLE"]}, - "end": {"victory":false,"roundsPassed":13,"hiddenReached":false,"hiddenCleared":false,"reason":"defeat","teamLp":0,"bossPoolLeft":341377,"virtualMs":107800}, + "end": {"victory":false,"roundsPassed":13,"hiddenReached":false,"hiddenCleared":false,"reason":"defeat","teamLp":0,"bossPoolLeft":341949,"virtualMs":107800}, "rng": {"setup":25,"shop":215,"waves":0,"draft":42,"bots":8070,"meta":1}, "errors": {"engine":0,"logged":0,"sim":0,"dispatcher":0}, "rounds": [ @@ -299,7 +299,7 @@ "title": {"id":"comment_2","name":"不朽盟约","picId":"comment_icon_2","text":"激活的盟约层数总和最高"}, "trophies": 0, "reward": 340, - "stats": {"activatedLayers":212,"bossDamage":438623,"dmgDealt":2261523,"gold":135,"itemsEquipped":16,"kills":402,"leaks":14,"lpLost":14,"merges":4,"perfectRounds":10,"refreshes":17}, + "stats": {"activatedLayers":212,"bossDamage":438051,"dmgDealt":2260950,"gold":135,"itemsEquipped":16,"kills":402,"leaks":14,"lpLost":14,"merges":4,"perfectRounds":10,"refreshes":17}, "lineup": ["3_13_a@12,5[1_01_e_b+3_08_e_a]","3_20_a@11,8[5_02_e_a]","4_13_a@10,3","6_19_a@10,5[1_02_e_a+3_11_e_a]","6_17_a@10,8[2_01_e_a+5_01_e_a]","2_08_a@9,3[4_02_e_a+3_07_e_b]","1_19_b@9,4","1_12_b@9,5[4_10_e_a]"], "bonds": ["deputShip 0/24","kazimierzShip 1/26","kjeragShip 1/15","sargonShip 1/41","skillfulShip 1/31","soloShip 0/15","steadShip 0/11","suntShip 1/22","swiftShip 1/77"] } @@ -384,7 +384,7 @@ "combat": "client", "boost": null, "setup": {"stageId":"act1autochess_m03","bossId":"boss_5","hiddenBossId":"boss_8","factions":["TIMES","DOT","INVISIBLE"]}, - "end": {"victory":false,"roundsPassed":13,"hiddenReached":false,"hiddenCleared":false,"reason":"defeat","teamLp":0,"bossPoolLeft":2832671,"virtualMs":107800}, + "end": {"victory":false,"roundsPassed":13,"hiddenReached":false,"hiddenCleared":false,"reason":"defeat","teamLp":0,"bossPoolLeft":2833084,"virtualMs":107800}, "rng": {"setup":25,"shop":215,"waves":0,"draft":42,"bots":8070,"meta":1}, "errors": {"engine":0,"logged":0,"sim":0,"dispatcher":0}, "rounds": [ @@ -437,7 +437,7 @@ "title": {"id":"comment_2","name":"不朽盟约","picId":"comment_icon_2","text":"激活的盟约层数总和最高"}, "trophies": 0, "reward": 340, - "stats": {"activatedLayers":220,"bossDamage":167329,"dmgDealt":3078206,"gold":135,"itemsEquipped":16,"kills":386,"leaks":22,"lpLost":20,"merges":4,"perfectRounds":11,"refreshes":17}, + "stats": {"activatedLayers":220,"bossDamage":166916,"dmgDealt":3077793,"gold":135,"itemsEquipped":16,"kills":386,"leaks":22,"lpLost":20,"merges":4,"perfectRounds":11,"refreshes":17}, "lineup": ["3_20_a@12,5[5_02_e_a]","4_13_a@11,8","3_13_a@10,3[1_01_e_b+3_08_e_a]","2_08_a@10,5[4_02_e_a+3_07_e_b]","6_17_a@10,8","1_12_b@9,4[4_10_e_a]","6_19_a@9,5[1_02_e_a+3_11_e_a]","1_19_b@9,9[2_01_e_a+5_01_e_a]"], "bonds": ["deputShip 0/24","kazimierzShip 1/26","kjeragShip 1/15","sargonShip 1/41","skillfulShip 1/31","soloShip 0/15","steadShip 0/11","suntShip 1/22","swiftShip 1/85"] } diff --git a/test/golden/roster.json b/test/golden/roster.json index c05a0ec6..1643af46 100644 --- a/test/golden/roster.json +++ b/test/golden/roster.json @@ -1630,7 +1630,7 @@ "bossHpLeft": null, "rngDraws": 179, "errors": {"count":0,"kinds":[]}, - "players": {"p1":{"killed":19,"total":71,"perfect":false,"leaks":54,"leaksUncounted":0,"coins":6,"dmg":212430,"bossDmg":0,"heal":2065,"deaths":22,"layerGains":{}}}, + "players": {"p1":{"killed":19,"total":71,"perfect":false,"leaks":54,"leaksUncounted":0,"coins":6,"dmg":210891,"bossDmg":0,"heal":2065,"deaths":22,"layerGains":{}}}, "units": [ ["p1",1,"op","chess_char_3_12_a",0,null,0,0,99,2,9831,6171,2065,21,4], ["p1",2,"op","chess_char_3_16_a",0,null,0,0,101,0,4246,4781,0,15,1], @@ -1639,7 +1639,7 @@ ["p1",5,"op","chess_char_3_09_a",0,null,1393.7,1,103,1,16928,1394,0,24,3], ["p1",6,"op","chess_char_3_10_a",0,null,0,0,89,1,36230,3630,0,22,5], ["p1",7,"op","chess_char_3_11_a",0,null,0,0,7,1,12325,1448,0,6,1], - ["p1",8,"op","chess_char_3_13_a",0,null,0,0,53,0,3936,5199,0,23,1], + ["p1",8,"op","chess_char_3_13_a",0,null,0,0,53,0,2397,5199,0,23,1], ["p1",9,"op","chess_char_3_14_a",0,null,0,0,79,0,13946,2752,0,15,0], ["p1",10,"op","chess_char_3_17_a",0,null,1218.8,1,96,4,42240,3722,0,49,9], ["p1",11,"op","chess_char_3_19_a",0,null,0,0,77,1,20696,3166,0,32,0], @@ -1653,7 +1653,7 @@ "enemies": { "enemy_10042_prtrop": [12,0,12,7101,28836], "enemy_10042_prtrop_2": [12,0,10,6218,18414], - "enemy_10043_sailor": [39,12,27,20980,197254], + "enemy_10043_sailor": [39,12,27,20980,195715], "enemy_1005_yokai_2": [1,1,0,68,2678], "enemy_10156_mncrer": [1,1,0,140,6912], "enemy_1116_liprr_2": [1,1,0,247,12960], @@ -1667,7 +1667,7 @@ "hooks": [1,102,4200,412,412,1525,1585,10,42,95,33,33,0,10321,22,47,73,52,1,46,0,0,0], "events": {"atk":478,"bounty":3,"deploy":26,"die":43,"dmg":1585,"engage":14,"fx":26,"heal":10,"leak":52,"skill":66,"spawn":90,"status":54}, "fx": {"dp":7,"explode":3,"revive":4,"sp":4,"strike":1,"summon":2,"telegraph":2,"wolfShadow":1,"wolfShadowLost":2}, - "snaps": "226d72ef 4944a14f b2954de1 522e52cb dd46cce1 b1febb8b ac7e7cf4 bb6af571 080c3ba3 fc1505bd c0528264 4a64f7c2 beffba7c a423b42e" + "snaps": "226d72ef 4944a14f b2954de1 3ffcf693 a30dc502 d84524d6 4ead34e9 4b042eb0 8e0173d6 43359664 a5bda569 a184d2eb 172129c9 aa1a4797" }, "roster-027": { "about": "pass 1 band 4: chess_char_3_06_b chess_char_3_12_b chess_char_3_02_b chess_char_3_03_b chess_char_3_04_b chess_char_3_05_b chess_char_3_21_a chess_char_3_01_b chess_char_3_08_b chess_char_3_09_b chess_char_3_10_b chess_char_3_11_b", diff --git a/test/match/funnel-redeploy.test.js b/test/match/funnel-redeploy.test.js new file mode 100644 index 00000000..1331cb31 --- /dev/null +++ b/test/match/funnel-redeploy.test.js @@ -0,0 +1,75 @@ +// Real production Match + Battle, legal normal intents from an empty board. Seed 8 fixes the shop/wave route. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { Battle } from '../../server/sim/Battle.js'; +import { makeMatch } from './harness.js'; + +test('Rockrock killed by a real enemy and automatically revived by Ermengard starts the same target at init', () => { + const observed = []; + class ObservedBattle extends Battle { + constructor(opts) { + super(opts); + const log = { round: opts.round, deaths: [], deploys: [], firstHits: [] }; + observed.push(log); + const seen = new Set(), launches = new Map(); + this.on('death', c => { + if (c.unit.defId === 'chess_char_2_10_a') log.deaths.push({ t: this.time, seq: c.unit.deploySeq, + target: c.unit.trait.funnelTarget, scale: c.unit.trait.funnelScale, reason: c.reason }); + }, { priority: 1000 }); + this.on('death', c => { + if (c.unit.defId === 'chess_char_2_10_a') log.deaths.at(-1).revivedBy = c.revivedBy; + }, { priority: -1000 }); + this.on('deploy', c => { + if (c.unit.defId === 'chess_char_2_10_a') log.deploys.push({ seq: c.unit.deploySeq, + target: c.unit.trait.funnelTarget, scale: c.unit.trait.funnelScale }); + }, { priority: -1000 }); + this.on('attack', c => { + if (c.attacker.defId !== 'chess_char_2_10_a') return; + // This unmodified Rockrock route has no nested attack callback. The attack hook follows its shot creation. + launches.set(this._attackSeq, { t: this.time, seq: c.attacker.deploySeq }); + }); + this.on('damaged', c => { + const u = c.source; + const launch = launches.get(c.dmg?.attackId); + if (u?.defId !== 'chess_char_2_10_a' || seen.has(u.deploySeq) || launch?.seq !== u.deploySeq) return; + seen.add(u.deploySeq); + log.firstHits.push({ t: this.time, launchAt: launch.t, seq: u.deploySeq, target: c.target.id, + scale: u.trait.funnelScale, hp: c.target.hp }); + }); + } + } + const h = makeMatch({ mode: 'solo', seed: 8 }); + h.m.BattleClass = ObservedBattle; // read-only observers, no combat state writes + const p = h.ps('p_0'), act = msg => assert.deepEqual(h.m.handle('p_0', msg), { ok: true }); + try { + h.start().toPrep(1, { band: 'band_ermengard' }); + act({ t: 'g.buy', slot: 0 }); + act({ t: 'g.move', uid: p.hand.find(x => x?.id === 'chess_char_1_09_a').uid, + to: { area: 'board', row: 12, col: 6 }, dir: 'RIGHT' }); + h.toPrep(2, { band: 'band_ermengard' }); + act({ t: 'g.levelUp' }); + h.toPrep(5, { band: 'band_ermengard' }); + assert.equal(p.shop.slots[3].id, 'chess_char_2_10_a'); + act({ t: 'g.buy', slot: 3 }); + act({ t: 'g.move', uid: p.hand.find(x => x?.id === 'chess_char_2_10_a').uid, + to: { area: 'board', row: 12, col: 8 }, dir: 'RIGHT' }); + h.toPrep(7, { band: 'band_ermengard' }); + const log = observed.find(x => x.round === 6); + assert.ok(log?.deaths.length > 0); + const paired = log.deaths.map(d => ({ death: d, hit: log.firstHits.find(x => x.seq > d.seq) })) + .filter(x => x.hit && x.death.target === x.hit.target && x.death.scale > 0.2); + assert.ok(paired.length > 0, JSON.stringify(log)); + for (const { death, hit } of paired) { + assert.equal(death.reason, 'killed'); + assert.equal(death.revivedBy, 'band'); + assert.ok(hit.launchAt >= death.t, 'fresh attack after the knockout and revival'); + assert.ok(hit.hp > 0, 'original target still lives'); + const deploy = log.deploys.find(x => x.seq === hit.seq); + assert.equal(deploy.target, null); + assert.ok(Math.abs(deploy.scale - 0.2) < 1e-8); + assert.ok(Math.abs(hit.scale - 0.2) < 1e-8, JSON.stringify({ death, hit })); + } + assert.deepEqual(h.logs.error, []); + h.invariants(); + } finally { h.m.dispose(); } +}); diff --git a/test/sim/funnel-redeploy.test.js b/test/sim/funnel-redeploy.test.js new file mode 100644 index 00000000..90d78853 --- /dev/null +++ b/test/sim/funnel-redeploy.test.js @@ -0,0 +1,90 @@ +// PRTS 分支特性信息/data §驭械术师: changing targets / redeploying resets the drone ramp. +// Controlled battle inputs here; the real Match route is covered in test/match/funnel-redeploy.test.js. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { makeBattle, enemyRec, checkInvariants } from '../helpers/battleHarness.js'; + +const make = (id = 'chess_char_2_10_a', extra = []) => makeBattle({ + units: [{ chessId: id, row: 10, col: 4 }, ...extra], + defs: { enemies: { dummy: enemyRec({ key: 'dummy', hp: 1e7, speed: 0 }) } }, + enemies: [{ key: 'dummy', pos: [10, 6] }], timeLimit: 300, autoFinish: false, + hooks: ['damaged'], captureNoisy: true, +}); +const hits = (h, u) => h.hooksOf('damaged').filter(c => c.source === u && c.target.side === 'enemy'); +const near = (a, b) => assert.ok(Math.abs(a - b) < 1e-8, `${a} != ${b}`); +const ramp = (h, u) => { + h.runUntil(() => hits(h, u).length >= 8, 20); + assert.ok(hits(h, u).length >= 8); +}; + +for (const id of ['chess_char_2_10_a', 'chess_char_2_10_b', 'chess_char_4_03_a', 'chess_char_4_03_b']) { + test(`${id}: same living target starts at the configured initial scale after automatic redeployment`, () => { + const h = make(id), u = h.unit(id); + ramp(h, u); + const e = h.enemies()[0]; + assert.ok(u.trait.funnelScale > u.profile.funnel.init); + const before = hits(h, u).length; + h.b.dealDamage(e, u, { amount: 1e7, type: 'true' }); + assert.equal(u.alive, false); + h.runUntil(() => u.alive, 100); + assert.equal(u.alive, true); + assert.equal(e.alive, true); + assert.equal(u.trait.funnelTarget, null); + near(u.trait.funnelScale, u.profile.funnel.init); + assert.equal(h.runUntil(() => hits(h, u).length > before, 5), true); + assert.ok(hits(h, u).length > before); + assert.equal(hits(h, u).at(-1).target, e); + near(u.trait.funnelScale, u.profile.funnel.init); + checkInvariants(h.b); + assert.deepEqual(h.b.errors, []); + }); +} + +for (const id of ['chess_char_3_13_a', 'chess_char_3_13_b', 'chess_char_3_20_a', 'chess_char_3_20_b']) { + test(`${id}: per-target funnel map restarts on redeployment`, () => { + const h = make(id), u = h.unit(id); + ramp(h, u); + const e = h.enemies()[0]; + assert.ok(u.trait.funnelMap.get(e.id).s > u.profile.funnel.init); + h.b.retreat(u); + assert.equal(h.b.redeploy(u), true); + assert.equal(u.trait.funnelMap.size, 0); + const before = hits(h, u).length; + assert.equal(h.runUntil(() => hits(h, u).length > before, 5), true); + assert.ok(hits(h, u).length > before); + near(u.trait.funnelMap.get(e.id).s, u.profile.funnel.init); + checkInvariants(h.b); + }); +} + +test('continuous attacks ramp and cap; changing the living target resets normally', () => { + const h = make(), u = h.unit('chess_char_2_10_a'); + ramp(h, u); + near(u.trait.funnelScale, u.profile.funnel.max); + const replacement = h.spawn('dummy', { pos: [10, 5] }); + const before = hits(h, u).length; + assert.equal(h.runUntil(() => hits(h, u).length > before, 5), true); + assert.ok(hits(h, u).length > before); + assert.equal(hits(h, u).at(-1).target, replacement); + near(u.trait.funnelScale, u.profile.funnel.init); +}); + +test('another unit deployment and a free move keep this ramp; unrelated trait state survives redeployment', () => { + const h = make('chess_char_2_10_a', [{ chessId: 'chess_char_1_02_a', row: 12, col: 3 }]); + const u = h.unit('chess_char_2_10_a'), other = h.unit('chess_char_1_02_a'); + ramp(h, u); + const scale = u.trait.funnelScale, target = u.trait.funnelTarget; + u.trait.testUnrelated = { value: 7 }; // controlled sentinel: no production trait object may be replaced + const sentinel = u.trait.testUnrelated; + h.b.retreat(other); + assert.equal(h.b.redeploy(other), true); + assert.equal(u.trait.funnelTarget, target); + near(u.trait.funnelScale, scale); + assert.equal(h.b.moveRedeploy(u, 10, 3), true); // owner-approved move carry, DESIGN §22.3 + assert.equal(u.trait.funnelTarget, target); + near(u.trait.funnelScale, scale); + h.b.retreat(u); + assert.equal(h.b.redeploy(u), true); + assert.equal(u.trait.testUnrelated, sentinel); + near(u.trait.funnelScale, u.profile.funnel.init); +});