feedback7: the battle sim uses engine-independent math — Chrome, Firefox, Safari and the server agree bit for bit (PR #373 by @Cloudnyco)
- server/sim/detmath.js: hypot (sqrt of the squares, rescaled outside 2^±500), powi (integer powers in double-double, one rounding), sin / cos (fdlibm 5.3) and atan2 (fdlibm / FreeBSD msun) built only from correctly rounded IEEE operations; the sim's Math.hypot / sin / cos / atan2 / pow calls go through it (65 files); eslint.config.js forbids the approximated Math functions under server/sim and test/sim/detmath.test.js scans for them. - sin / cos / atan2 are the algorithms V8 runs in Node, so the server's results stay; only hypot differs from V8's. Golden: 2 moved — diy-027 (a hypot distance, damage +80) and coop3-ABYSS-5 (a later card pick from round 9, same end). - Applied unchanged after review (Grok static review: MERGE; the diff read in full: no network, no new dependency). - Tests: broad node --test 5155 / 5155 with the new file tracked (detmath 7 / 7, package 10 / 10); lint 0 errors, typecheck, check:imports.
This commit is contained in:
1 parent
2580152a3a
commit
ae22335cfe
65 files changed
+616
-141
No files matched your search
+11
-11
@@ -1085,26 +1085,26 @@
|
||||
"bossHpLeft": null,
|
||||
"rngDraws": 0,
|
||||
"errors": {"count":0,"kinds":[]},
|
||||
"players": {"p1":{"killed":111,"total":111,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":1987984,"bossDmg":0,"heal":0,"deaths":0,"layerGains":{}}},
|
||||
"players": {"p1":{"killed":111,"total":111,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":1988064,"bossDmg":0,"heal":0,"deaths":0,"layerGains":{}}},
|
||||
"units": [
|
||||
["p1",1,"op","chess_char_5_diy1_b",0,"uniequip_002_narant",2331,1,10,9,139430,0,0,91,1],
|
||||
["p1",2,"op","chess_char_5_diy2_b",1,"uniequip_002_narant",2331,1,11,5,155980,0,0,88,3],
|
||||
["p1",3,"op","chess_char_5_diy1_b",2,"uniequip_002_narant",2331,1,1,0,740,0,0,5,0],
|
||||
["p1",4,"op","chess_char_5_diy2_b",0,"uniequip_003_narant",2541,1,3,1,12484,0,0,4,1],
|
||||
["p1",5,"op","chess_char_5_diy1_b",1,"uniequip_003_narant",2541,1,5,16,222860,0,0,87,3],
|
||||
["p1",6,"op","chess_char_5_diy2_b",2,"uniequip_003_narant",2541,1,7,11,255194,0,0,170,4],
|
||||
["p1",7,"op","chess_char_6_diy1_a",0,null,2000,1,9,18,305504,0,0,107,1],
|
||||
["p1",8,"op","chess_char_6_diy2_a",1,null,2000,1,12,23,447750,0,0,177,4],
|
||||
["p1",5,"op","chess_char_5_diy1_b",1,"uniequip_003_narant",2541,1,5,18,216963,0,0,85,3],
|
||||
["p1",6,"op","chess_char_5_diy2_b",2,"uniequip_003_narant",2541,1,7,11,262559,0,0,170,4],
|
||||
["p1",7,"op","chess_char_6_diy1_a",0,null,2000,1,9,19,302382,0,0,106,1],
|
||||
["p1",8,"op","chess_char_6_diy2_a",1,null,2000,1,12,27,452511,0,0,177,4],
|
||||
["p1",9,"op","chess_char_6_diy1_a",2,null,2000,1,2,0,251,0,0,5,0],
|
||||
["p1",10,"op","chess_char_6_diy2_b",0,"none",2331,1,4,0,12573,0,0,4,1],
|
||||
["p1",11,"op","chess_char_6_diy1_b",1,"none",2331,1,6,18,198679,0,0,90,3],
|
||||
["p1",12,"op","chess_char_6_diy2_b",2,"none",2331,1,8,10,236538,0,0,173,4]
|
||||
["p1",11,"op","chess_char_6_diy1_b",1,"none",2331,1,6,10,197025,0,0,89,3],
|
||||
["p1",12,"op","chess_char_6_diy2_b",2,"none",2331,1,8,11,235164,0,0,173,4]
|
||||
],
|
||||
"enemies": {"enemy_1010_demon":[54,54,0,0,755827],"enemy_1320_wdrrl_2":[18,18,0,0,839808],"enemy_1422_lrsldr_2":[39,39,0,0,400308]},
|
||||
"hooks": [1,123,5886,1001,1001,2296,2296,0,111,111,25,20,0,9735,233,0,111,0,1,111,0,0,0],
|
||||
"events": {"atk":1001,"deploy":12,"die":111,"dmg":2296,"engage":12,"fx":453,"skill":45,"spawn":123,"status":245},
|
||||
"fx": {"aoe":137,"bounce":316},
|
||||
"snaps": "b054f186 5f1791df 62fad2b9 772b925e 3c33e5ff 792df80b b43d3d8f 4283ce62 933a9c03 44991c3e b9347ef2 941827a5 73c09893 9ddf9d2c dbaf0a96 63e46fbe d21c76f5 55bdfa26 bfaf6c80 6968f2d8"
|
||||
"hooks": [1,123,5886,997,997,2292,2292,0,111,111,25,20,0,9744,235,0,111,0,1,111,0,0,0],
|
||||
"events": {"atk":997,"deploy":12,"die":111,"dmg":2292,"engage":12,"fx":451,"skill":45,"spawn":123,"status":251},
|
||||
"fx": {"aoe":137,"bounce":314},
|
||||
"snaps": "b054f186 5f1791df 3d017ddc 6caa5d05 8c321828 7e0d74c4 924d8c48 bc44fa99 448a38b4 f74013c9 0e587d4d 2185ab3b 9482eaf7 c6e6f386 7ce17fa6 af9ad4b3 97bb47c5 500c935b 824afe6d 6e3d1bcb"
|
||||
},
|
||||
"diy-028": {
|
||||
"about": "6_diy1_b=narant/S1/002_narant 6_diy2_b=narant/S2/002_narant 6_diy1_b=narant/S3/002_narant 6_diy2_b=narant/S1/003_narant 6_diy1_b=narant/S2/003_narant 6_diy2_b=narant/S3/003_narant 5_diy1_a=logos/S1 5_diy2_a=logos/S2 5_diy1_a=logos/S3 5_diy2_b=logos/S1 5_diy1_b=logos/S2 5_diy2_b=logos/S3",
|
||||
|
||||
+19
-19
@@ -755,7 +755,7 @@
|
||||
"boost": null,
|
||||
"setup": {"stageId":"act2autochess_m03","bossId":"boss_4","hiddenBossId":"boss_9","factions":["FLY","TIMES","REFLECTION"]},
|
||||
"end": {"victory":false,"roundsPassed":11,"hiddenReached":false,"hiddenCleared":false,"reason":"eliminated","teamLp":null,"bossPoolLeft":null,"virtualMs":115700},
|
||||
"rng": {"setup":25,"shop":525,"waves":0,"draft":75,"bots":24735,"meta":10},
|
||||
"rng": {"setup":25,"shop":510,"waves":0,"draft":75,"bots":24735,"meta":8},
|
||||
"errors": {"engine":0,"logged":0,"sim":0,"dispatcher":0},
|
||||
"rounds": [
|
||||
{
|
||||
@@ -808,27 +808,27 @@
|
||||
},
|
||||
{
|
||||
"round": 9,
|
||||
"ai_0": [1,3,12,0,1,4,8,"47bf424b",157,"deputShip 2/24, indomShip 1/17, kazimierzShip 1/28, siracusaShip 0/24, skillfulShip 0/19, soloShip 1/14, suntShip 1/14, swiftShip 1/60",36,53,17,76],
|
||||
"ai_0": [1,3,12,0,0,4,8,"e73e84c6",170,"deputShip 2/24, indomShip 1/29, kazimierzShip 1/29, siracusaShip 0/24, skillfulShip 0/19, soloShip 1/14, suntShip 1/14, swiftShip 1/60",32,48,16,76],
|
||||
"ai_1": [1,25,12,0,0,4,8,"fec52ed0",54,"arcaneShip 1/17, indomShip 1/18, kazimierzShip 0/7, siracusaShip 1/12, soloShip 1/0, suntShip 1/0, swiftShip 0/17, victoriaShip 1/7, visiShip 0/4, yanShip 0/2",19,43,24,76],
|
||||
"ai_2": [1,15,12,0,0,4,8,"549a87ca",142,"deputShip 1/12, indomShip 1/12, siracusaShip 1/88, skillfulShip 1/2, soloShip 1/18, suntShip 1/10, swiftShip 0/6, yanShip 0/1",52,59,7,80]
|
||||
},
|
||||
{
|
||||
"round": 10,
|
||||
"ai_0": [0,0,13,0,0,5,0,"811c9dc5",0,"deputShip 0/28, indomShip 0/41, kazimierzShip 0/33, siracusaShip 0/24, skillfulShip 0/19, soloShip 0/18, suntShip 0/18, swiftShip 0/66",8,18,10,88],
|
||||
"ai_0": [0,0,12,0,0,5,0,"811c9dc5",0,"deputShip 0/28, indomShip 0/53, kazimierzShip 0/34, siracusaShip 0/24, skillfulShip 0/19, soloShip 0/18, suntShip 0/18, swiftShip 0/66",8,18,10,86],
|
||||
"ai_1": [1,15,12,0,0,5,8,"fd30bf27",65,"arcaneShip 1/17, indomShip 1/24, kazimierzShip 0/7, siracusaShip 1/12, soloShip 1/0, suntShip 1/0, swiftShip 0/17, victoriaShip 1/12, visiShip 0/4, yanShip 0/2",8,18,10,88],
|
||||
"ai_2": [1,10,12,0,0,5,8,"1a4bfd5d",172,"deputShip 1/14, indomShip 1/16, siracusaShip 1/106, skillfulShip 1/4, soloShip 1/20, suntShip 1/12, swiftShip 0/6, yanShip 0/1",13,18,5,91]
|
||||
"ai_2": [1,10,12,0,2,5,8,"1a4bfd5d",172,"deputShip 1/14, indomShip 1/16, siracusaShip 1/106, skillfulShip 1/4, soloShip 1/20, suntShip 1/12, swiftShip 0/6, yanShip 0/1",13,18,5,92]
|
||||
},
|
||||
{
|
||||
"round": 11,
|
||||
"ai_0": [0,0,0,0,0,5,0,"811c9dc5",0,"deputShip 0/28, indomShip 0/41, kazimierzShip 0/33, siracusaShip 0/24, skillfulShip 0/19, soloShip 0/18, suntShip 0/18, swiftShip 0/66",null,null,null,88],
|
||||
"ai_1": [1,5,12,0,0,5,8,"6eebc22c",74,"arcaneShip 2/31, indomShip 1/28, kazimierzShip 0/11, siracusaShip 0/12, soloShip 1/0, suntShip 1/0, swiftShip 0/21, victoriaShip 1/15, visiShip 0/4, yanShip 0/2",5,25,20,101],
|
||||
"ai_2": [1,3,12,0,0,5,8,"a5108c6b",202,"deputShip 1/16, indomShip 1/20, kazimierzShip 0/8, kjeragShip 0/8, siracusaShip 1/130, skillfulShip 0/4, soloShip 1/22, suntShip 2/14, swiftShip 0/14, yanShip 0/1",18,25,7,103]
|
||||
"ai_0": [0,0,0,0,0,5,0,"811c9dc5",0,"deputShip 0/28, indomShip 0/53, kazimierzShip 0/34, siracusaShip 0/24, skillfulShip 0/19, soloShip 0/18, suntShip 0/18, swiftShip 0/66",null,null,null,86],
|
||||
"ai_1": [1,5,12,0,1,5,8,"38b1f273",92,"arcaneShip 2/29, indomShip 1/38, kazimierzShip 0/7, siracusaShip 0/12, soloShip 1/0, suntShip 1/0, swiftShip 0/17, victoriaShip 1/25, visiShip 0/4, yanShip 0/2",7,25,18,99],
|
||||
"ai_2": [1,4,14,0,0,5,8,"a5108c6b",208,"deputShip 1/16, indomShip 1/20, kazimierzShip 0/8, kjeragShip 0/8, siracusaShip 1/136, skillfulShip 0/4, soloShip 1/22, suntShip 2/14, swiftShip 0/14, yanShip 0/1",19,25,6,105]
|
||||
},
|
||||
{
|
||||
"round": 12,
|
||||
"ai_0": [0,0,0,0,0,5,0,"811c9dc5",0,"deputShip 0/28, indomShip 0/41, kazimierzShip 0/33, siracusaShip 0/24, skillfulShip 0/19, soloShip 0/18, suntShip 0/18, swiftShip 0/66",null,null,null,88],
|
||||
"ai_1": [0,0,12,0,0,5,0,"811c9dc5",0,"arcaneShip 0/45, indomShip 0/38, kazimierzShip 0/11, siracusaShip 0/12, swiftShip 0/21, victoriaShip 0/24, visiShip 0/4, yanShip 0/2",5,57,52,116],
|
||||
"ai_2": [0,0,12,0,0,6,0,"811c9dc5",0,"deputShip 0/20, indomShip 0/20, kazimierzShip 0/8, kjeragShip 0/8, siracusaShip 0/136, skillfulShip 0/4, soloShip 0/26, suntShip 0/18, swiftShip 0/14, yanShip 0/1",13,57,44,116]
|
||||
"ai_0": [0,0,0,0,0,5,0,"811c9dc5",0,"deputShip 0/28, indomShip 0/53, kazimierzShip 0/34, siracusaShip 0/24, skillfulShip 0/19, soloShip 0/18, suntShip 0/18, swiftShip 0/66",null,null,null,86],
|
||||
"ai_1": [0,0,13,0,0,6,0,"811c9dc5",0,"arcaneShip 0/35, indomShip 0/48, kazimierzShip 0/7, siracusaShip 0/12, swiftShip 0/17, victoriaShip 0/34, visiShip 0/4, yanShip 0/2",5,57,52,110],
|
||||
"ai_2": [0,0,12,0,0,6,0,"811c9dc5",0,"deputShip 0/20, indomShip 0/20, kazimierzShip 0/8, kjeragShip 0/8, siracusaShip 0/142, skillfulShip 0/4, soloShip 0/26, suntShip 0/18, swiftShip 0/14, yanShip 0/1",14,57,43,118]
|
||||
}
|
||||
],
|
||||
"players": {
|
||||
@@ -839,12 +839,12 @@
|
||||
"eliminatedRound": 10,
|
||||
"lp": 0,
|
||||
"bandId": "band_chiave",
|
||||
"title": {"id":"comment_5","name":"万事俱备","picId":"comment_icon_5","text":"装备配发数量最多"},
|
||||
"title": {"id":"comment_6","name":"挥金如土","picId":"comment_icon_6","text":"消耗资金最多"},
|
||||
"trophies": 2,
|
||||
"reward": 191,
|
||||
"stats": {"activatedLayers":0,"bossDamage":0,"dmgDealt":1727766,"gold":88,"itemsEquipped":9,"kills":225,"leaks":38,"lpLost":30,"merges":4,"perfectRounds":6,"refreshes":13},
|
||||
"stats": {"activatedLayers":0,"bossDamage":0,"dmgDealt":1678337,"gold":86,"itemsEquipped":8,"kills":221,"leaks":37,"lpLost":30,"merges":4,"perfectRounds":6,"refreshes":12},
|
||||
"lineup": [],
|
||||
"bonds": ["deputShip 0/28","indomShip 0/41","kazimierzShip 0/33","siracusaShip 0/24","skillfulShip 0/19","soloShip 0/18","suntShip 0/18","swiftShip 0/66"]
|
||||
"bonds": ["deputShip 0/28","indomShip 0/53","kazimierzShip 0/34","siracusaShip 0/24","skillfulShip 0/19","soloShip 0/18","suntShip 0/18","swiftShip 0/66"]
|
||||
},
|
||||
"ai_1": {
|
||||
"alive": false,
|
||||
@@ -853,12 +853,12 @@
|
||||
"eliminatedRound": 12,
|
||||
"lp": 0,
|
||||
"bandId": "band_sarkazb",
|
||||
"title": {"id":"comment_4","name":"精英云集","picId":"comment_icon_4","text":"晋升精锐次数最多"},
|
||||
"title": {"id":"comment_5","name":"万事俱备","picId":"comment_icon_5","text":"装备配发数量最多"},
|
||||
"trophies": 2,
|
||||
"reward": 255,
|
||||
"stats": {"activatedLayers":0,"bossDamage":0,"dmgDealt":2389171,"gold":116,"itemsEquipped":19,"kills":220,"leaks":120,"lpLost":50,"merges":7,"perfectRounds":6,"refreshes":12},
|
||||
"stats": {"activatedLayers":0,"bossDamage":0,"dmgDealt":2293866,"gold":110,"itemsEquipped":10,"kills":222,"leaks":118,"lpLost":50,"merges":5,"perfectRounds":6,"refreshes":10},
|
||||
"lineup": [],
|
||||
"bonds": ["arcaneShip 0/45","indomShip 0/38","kazimierzShip 0/11","siracusaShip 0/12","swiftShip 0/21","victoriaShip 0/24","visiShip 0/4","yanShip 0/2"]
|
||||
"bonds": ["arcaneShip 0/35","indomShip 0/48","kazimierzShip 0/7","siracusaShip 0/12","swiftShip 0/17","victoriaShip 0/34","visiShip 0/4","yanShip 0/2"]
|
||||
},
|
||||
"ai_2": {
|
||||
"alive": false,
|
||||
@@ -867,12 +867,12 @@
|
||||
"eliminatedRound": 12,
|
||||
"lp": 0,
|
||||
"bandId": "band_lmlee",
|
||||
"title": {"id":"comment_6","name":"挥金如土","picId":"comment_icon_6","text":"消耗资金最多"},
|
||||
"title": {"id":"comment_4","name":"精英云集","picId":"comment_icon_4","text":"晋升精锐次数最多"},
|
||||
"trophies": 2,
|
||||
"reward": 255,
|
||||
"stats": {"activatedLayers":0,"bossDamage":0,"dmgDealt":3734992,"gold":116,"itemsEquipped":8,"kills":216,"leaks":100,"lpLost":34,"merges":6,"perfectRounds":2,"refreshes":16},
|
||||
"stats": {"activatedLayers":0,"bossDamage":0,"dmgDealt":3450226,"gold":118,"itemsEquipped":9,"kills":218,"leaks":98,"lpLost":33,"merges":6,"perfectRounds":2,"refreshes":17},
|
||||
"lineup": [],
|
||||
"bonds": ["deputShip 0/20","indomShip 0/20","kazimierzShip 0/8","kjeragShip 0/8","siracusaShip 0/136","skillfulShip 0/4","soloShip 0/26","suntShip 0/18","swiftShip 0/14","yanShip 0/1"]
|
||||
"bonds": ["deputShip 0/20","indomShip 0/20","kazimierzShip 0/8","kjeragShip 0/8","siracusaShip 0/142","skillfulShip 0/4","soloShip 0/26","suntShip 0/18","swiftShip 0/14","yanShip 0/1"]
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
// server/sim/detmath.js — the sim's engine-independent math (hypot, powi, sin, cos, atan2). The functions use only
|
||||
// + − × ÷ and Math.sqrt, so their results are the same bits on every engine; the digests below pin them (the same
|
||||
// digests hold in Chrome, Firefox and Safari). Also: the special values of ECMA-262, powi correctly rounded and hypot
|
||||
// within 1 ulp against exact BigInt references, and no sim file calls an implementation-approximated Math function.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readdirSync, readFileSync } from 'node:fs';
|
||||
import { join, dirname, relative } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { hypot, powi, sin, cos, atan2 } from '../../server/sim/detmath.js';
|
||||
|
||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '../..');
|
||||
|
||||
const lcg = (seed) => { let s = seed >>> 0; return () => { s = (Math.imul(s, 1664525) + 1013904223) >>> 0; return s / 4294967296; }; };
|
||||
const WORDS = new Uint32Array(2), F = new Float64Array(WORDS.buffer);
|
||||
/** FNV-1a over the IEEE 754 bits of the values */
|
||||
const digest = (values) => {
|
||||
let h = 0x811c9dc5;
|
||||
for (const v of values) { F[0] = v; for (const w of WORDS) for (let k = 0; k < 4; k++) { h ^= (w >>> (8 * k)) & 0xff; h = Math.imul(h, 16777619); } }
|
||||
return (h >>> 0).toString(16).padStart(8, '0');
|
||||
};
|
||||
const N = 20000;
|
||||
|
||||
test('the results are pinned bit for bit (engine-independent digests)', () => {
|
||||
const runs = {
|
||||
hypot: () => { const r = lcg(1); return Array.from({ length: N }, () => hypot((r() * 2 - 1) * 20, (r() < 0.3 ? 0 : (r() * 2 - 1) * 20))); },
|
||||
powi: () => { const r = lcg(2); return Array.from({ length: N }, () => powi(r() * 3, Math.floor(r() * 41))); },
|
||||
sin: () => { const r = lcg(3); return Array.from({ length: N }, () => sin((r() * 2 - 1) * 4 * Math.PI)); },
|
||||
cos: () => { const r = lcg(4); return Array.from({ length: N }, () => cos((r() * 2 - 1) * 4 * Math.PI)); },
|
||||
atan2: () => { const r = lcg(5); return Array.from({ length: N }, () => atan2((r() * 2 - 1) * 20, (r() * 2 - 1) * 20)); },
|
||||
};
|
||||
const want = { hypot: '6e3e3a7d', powi: 'f80a3234', sin: '73aedcb1', cos: '51b52432', atan2: 'a0ab93f0' };
|
||||
for (const [name, run] of Object.entries(runs)) assert.equal(digest(run()), want[name], name);
|
||||
});
|
||||
|
||||
test('values that tell the engines apart', () => {
|
||||
// V8's Math.hypot(3, 2) is 3.6055512754639896; correctly rounded √13 is 3.605551275463989 (also Firefox, Safari)
|
||||
assert.equal(hypot(3, 2), 3.605551275463989);
|
||||
// fdlibm (Node 24–26); Chrome 154's correctly rounded Math.sin / Math.atan2 give 0.7931254945086103 / 0.6023179867387402
|
||||
assert.equal(sin(2.22566898269194), 0.7931254945086104);
|
||||
assert.equal(cos(0.74090688893123), 0.7378567508940902);
|
||||
assert.equal(atan2(0.5098986645868919, 0.74162210852462), 0.6023179867387403);
|
||||
// correctly rounded; Safari's Math.pow squares and multiplies in double and gives 2.593742460100002
|
||||
assert.equal(powi(1.1, 10), 2.5937424601000023);
|
||||
assert.equal(powi(0.85, 3), 0.6141249999999999);
|
||||
});
|
||||
|
||||
test('special values follow ECMA-262', () => {
|
||||
const same = (a, b, msg) => assert.ok(Object.is(a, b), `${msg}: ${a} vs ${b}`);
|
||||
for (const [x, y, want] of [[Infinity, NaN, Infinity], [NaN, -Infinity, Infinity], [NaN, 1, NaN], [0, 0, 0], [-0, -0, 0], [0, -2, 2], [-3, 0, 3]]) same(hypot(x, y), want, `hypot(${x}, ${y})`);
|
||||
for (const x of [0, -0]) { same(sin(x), x, 'sin ±0'); same(cos(x), 1, 'cos ±0'); }
|
||||
for (const x of [Infinity, -Infinity, NaN]) { same(sin(x), NaN, `sin(${x})`); same(cos(x), NaN, `cos(${x})`); }
|
||||
const P = Math.PI, H = Math.PI / 2, Q = Math.PI / 4;
|
||||
const table = [
|
||||
[0, 1, 0], [-0, 1, -0], [0, -1, P], [-0, -1, -P], [0, 0, 0], [-0, 0, -0], [0, -0, P], [-0, -0, -P],
|
||||
[1, 0, H], [-1, 0, -H], [1, -0, H], [-1, -0, -H],
|
||||
[1, Infinity, 0], [-1, Infinity, -0], [1, -Infinity, P], [-1, -Infinity, -P],
|
||||
[Infinity, 1, H], [-Infinity, 1, -H], [Infinity, Infinity, Q], [-Infinity, Infinity, -Q], [Infinity, -Infinity, 3 * Q], [-Infinity, -Infinity, -3 * Q],
|
||||
[NaN, 1, NaN], [1, NaN, NaN], [1, 1, Q], [-1, -1, -3 * Q],
|
||||
];
|
||||
for (const [y, x, want] of table) same(atan2(y, x), want, `atan2(${y}, ${x})`);
|
||||
same(powi(2, 0), 1, 'x^0'); same(powi(0, 0), 1, '0^0'); same(powi(0, 3), 0, '0^3'); same(powi(-2, 3), -8, '(-2)^3');
|
||||
same(powi(2, -2), 0.25, 'x^-n'); same(powi(Infinity, 2), Infinity, '∞²'); same(powi(NaN, 0), 1, 'NaN^0');
|
||||
});
|
||||
|
||||
// exact references: x = M·2^E with M an integer
|
||||
const DV = new DataView(new ArrayBuffer(8));
|
||||
const decompose = (x) => {
|
||||
DV.setFloat64(0, Math.abs(x));
|
||||
const hiw = DV.getUint32(0), low = DV.getUint32(4), be = (hiw >>> 20) & 0x7ff;
|
||||
const M = (BigInt(hiw & 0xfffff) << 32n) | BigInt(low);
|
||||
return be === 0 ? [M, -1074] : [M | (1n << 52n), be - 1075];
|
||||
};
|
||||
const bitlen = (n) => n.toString(2).length;
|
||||
/** round M·2^e (M > 0) to the nearest double, ties to even; `sticky` = the true value is a little above M·2^e */
|
||||
const roundBig = (M, e, sticky = false) => {
|
||||
const L = bitlen(M);
|
||||
if (L <= 53 && !sticky) return Number(M) * 2 ** e;
|
||||
const sh = BigInt(Math.max(0, L - 53));
|
||||
let q = M >> sh;
|
||||
const rem = M & ((1n << sh) - 1n), half = sh > 0n ? 1n << (sh - 1n) : 0n;
|
||||
if (rem > half || (rem === half && (sticky || (q & 1n)))) q++;
|
||||
return Number(q) * 2 ** (e + Number(sh));
|
||||
};
|
||||
const isqrt = (n) => { if (n < 2n) return n; let x = BigInt(Math.floor(Math.sqrt(Number(n)))); for (;;) { const y = (x + n / x) >> 1n; if (y >= x) { while (x * x > n) x--; while ((x + 1n) * (x + 1n) <= n) x++; return x; } x = y; } };
|
||||
const exactHypot = (x, y) => {
|
||||
if (x === 0 || y === 0) return Math.abs(x || y);
|
||||
const [mx, ex] = decompose(x), [my, ey] = decompose(y), E = Math.min(ex, ey);
|
||||
const S = (mx * mx << BigInt(2 * (ex - E))) + (my * my << BigInt(2 * (ey - E)));
|
||||
const k = Math.max(0, Math.ceil((130 - bitlen(S)) / 2));
|
||||
const q = isqrt(S << BigInt(2 * k));
|
||||
return roundBig(q, E - k, q * q !== S << BigInt(2 * k));
|
||||
};
|
||||
const exactPowi = (x, n) => { if (n === 0) return 1; const [M, E] = decompose(x); return roundBig(M ** BigInt(n), E * n); };
|
||||
const ORD = new BigInt64Array(F.buffer);
|
||||
const ulps = (a, b) => { F[0] = a; const ia = ORD[0]; F[0] = b; return Number(ia - ORD[0]); };
|
||||
|
||||
test('powi is correctly rounded (exact BigInt reference)', () => {
|
||||
for (let b = 1; b <= 300; b++) for (let n = 0; n <= 24; n++) {
|
||||
const x = b / 100;
|
||||
assert.equal(powi(x, n), exactPowi(x, n), `${x}^${n}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('hypot is within 1 ulp of √(x² + y²), and symmetric', () => {
|
||||
const r = lcg(7);
|
||||
for (let i = 0; i < 3000; i++) {
|
||||
const x = (r() * 2 - 1) * 20, y = (r() * 2 - 1) * 20;
|
||||
assert.ok(Math.abs(ulps(hypot(x, y), exactHypot(x, y))) <= 1, `hypot(${x}, ${y})`);
|
||||
assert.equal(hypot(x, y), hypot(y, x)); assert.equal(hypot(-x, y), hypot(x, y));
|
||||
}
|
||||
for (const s of [1e300, 1e-300]) assert.ok(Math.abs(ulps(hypot(3 * s, 4 * s), 5 * s)) <= 1, `rescaled ${s}`);
|
||||
});
|
||||
|
||||
test('sin, cos and atan2 stay within 2 ulp of the engine and keep their symmetries', () => {
|
||||
const r = lcg(9);
|
||||
for (let i = 0; i < 20000; i++) {
|
||||
const x = (r() * 2 - 1) * 10;
|
||||
assert.ok(Math.abs(ulps(sin(x), Math.sin(x))) <= 2 && Math.abs(ulps(cos(x), Math.cos(x))) <= 2, `x = ${x}`);
|
||||
assert.ok(Object.is(sin(-x), -sin(x)) && Object.is(cos(-x), cos(x)), `symmetry at ${x}`);
|
||||
const y = (r() * 2 - 1) * 10;
|
||||
assert.ok(Math.abs(ulps(atan2(y, x), Math.atan2(y, x))) <= 2, `atan2(${y}, ${x})`);
|
||||
assert.ok(Object.is(atan2(-y, x), -atan2(y, x)), `atan2 odd in y at (${y}, ${x})`);
|
||||
}
|
||||
});
|
||||
|
||||
test('the sim never calls an implementation-approximated Math function', () => {
|
||||
const APPROX = /\bMath\s*\.\s*(acos|acosh|asin|asinh|atan|atanh|atan2|cbrt|cos|cosh|exp|expm1|hypot|log|log1p|log10|log2|pow|sin|sinh|tan|tanh)\b/;
|
||||
const files = [];
|
||||
const walk = (dir) => { for (const e of readdirSync(dir, { withFileTypes: true })) { const p = join(dir, e.name); if (e.isDirectory()) walk(p); else if (e.name.endsWith('.js')) files.push(p); } };
|
||||
walk(join(ROOT, 'server/sim'));
|
||||
assert.ok(files.length > 50);
|
||||
const bad = [];
|
||||
for (const f of files) {
|
||||
const rel = relative(ROOT, f).replaceAll('\\', '/');
|
||||
if (rel === 'server/sim/detmath.js') continue;
|
||||
const code = readFileSync(f, 'utf8').replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:'"`])\/\/.*$/gm, '$1');
|
||||
if (APPROX.test(code)) bad.push(`${rel}: ${code.match(APPROX)[0]}`);
|
||||
}
|
||||
assert.deepEqual(bad, [], 'use server/sim/detmath.js');
|
||||
});
|
||||
Reference in new issue
Block a user