Skip to content

Commit 94c7f4f

Browse files
author
NBL Agent
committed
feat(v4): BattlePower v2 (recursive features + empirical calibration)
- battlepower-v2.js: power = generationBudget x mechanicFactor. base anchors rarity/level (same-seed same-level 12-rarity ladder STRICTLY monotonic, level ladder rises, large rarity gaps never collapse to equal integers). mechanic factor from RECURSIVE feature extraction: conditional/repeat/consume/DoT/ trigger/resource/lifesteal/self-harm/reflect/ward/resistance/cooldown/ priority/accuracy/crit/variance/ramp/fatigue/wear-resistance/status-stack/ detonation. Static (no Monte Carlo), cheap per render, display-only (engine never reads it — tested). - weights sweep -> offense-heavy set; full calibration: Spearman 0.814 (>0.80), pairwise ordering 0.864 (>0.85), rarity median C 608 -> XS典藏 5438. - scripts/power-v4-calibration.js: canonical-AI battles, Elo empirical strength, spearman + pairwise + per-rarity trend -> qa/power-v4-calibration.json. - scripts/diversity-audit-v4.js (npm run diversity:v4): 10k v4 cards, classless proof, effect/condition/event/damage-type/resource/status/target/trigger/ behavior-tag coverage, volatility bands, time profiles, rarity+level dist, convergence probes -> qa/diversity-v4.json. - card-ui battlePowerOf uses v2 for v4 cards (legacy model for older cards); index.html loads battlepower-v2.js. Tests: +10 (BP v2 6: monotonic ladders/no-collapse/recursive-features/no-engine- hook; calibration smoke 2; v4 diversity 2). 233 total.
1 parent 414d84c commit 94c7f4f

10 files changed

Lines changed: 636 additions & 3 deletions

index.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ <h1>数值对战实验室</h1>
8181
<script src="src/behavior.js"></script>
8282
<script src="src/battlepower-model.js"></script>
8383
<script src="src/battlepower.js"></script>
84+
<script src="src/battlepower-v2.js"></script>
8485
<script src="src/card-ui.js"></script>
8586
<script src="src/presets.js"></script>
8687
<script src="src/app.js"></script>

package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111
"manifest": "node scripts/generate-manifest.js",
1212
"gen-mc": "node scripts/gen-mc.js",
1313
"diagnostics:legacy": "node scripts/health-metrics.js --n 3000 && node scripts/adjacent-rarity-matrix.js --seeds 48 --battles 8 && node scripts/matched-rarity-test.js --seeds 48 --battles 8 && node scripts/power-calibration.js --sample 600 --battles 14 --rarityN 48 && node scripts/similar-bp-test.js --battles 14 --pairs 120",
14-
"diversity": "node scripts/diversity-audit.js 3000"
14+
"diversity": "node scripts/diversity-audit.js 3000",
15+
"diversity:v4": "node scripts/diversity-audit-v4.js 10000",
16+
"calibration:v4": "node scripts/power-v4-calibration.js 72 1600"
1517
}
1618
}

qa/power-v4-calibration.json

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
{
2+
"nCards": 72,
3+
"pairs": 1196,
4+
"spearman": 0.814,
5+
"pairwiseOrdering": 0.864,
6+
"trend": {
7+
"C": {
8+
"median": 608,
9+
"min": 254,
10+
"max": 1275
11+
},
12+
"C_PLUS": {
13+
"median": 1024,
14+
"min": 325,
15+
"max": 1624
16+
},
17+
"B": {
18+
"median": 1525,
19+
"min": 1116,
20+
"max": 1883
21+
},
22+
"B_PLUS": {
23+
"median": 1316,
24+
"min": 505,
25+
"max": 4496
26+
},
27+
"A": {
28+
"median": 1321,
29+
"min": 584,
30+
"max": 2030
31+
},
32+
"A_PLUS": {
33+
"median": 2171,
34+
"min": 829,
35+
"max": 4205
36+
},
37+
"S": {
38+
"median": 1554,
39+
"min": 1008,
40+
"max": 4100
41+
},
42+
"SS": {
43+
"median": 3620,
44+
"min": 1259,
45+
"max": 6114
46+
},
47+
"SSS": {
48+
"median": 3525,
49+
"min": 1029,
50+
"max": 6979
51+
},
52+
"SSS_COLLECTOR": {
53+
"median": 4177,
54+
"min": 1813,
55+
"max": 6729
56+
},
57+
"XS": {
58+
"median": 5289,
59+
"min": 2622,
60+
"max": 10317
61+
},
62+
"XS_COLLECTOR": {
63+
"median": 5438,
64+
"min": 2867,
65+
"max": 8209
66+
}
67+
},
68+
"byRarity": {
69+
"C": [
70+
254,
71+
372,
72+
443,
73+
608,
74+
1195,
75+
1275
76+
],
77+
"C_PLUS": [
78+
325,
79+
529,
80+
602,
81+
1024,
82+
1499,
83+
1624
84+
],
85+
"B": [
86+
1116,
87+
1142,
88+
1184,
89+
1525,
90+
1756,
91+
1883
92+
],
93+
"B_PLUS": [
94+
505,
95+
774,
96+
1043,
97+
1316,
98+
1695,
99+
4496
100+
],
101+
"A": [
102+
584,
103+
615,
104+
1122,
105+
1321,
106+
1431,
107+
2030
108+
],
109+
"A_PLUS": [
110+
829,
111+
948,
112+
1108,
113+
2171,
114+
3639,
115+
4205
116+
],
117+
"S": [
118+
1008,
119+
1082,
120+
1341,
121+
1554,
122+
2488,
123+
4100
124+
],
125+
"SS": [
126+
1259,
127+
1852,
128+
3113,
129+
3620,
130+
5937,
131+
6114
132+
],
133+
"SSS": [
134+
1029,
135+
1062,
136+
2706,
137+
3525,
138+
4702,
139+
6979
140+
],
141+
"SSS_COLLECTOR": [
142+
1813,
143+
3668,
144+
4100,
145+
4177,
146+
5713,
147+
6729
148+
],
149+
"XS": [
150+
2622,
151+
4262,
152+
5281,
153+
5289,
154+
6870,
155+
10317
156+
],
157+
"XS_COLLECTOR": [
158+
2867,
159+
3235,
160+
4158,
161+
5438,
162+
6983,
163+
8209
164+
]
165+
}
166+
}

