fix: 再部署重置浮游倍率;roster-026、solo-HARD-1、solo-ABYSS-1 不再沿用旧缓存

This commit is contained in:
星闌 committed 2026-10-11 16:40:10 +08:00
1 parent bc50cac01f
commit 7434e9eaf0
8 files changed
+210 -11

No files matched your search

+1 -1
View File
@@ -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) |
+6
View File
@@ -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).
+21
View File
@@ -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.
+9 -2
View File
@@ -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 });
};
/**
+4 -4
View File
@@ -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"]
}
+4 -4
View File
@@ -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",
+75
View File
@@ -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(); }
});
+90
View File
@@ -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);
});