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