scripts/diversity-audit-v4.js

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
// Generator v4 diversity audit (spec §63-64, §82).
2+
//
3+
// Generates N v4 cards and reports coverage across mechanics, randomness,
4+
// time profiles, behavior tags, rarity, level, and convergence/lethality.
5+
// Also proves classlessness: no card carries an archetype, and no archetype
6+
// vocabulary appears in identities.
7+
const fs=require('node:fs'),path=require('node:path');
8+
for(const f of ['kernel','components','rules','content','status-runtime','formula','validator','effects','engine','ai','power','gen-stats','gen-skills','generator','gen-names','gen-v2','gen-v3','gen-v4','behavior','battlepower-v2'])require('../src/'+f+'.js');
9+
const N=global.NCB;
10+
11+
function audit(n=10000){
12+
const counts={},effects={},conditions={},events={},damageTypes={},resources={},statuses={},targets={},triggers={},tags={},timeProfiles={},volBands={low:0,mid:0,high:0};
13+
const fingerprints=new Set();
14+
const inc=(m,k)=>{if(k)m[k]=(m[k]||0)+1;};
15+
const walk=x=>{if(!x||typeof x!=='object')return;
16+
if(N.EFFECT_COMPONENTS[x.type])inc(effects,x.type);
17+
if(N.CONDITION_COMPONENTS[x.type])inc(conditions,x.type);
18+
if(N.DAMAGE_TYPES[x.type])inc(damageTypes,x.type);
19+
if(x.damageType)inc(damageTypes,x.damageType);
20+
if(x.event)inc(events,x.event);
21+
for(const k of ['resource','from','to'])if(x[k])inc(resources,x[k]);
22+
if(x.status)inc(statuses,x.type||'reference');
23+
if(x.target)inc(targets,x.target);if(x.relation)inc(targets,'query:'+x.relation);
24+
if(x.event&&x.effects)inc(triggers,x.event);
25+
for(const v of Object.values(x))if(v&&typeof v==='object')walk(v);
26+
};
27+
let nonDamage=0,multiHeal=0,multiShield=0,multiStatus=0,classless=true,maxRoundsRate=0;
28+
const rarityDist={},levelBuckets={};
29+
for(let i=0;i<n;i++){
30+
const rarity=N.RARITY_V2_ORDER[i%12];
31+
const level=10+((i*37)%91);
32+
const c=N.generateCardV4({seed:'div4-'+i,rarity,level});
33+
inc(counts,c.actions.length);fingerprints.add(c.mechanicFingerprint);
34+
if('archetype' in c||/archetype|Balanced|Tank|Bruiser|Assassin|Mage|Support|Controller/.test(c.identity||''))classless=false;
35+
inc(rarityDist,c.rarity);
36+
const bucket=level===100?'100':Math.floor(level/10)*10+'-'+ (Math.floor(level/10)*10+9);
37+
inc(levelBuckets,bucket);
38+
const vol=Number(c.stats.VOLATILITY||1);
39+
if(vol>=1.6)volBands.high++;else if(vol<=0.6)volBands.low++;else volBands.mid++;
40+
const ramp=Number(c.stats.RAMP_RATE||0),fat=Number(c.stats.FATIGUE_RATE||0);
41+
const profile=ramp>0&&fat>0?'mixed':ramp>0?'ramp':fat>0?'fatigue':'stable';
42+
inc(timeProfiles,profile);
43+
const beh=N.analyzeBehavior(c);for(const t of beh.tags)inc(tags,t);
44+
const kinds=c.actions.map(a=>a.kind);if(!kinds.includes('damage'))nonDamage++;
45+
if(kinds.filter(x=>x==='heal').length>1)multiHeal++;
46+
if(kinds.filter(x=>x==='shield').length>1)multiShield++;
47+
if(kinds.filter(x=>x==='status').length>1)multiStatus++;
48+
walk({actions:c.actions,statuses:c.statuses,triggers:c.triggers,passives:c.passives});
49+
// convergence probe: subset of cards fight canonical AI (bounded for speed)
50+
if(i%40===0){
51+
const opp=N.generateCardV4({seed:'div4-opp-'+i,rarity:'A',level:50});
52+
N.deployCard(c);N.deployCard(opp);
53+
const e=N.createBattle({seed:N.deriveSeed(90000+i),teamA:[c.id],teamB:[opp.id],maxRounds:80});
54+
let g=0;while(!e.outcome().ended&&g++<80)e.resolveRound([...N.planAI(e,'A'),...N.planAI(e,'B')]);
55+
if(e.outcome().winner==='draw')maxRoundsRate++;
56+
}
57+
}
58+
return{samples:n,classless,actionCounts:counts,effectCoverage:effects,conditionCoverage:conditions,eventCoverage:events,
59+
damageTypeCoverage:damageTypes,resourceCoverage:resources,statusInteractionCoverage:statuses,targetCoverage:targets,
60+
triggerCoverage:triggers,behaviorTags:tags,timeProfiles,volatilityBands:volBands,uniqueFingerprints:fingerprints.size,
61+
duplicateRate:1-fingerprints.size/n,nonDamage,multiHeal,multiShield,multiStatus,rarityDistribution:rarityDist,levelBuckets,
62+
convergenceProbes:Math.floor(n/40),maxRoundsDraws:maxRoundsRate};
63+
}
64+
if(require.main===module){const report=audit(Number(process.argv[2])||10000);fs.mkdirSync(path.join(__dirname,'../qa'),{recursive:true});fs.writeFileSync(path.join(__dirname,'../qa/diversity-v4.json'),JSON.stringify(report,null,2)+'\n');console.log(JSON.stringify(report,null,2));}
65+
module.exports={audit};

scripts/power-v4-calibration.js

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
// BattlePower v2 empirical calibration (spec §25-26, §72).
2+
//
3+
// Generates a panel of v4 cards across rarity/level, plays them with the
4+
// canonical AI (pair sampling), derives an empirical strength rating (Elo-like),
5+
// then reports how well battlePowerV2 predicts it:
6+
// - Spearman correlation (BP vs empirical rating)
7+
// - pairwise ordering accuracy (of two cards, does BP order them like battles do?)
8+
// - rarity median trend (per-rarity BP distribution must move right)
9+
//
10+
// This is a DIAGNOSTIC tool, not a release gate: it reports real numbers and
11+
// never fake-passes. The unit test runs a tiny sample to prove the pipeline;
12+
// the real sample is run via `npm run calibration:v4`.
13+
const fs=require('node:fs'),path=require('node:path');
14+
for(const f of ['kernel','components','rules','content','status-runtime','formula','validator','effects','engine','ai','power','gen-stats','gen-skills','generator','gen-names','gen-v2','gen-v3','gen-v4','behavior','battlepower-v2'])require('../src/'+f+'.js');
15+
const N=global.NCB;
16+
17+
function spearman(xs,ys){
18+
const n=xs.length;if(n<3)return 0;
19+
const rank=a=>{const idx=a.map((v,i)=>[v,i]).sort((p,q)=>p[0]-q[0]);const r=new Array(n);let i=0;while(i<n){let j=i;while(j<n-1&&idx[j+1][0]===idx[i][0])j++;const avg=(i+j)/2+1;for(let k=i;k<=j;k++)r[idx[k][1]]=avg;i=j+1;}return r;};
20+
const rx=rank(xs),ry=rank(ys);
21+
const mx=rx.reduce((a,b)=>a+b,0)/n,my=ry.reduce((a,b)=>a+b,0)/n;
22+
let num=0,dx=0,dy=0;
23+
for(let i=0;i<n;i++){const a=rx[i]-mx,b=ry[i]-my;num+=a*b;dx+=a*a;dy+=b*b;}
24+
return dx&&dy?num/Math.sqrt(dx*dy):0;
25+
}
26+
function eloRatings(results,players){
27+
const rating=new Map();for(const p of players)rating.set(p,1400);
28+
for(const [a,b,w] of results){
29+
const ra=rating.get(a),rb=rating.get(b);
30+
const ea=1/(1+Math.pow(10,(rb-ra)/400)),eb=1-ea;
31+
const K=24;
32+
rating.set(a,ra+K*(w-ea));rating.set(b,rb+K*((1-w)-eb));
33+
}
34+
return rating;
35+
}
36+
function samplePairs(cards,nPairs,rngSeed){
37+
const prng=new N.Gen5PRNG(N.deriveSeed(N.seedHash?N.seedHash(String(rngSeed)):String(rngSeed)));
38+
const pick=n=>Math.floor(prng.random()*n);
39+
const pairs=[];const seen=new Set();
40+
let guard=0;
41+
while(pairs.length<nPairs&&guard++<nPairs*30){
42+
const i=pick(cards.length),j=pick(cards.length);
43+
if(i===j)continue;const key=i<j?i*10000+j:j*10000+i;
44+
if(seen.has(key))continue;seen.add(key);
45+
pairs.push([cards[i],cards[j]]);
46+
}
47+
return pairs;
48+
}
49+
function runCalibration(opts={}){
50+
const nCards=opts.cards||60,nPairs=opts.pairs||900;
51+
const cards=[];
52+
for(let i=0;i<nCards;i++){
53+
const rarity=N.RARITY_V2_ORDER[i%12];
54+
const level=10+((i*37)%91);
55+
cards.push(N.generateCardV4({seed:'cal-v4-'+i,rarity,level}));
56+
}
57+
// deploy all (distinct ids)
58+
const deployed=new Map();
59+
for(const c of cards){N.deployCard(c);deployed.set(c.id,c);}
60+
const players=cards.map(c=>c.id);
61+
const results=[];
62+
const maxRounds=opts.maxRounds||60;
63+
const pairs=samplePairs(cards,nPairs,'gen5,cal,1,2,3');
64+
for(let k=0;k<pairs.length;k++){
65+
const [a,b]=pairs[k];
66+
const e=N.createBattle({seed:N.deriveSeed(700000+k),teamA:[a.id],teamB:[b.id],maxRounds});
67+
let guard=0;
68+
while(!e.outcome().ended&&guard++<maxRounds)e.resolveRound([...N.planAI(e,'A'),...N.planAI(e,'B')]);
69+
const o=e.outcome();if(o.ended&&o.winner!=='draw'){
70+
const w=o.winner==='A'?1:0;
71+
results.push([a.id,b.id,w]);
72+
}
73+
}
74+
const rating=eloRatings(results,players);
75+
const rows=cards.map(c=>{
76+
const bp=N.battlePowerV2(c).power;
77+
return {id:c.id,rarity:c.rarity,level:c.level,bp,emp:rating.get(c.id)||1400};
78+
});
79+
const bpVals=rows.map(r=>r.bp),empVals=rows.map(r=>r.emp);
80+
const corr=spearman(bpVals,empVals);
81+
// pairwise ordering accuracy on decided pairs
82+
let agree=0,total=0;
83+
for(const [a,b,w] of results){
84+
const bpA=N.battlePowerV2(deployed.get(a)).power,bpB=N.battlePowerV2(deployed.get(b)).power;
85+
if(bpA===bpB)continue;total++;
86+
const bpSays=bpA>bpB?'A':'B';const battleSays=w===1?'A':'B';
87+
if(bpSays===battleSays)agree++;
88+
}
89+
const pairwise=total?agree/total:0;
90+
// rarity median trend
91+
const byRarity={};
92+
for(const r of rows)(byRarity[r.rarity]=byRarity[r.rarity]||[]).push(r.bp);
93+
const trend={};
94+
for(const [k,vs] of Object.entries(byRarity)){
95+
vs.sort((x,y)=>x-y);trend[k]={median:vs[Math.floor(vs.length/2)],min:vs[0],max:vs[vs.length-1]};
96+
}
97+
return{nCards,pairs:results.length,spearman:Math.round(corr*1000)/1000,pairwiseOrdering:Math.round(pairwise*1000)/1000,trend,byRarity};
98+
}
99+
100+
if(require.main===module){
101+
const n=Number(process.argv[2])||60,p=Number(process.argv[3])||900;
102+
const report=runCalibration({cards:n,pairs:p});
103+
fs.mkdirSync(path.join(__dirname,'../qa'),{recursive:true});
104+
fs.writeFileSync(path.join(__dirname,'../qa/power-v4-calibration.json'),JSON.stringify(report,null,2)+'\n');
105+
console.log(JSON.stringify(report,null,1));
106+
}
107+
module.exports={runCalibration,spearman};

0 commit comments

Comments
 (0)