paddla-engine 9.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +32 -0
- package/index.js +583 -0
- package/package.json +13 -0
package/README.md
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# paddla-engine
|
|
2
|
+
|
|
3
|
+
PADDLA physics arcade engine — UVS 1.0 compatible.
|
|
4
|
+
|
|
5
|
+
**Version:** 9.0.1
|
|
6
|
+
**Protocol:** [UVS v1](https://github.com/constarik/uvs)
|
|
7
|
+
**PRNG:** ChaCha20 (RFC 8439) + SHA-512
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
npm install paddla-engine
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Usage
|
|
16
|
+
|
|
17
|
+
```js
|
|
18
|
+
const { createInitialState, tick, replay, ENGINE_VERSION } = require('paddla-engine');
|
|
19
|
+
|
|
20
|
+
const state = createInitialState(serverSeed, numBalls, betPerBall);
|
|
21
|
+
const events = tick(state, { x: 4.5, y: 2.0 });
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Exports
|
|
25
|
+
|
|
26
|
+
- `createInitialState(serverSeed, numBalls, betPerBall)`
|
|
27
|
+
- `tick(state, bumperTarget)`
|
|
28
|
+
- `replay(serverSeed, numBalls, inputLog, betPerBall)`
|
|
29
|
+
- `finishGame(state)`
|
|
30
|
+
- `UVS_PRNG` — ChaCha20-based PRNG
|
|
31
|
+
- `sha256Hex`, `sha512Hex`
|
|
32
|
+
- `ENGINE_VERSION`, `CONFIG`, `BUMPER`
|
package/index.js
ADDED
|
@@ -0,0 +1,583 @@
|
|
|
1
|
+
// PADDLA Engine v9 - UVS 1.0 Compatible
|
|
2
|
+
// Provably fair via UVS protocol: github.com/constarik/uvs
|
|
3
|
+
// combinedSeed = SHA-512(serverSeed + ":" + clientSeed + ":" + nonce)
|
|
4
|
+
// PRNG: ChaCha20 (RFC 8439), key=combinedSeed[0..31], nonce=combinedSeed[32..43]
|
|
5
|
+
|
|
6
|
+
const ENGINE_VERSION = 9;
|
|
7
|
+
|
|
8
|
+
const _crypto = typeof window === 'undefined' ? require('crypto') : null;
|
|
9
|
+
|
|
10
|
+
// ===== SHA-256 pure JS (browser fallback) =====
|
|
11
|
+
|
|
12
|
+
const _SHA256_K = new Uint32Array([
|
|
13
|
+
0x428a2f98,0x71374491,0xb5c0fbcf,0xe9b5dba5,0x3956c25b,0x59f111f1,0x923f82a4,0xab1c5ed5,
|
|
14
|
+
0xd807aa98,0x12835b01,0x243185be,0x550c7dc3,0x72be5d74,0x80deb1fe,0x9bdc06a7,0xc19bf174,
|
|
15
|
+
0xe49b69c1,0xefbe4786,0x0fc19dc6,0x240ca1cc,0x2de92c6f,0x4a7484aa,0x5cb0a9dc,0x76f988da,
|
|
16
|
+
0x983e5152,0xa831c66d,0xb00327c8,0xbf597fc7,0xc6e00bf3,0xd5a79147,0x06ca6351,0x14292967,
|
|
17
|
+
0x27b70a85,0x2e1b2138,0x4d2c6dfc,0x53380d13,0x650a7354,0x766a0abb,0x81c2c92e,0x92722c85,
|
|
18
|
+
0xa2bfe8a1,0xa81a664b,0xc24b8b70,0xc76c51a3,0xd192e819,0xd6990624,0xf40e3585,0x106aa070,
|
|
19
|
+
0x19a4c116,0x1e376c08,0x2748774c,0x34b0bcb5,0x391c0cb3,0x4ed8aa4a,0x5b9cca4f,0x682e6ff3,
|
|
20
|
+
0x748f82ee,0x78a5636f,0x84c87814,0x8cc70208,0x90befffa,0xa4506ceb,0xbef9a3f7,0xc67178f2
|
|
21
|
+
]);
|
|
22
|
+
|
|
23
|
+
function _sha256Bytes(msg) {
|
|
24
|
+
const bytes = typeof msg === 'string' ? new TextEncoder().encode(msg) : new Uint8Array(msg);
|
|
25
|
+
const bitLen = bytes.length * 8;
|
|
26
|
+
const padLen = (bytes.length + 9 + 63) & ~63;
|
|
27
|
+
const padded = new Uint8Array(padLen);
|
|
28
|
+
padded.set(bytes);
|
|
29
|
+
padded[bytes.length] = 0x80;
|
|
30
|
+
const dv = new DataView(padded.buffer);
|
|
31
|
+
dv.setUint32(padLen - 4, bitLen, false);
|
|
32
|
+
let h0=0x6a09e667,h1=0xbb67ae85,h2=0x3c6ef372,h3=0xa54ff53a;
|
|
33
|
+
let h4=0x510e527f,h5=0x9b05688c,h6=0x1f83d9ab,h7=0x5be0cd19;
|
|
34
|
+
const w = new Uint32Array(64);
|
|
35
|
+
for (let off = 0; off < padLen; off += 64) {
|
|
36
|
+
for (let i = 0; i < 16; i++) w[i] = dv.getUint32(off + i*4, false);
|
|
37
|
+
for (let i = 16; i < 64; i++) {
|
|
38
|
+
const s0=((w[i-15]>>>7)|(w[i-15]<<25))^((w[i-15]>>>18)|(w[i-15]<<14))^(w[i-15]>>>3);
|
|
39
|
+
const s1=((w[i-2]>>>17)|(w[i-2]<<15))^((w[i-2]>>>19)|(w[i-2]<<13))^(w[i-2]>>>10);
|
|
40
|
+
w[i]=(w[i-16]+s0+w[i-7]+s1)>>>0;
|
|
41
|
+
}
|
|
42
|
+
let a=h0,b=h1,c=h2,d=h3,e=h4,f=h5,g=h6,h=h7;
|
|
43
|
+
for (let i = 0; i < 64; i++) {
|
|
44
|
+
const S1=((e>>>6)|(e<<26))^((e>>>11)|(e<<21))^((e>>>25)|(e<<7));
|
|
45
|
+
const ch=(e&f)^(~e&g);
|
|
46
|
+
const t1=(h+S1+ch+_SHA256_K[i]+w[i])>>>0;
|
|
47
|
+
const S0=((a>>>2)|(a<<30))^((a>>>13)|(a<<19))^((a>>>22)|(a<<10));
|
|
48
|
+
const maj=(a&b)^(a&c)^(b&c);
|
|
49
|
+
const t2=(S0+maj)>>>0;
|
|
50
|
+
h=g;g=f;f=e;e=(d+t1)>>>0;d=c;c=b;b=a;a=(t1+t2)>>>0;
|
|
51
|
+
}
|
|
52
|
+
h0=(h0+a)>>>0;h1=(h1+b)>>>0;h2=(h2+c)>>>0;h3=(h3+d)>>>0;
|
|
53
|
+
h4=(h4+e)>>>0;h5=(h5+f)>>>0;h6=(h6+g)>>>0;h7=(h7+h)>>>0;
|
|
54
|
+
}
|
|
55
|
+
const r = new Uint8Array(32);
|
|
56
|
+
const rv = new DataView(r.buffer);
|
|
57
|
+
rv.setUint32(0,h0,false);rv.setUint32(4,h1,false);rv.setUint32(8,h2,false);rv.setUint32(12,h3,false);
|
|
58
|
+
rv.setUint32(16,h4,false);rv.setUint32(20,h5,false);rv.setUint32(24,h6,false);rv.setUint32(28,h7,false);
|
|
59
|
+
return r;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ===== SHA-512 pure JS (browser fallback) =====
|
|
63
|
+
|
|
64
|
+
const _SHA512_K = [
|
|
65
|
+
0x428a2f98d728ae22n,0x7137449123ef65cdn,0xb5c0fbcfec4d3b2fn,0xe9b5dba58189dbbcn,
|
|
66
|
+
0x3956c25bf348b538n,0x59f111f1b605d019n,0x923f82a4af194f9bn,0xab1c5ed5da6d8118n,
|
|
67
|
+
0xd807aa98a3030242n,0x12835b0145706fben,0x243185be4ee4b28cn,0x550c7dc3d5ffb4e2n,
|
|
68
|
+
0x72be5d74f27b896fn,0x80deb1fe3b1696b1n,0x9bdc06a725c71235n,0xc19bf174cf692694n,
|
|
69
|
+
0xe49b69c19ef14ad2n,0xefbe4786384f25e3n,0x0fc19dc68b8cd5b5n,0x240ca1cc77ac9c65n,
|
|
70
|
+
0x2de92c6f592b0275n,0x4a7484aa6ea6e483n,0x5cb0a9dcbd41fbd4n,0x76f988da831153b5n,
|
|
71
|
+
0x983e5152ee66dfabn,0xa831c66d2db43210n,0xb00327c898fb213fn,0xbf597fc7beef0ee4n,
|
|
72
|
+
0xc6e00bf33da88fc2n,0xd5a79147930aa725n,0x06ca6351e003826fn,0x142929670a0e6e70n,
|
|
73
|
+
0x27b70a8546d22ffcn,0x2e1b21385c26c926n,0x4d2c6dfc5ac42aedn,0x53380d139d95b3dfn,
|
|
74
|
+
0x650a73548baf63den,0x766a0abb3c77b2a8n,0x81c2c92e47edaee6n,0x92722c851482353bn,
|
|
75
|
+
0xa2bfe8a14cf10364n,0xa81a664bbc423001n,0xc24b8b70d0f89791n,0xc76c51a30654be30n,
|
|
76
|
+
0xd192e819d6ef5218n,0xd69906245565a910n,0xf40e35855771202an,0x106aa07032bbd1b8n,
|
|
77
|
+
0x19a4c116b8d2d0c8n,0x1e376c085141ab53n,0x2748774cdf8eeb99n,0x34b0bcb5e19b48a8n,
|
|
78
|
+
0x391c0cb3c5c95a63n,0x4ed8aa4ae3418acbn,0x5b9cca4f7763e373n,0x682e6ff3d6b2b8a3n,
|
|
79
|
+
0x748f82ee5defb2fcn,0x78a5636f43172f60n,0x84c87814a1f0ab72n,0x8cc702081a6439ecn,
|
|
80
|
+
0x90befffa23631e28n,0xa4506cebde82bde9n,0xbef9a3f7b2c67915n,0xc67178f2e372532bn,
|
|
81
|
+
0xca273eceea26619cn,0xd186b8c721c0c207n,0xeada7dd6cde0eb1en,0xf57d4f7fee6ed178n,
|
|
82
|
+
0x06f067aa72176fban,0x0a637dc5a2c898a6n,0x113f9804bef90daen,0x1b710b35131c471bn,
|
|
83
|
+
0x28db77f523047d84n,0x32caab7b40c72493n,0x3c9ebe0a15c9bebcn,0x431d67c49c100d4cn,
|
|
84
|
+
0x4cc5d4becb3e42b6n,0x597f299cfc657e2an,0x5fcb6fab3ad6faecn,0x6c44198c4a475817n
|
|
85
|
+
];
|
|
86
|
+
|
|
87
|
+
function _sha512Bytes(msg) {
|
|
88
|
+
const bytes = typeof msg === 'string' ? new TextEncoder().encode(msg) : new Uint8Array(msg);
|
|
89
|
+
const bitLen = BigInt(bytes.length * 8);
|
|
90
|
+
const padLen = (bytes.length + 17 + 127) & ~127;
|
|
91
|
+
const padded = new Uint8Array(padLen);
|
|
92
|
+
padded.set(bytes);
|
|
93
|
+
padded[bytes.length] = 0x80;
|
|
94
|
+
const dv = new DataView(padded.buffer);
|
|
95
|
+
dv.setBigUint64(padLen - 8, bitLen, false);
|
|
96
|
+
const M = 0xFFFFFFFFFFFFFFFFn;
|
|
97
|
+
let h0=0x6a09e667f3bcc908n,h1=0xbb67ae8584caa73bn,h2=0x3c6ef372fe94f82bn,h3=0xa54ff53a5f1d36f1n;
|
|
98
|
+
let h4=0x510e527fade682d1n,h5=0x9b05688c2b3e6c1fn,h6=0x1f83d9abfb41bd6bn,h7=0x5be0cd19137e2179n;
|
|
99
|
+
const w = new Array(80);
|
|
100
|
+
for (let off = 0; off < padLen; off += 128) {
|
|
101
|
+
for (let i = 0; i < 16; i++) w[i] = dv.getBigUint64(off + i*8, false);
|
|
102
|
+
for (let i = 16; i < 80; i++) {
|
|
103
|
+
const w15=w[i-15], w2=w[i-2];
|
|
104
|
+
const s0=((w15>>1n)|(w15<<63n))^((w15>>8n)|(w15<<56n))^(w15>>7n);
|
|
105
|
+
const s1=((w2>>19n)|(w2<<45n))^((w2>>61n)|(w2<<3n))^(w2>>6n);
|
|
106
|
+
w[i]=(w[i-16]+s0+w[i-7]+s1)&M;
|
|
107
|
+
}
|
|
108
|
+
let a=h0,b=h1,c=h2,d=h3,e=h4,f=h5,g=h6,h=h7;
|
|
109
|
+
for (let i = 0; i < 80; i++) {
|
|
110
|
+
const S1=((e>>14n)|(e<<50n))^((e>>18n)|(e<<46n))^((e>>41n)|(e<<23n));
|
|
111
|
+
const ch=(e&f)^(~e&g)&M;
|
|
112
|
+
const t1=(h+S1+ch+_SHA512_K[i]+w[i])&M;
|
|
113
|
+
const S0=((a>>28n)|(a<<36n))^((a>>34n)|(a<<30n))^((a>>39n)|(a<<25n));
|
|
114
|
+
const maj=(a&b)^(a&c)^(b&c);
|
|
115
|
+
const t2=(S0+maj)&M;
|
|
116
|
+
h=g;g=f;f=e;e=(d+t1)&M;d=c;c=b;b=a;a=(t1+t2)&M;
|
|
117
|
+
}
|
|
118
|
+
h0=(h0+a)&M;h1=(h1+b)&M;h2=(h2+c)&M;h3=(h3+d)&M;
|
|
119
|
+
h4=(h4+e)&M;h5=(h5+f)&M;h6=(h6+g)&M;h7=(h7+h)&M;
|
|
120
|
+
}
|
|
121
|
+
const r = new Uint8Array(64);
|
|
122
|
+
const rv = new DataView(r.buffer);
|
|
123
|
+
rv.setBigUint64(0,h0,false);rv.setBigUint64(8,h1,false);rv.setBigUint64(16,h2,false);rv.setBigUint64(24,h3,false);
|
|
124
|
+
rv.setBigUint64(32,h4,false);rv.setBigUint64(40,h5,false);rv.setBigUint64(48,h6,false);rv.setBigUint64(56,h7,false);
|
|
125
|
+
return r;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ===== ChaCha20 pure JS (RFC 8439, browser fallback) =====
|
|
129
|
+
|
|
130
|
+
function _chacha20Block(key, nonce12, counter, out, outOff) {
|
|
131
|
+
// key: Uint8Array[32], nonce12: Uint8Array[12], counter: number
|
|
132
|
+
const s = new Uint32Array(16);
|
|
133
|
+
const dv = new DataView(key.buffer, key.byteOffset);
|
|
134
|
+
const nv = new DataView(nonce12.buffer, nonce12.byteOffset);
|
|
135
|
+
s[0]=0x61707865;s[1]=0x3320646e;s[2]=0x79622d32;s[3]=0x6b206574;
|
|
136
|
+
for (let i=0;i<8;i++) s[4+i]=dv.getUint32(i*4,true);
|
|
137
|
+
s[12]=counter>>>0;
|
|
138
|
+
s[13]=nv.getUint32(0,true);s[14]=nv.getUint32(4,true);s[15]=nv.getUint32(8,true);
|
|
139
|
+
const x = s.slice();
|
|
140
|
+
function qr(a,b,c,d){
|
|
141
|
+
x[a]=(x[a]+x[b])>>>0;x[d]=Math.imul(x[d]^x[a],1)<<16|(x[d]^x[a])>>>16;
|
|
142
|
+
x[c]=(x[c]+x[d])>>>0;x[b]=Math.imul(x[b]^x[c],1)<<12|(x[b]^x[c])>>>20;
|
|
143
|
+
x[a]=(x[a]+x[b])>>>0;x[d]=Math.imul(x[d]^x[a],1)<<8|(x[d]^x[a])>>>24;
|
|
144
|
+
x[c]=(x[c]+x[d])>>>0;x[b]=Math.imul(x[b]^x[c],1)<<7|(x[b]^x[c])>>>25;
|
|
145
|
+
}
|
|
146
|
+
for (let i=0;i<10;i++){
|
|
147
|
+
qr(0,4,8,12);qr(1,5,9,13);qr(2,6,10,14);qr(3,7,11,15);
|
|
148
|
+
qr(0,5,10,15);qr(1,6,11,12);qr(2,7,8,13);qr(3,4,9,14);
|
|
149
|
+
}
|
|
150
|
+
const ov = new DataView(out.buffer, out.byteOffset + outOff);
|
|
151
|
+
for (let i=0;i<16;i++) ov.setUint32(i*4,((x[i]+s[i])>>>0),true);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function _chacha20KeystreamPure(key32, nonce12, numBytes) {
|
|
155
|
+
const out = new Uint8Array(numBytes);
|
|
156
|
+
const blocks = Math.ceil(numBytes / 64);
|
|
157
|
+
const tmp = new Uint8Array(64);
|
|
158
|
+
for (let b=0;b<blocks;b++){
|
|
159
|
+
_chacha20Block(key32, nonce12, b, tmp, 0);
|
|
160
|
+
const end = Math.min(64, numBytes - b*64);
|
|
161
|
+
out.set(tmp.subarray(0, end), b*64);
|
|
162
|
+
}
|
|
163
|
+
return out;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ===== Crypto helpers =====
|
|
167
|
+
|
|
168
|
+
function _bytesToHex(b) {
|
|
169
|
+
return Array.from(b).map(x=>x.toString(16).padStart(2,'0')).join('');
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function sha256Hex(msg) {
|
|
173
|
+
if (_crypto) {
|
|
174
|
+
const data = typeof msg === 'string' ? msg : Buffer.from(msg);
|
|
175
|
+
return _crypto.createHash('sha256').update(data).digest('hex');
|
|
176
|
+
}
|
|
177
|
+
return _bytesToHex(_sha256Bytes(msg));
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function sha512Hex(msg) {
|
|
181
|
+
if (_crypto) {
|
|
182
|
+
const data = typeof msg === 'string' ? msg : Buffer.from(msg);
|
|
183
|
+
return _crypto.createHash('sha512').update(data).digest('hex');
|
|
184
|
+
}
|
|
185
|
+
return _bytesToHex(_sha512Bytes(msg));
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function _chacha20Keystream(key32buf, nonce12buf, numBytes) {
|
|
189
|
+
if (_crypto) {
|
|
190
|
+
const iv = Buffer.alloc(16);
|
|
191
|
+
iv.writeUInt32LE(0, 0);
|
|
192
|
+
nonce12buf.copy ? nonce12buf.copy(iv, 4) : iv.set(nonce12buf, 4);
|
|
193
|
+
const zeros = Buffer.alloc(numBytes);
|
|
194
|
+
const cipher = _crypto.createCipheriv('chacha20', key32buf, iv);
|
|
195
|
+
return cipher.update(zeros);
|
|
196
|
+
}
|
|
197
|
+
return _chacha20KeystreamPure(key32buf, nonce12buf, numBytes);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// ===== UVS_PRNG (UVS 1.0 compliant) =====
|
|
201
|
+
|
|
202
|
+
class UVS_PRNG {
|
|
203
|
+
constructor(combinedSeedHex) {
|
|
204
|
+
// combinedSeed = SHA-512(serverSeed + ":" + clientSeed + ":" + nonce)
|
|
205
|
+
// key = bytes 0-31, nonce12 = bytes 32-43
|
|
206
|
+
const buf = _crypto
|
|
207
|
+
? Buffer.from(combinedSeedHex, 'hex')
|
|
208
|
+
: (()=>{ const b=new Uint8Array(64); for(let i=0;i<64;i++) b[i]=parseInt(combinedSeedHex.slice(i*2,i*2+2),16); return b; })();
|
|
209
|
+
const key32 = _crypto ? buf.slice(0,32) : buf.slice(0,32);
|
|
210
|
+
const nonce12 = _crypto ? buf.slice(32,44) : buf.slice(32,44);
|
|
211
|
+
// Pre-generate 256 bytes (64 uint32) — enough for any single tick
|
|
212
|
+
this._stream = _chacha20Keystream(key32, nonce12, 256);
|
|
213
|
+
this._pos = 0;
|
|
214
|
+
this._log = [];
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
nextUint32() {
|
|
218
|
+
if (this._pos + 4 > this._stream.length) {
|
|
219
|
+
throw new Error('UVS_PRNG: keystream exhausted — increase pre-generated size');
|
|
220
|
+
}
|
|
221
|
+
const val = _crypto
|
|
222
|
+
? this._stream.readUInt32LE(this._pos)
|
|
223
|
+
: new DataView(this._stream.buffer).getUint32(this._pos, true);
|
|
224
|
+
this._pos += 4;
|
|
225
|
+
this._log.push(val);
|
|
226
|
+
return val;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
nextDouble() {
|
|
230
|
+
// Two uint32 → double [0, 1) with 53-bit precision
|
|
231
|
+
const hi = this.nextUint32();
|
|
232
|
+
const lo = this.nextUint32();
|
|
233
|
+
return (hi * 0x100000000 + lo) / 0x10000000000000000;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
consumed() {
|
|
237
|
+
return [...this._log];
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Per-tick combinedSeed: serverSeed is game secret, clientSeed encodes bumper position, nonce = tick
|
|
242
|
+
function _tickCombinedSeed(serverSeed, bumperX, bumperY, tick) {
|
|
243
|
+
const clientSeed = `${bumperX.toFixed(4)}:${bumperY.toFixed(4)}`;
|
|
244
|
+
return sha512Hex(`${serverSeed}:${clientSeed}:${tick}`);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// ===== UTILITIES =====
|
|
248
|
+
|
|
249
|
+
const FP_ROUND = 1e10;
|
|
250
|
+
function fpRound(v) { return Math.round(v * FP_ROUND) / FP_ROUND; }
|
|
251
|
+
function moneyRound(v) { return Math.round(v * 100) / 100; }
|
|
252
|
+
function dist(ax,ay,bx,by) { return Math.sqrt((bx-ax)**2+(by-ay)**2); }
|
|
253
|
+
function clamp(v,min,max) { return Math.max(min,Math.min(max,v)); }
|
|
254
|
+
function bytesToHex(b) { return _bytesToHex(b); }
|
|
255
|
+
|
|
256
|
+
// ===== CONFIG =====
|
|
257
|
+
|
|
258
|
+
const CONFIG = {
|
|
259
|
+
FIELD:9, BALL_R:0.2, SPEED:0.05, GOAL_R:1.02,
|
|
260
|
+
CENTER_R:0.225, CENTER_X:4.5, CENTER_Y:4.5, COUNTDOWN:45,
|
|
261
|
+
GOLDEN_CHANCE:0.01, EXPLOSIVE_CHANCE:1/75,
|
|
262
|
+
SPAWN_COOLDOWN:60, SPAWN_INTERVAL:60, MAX_ON_FIELD:10,
|
|
263
|
+
TIMEOUT_LIMIT:5, PROGRESSIVE_CAP:5, BET_PER_BALL:5, MAX_TICKS_PER_BALL:600
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
const BUMPER = {
|
|
267
|
+
RADIUS:0.4, MIN_Y:0.4, MAX_Y:3.5, MIN_X:1.5, MAX_X:7.5,
|
|
268
|
+
MAX_SPEED:0.15, START_X:4.5, START_Y:2.0
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
// ===== HELPERS =====
|
|
272
|
+
|
|
273
|
+
function isInLeftGoal(b) { return dist(b.x,b.y,0,0) < CONFIG.GOAL_R; }
|
|
274
|
+
function isInRightGoal(b) { return dist(b.x,b.y,CONFIG.FIELD,0) < CONFIG.GOAL_R; }
|
|
275
|
+
function isGoal(b) { return isInLeftGoal(b) || isInRightGoal(b); }
|
|
276
|
+
function isInCenter(b) { return dist(b.x,b.y,CONFIG.CENTER_X,CONFIG.CENTER_Y) < CONFIG.CENTER_R+CONFIG.BALL_R; }
|
|
277
|
+
function isInUpperHalf(b) { return b.y < CONFIG.FIELD/2; }
|
|
278
|
+
|
|
279
|
+
function createBumper() {
|
|
280
|
+
return { x:BUMPER.START_X, y:BUMPER.START_Y, targetX:BUMPER.START_X, targetY:BUMPER.START_Y };
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function moveBumper(bumper) {
|
|
284
|
+
const dx=bumper.targetX-bumper.x, dy=bumper.targetY-bumper.y;
|
|
285
|
+
const d=Math.sqrt(dx*dx+dy*dy);
|
|
286
|
+
if (d > BUMPER.MAX_SPEED) {
|
|
287
|
+
bumper.x=fpRound(bumper.x+(dx/d)*BUMPER.MAX_SPEED);
|
|
288
|
+
bumper.y=fpRound(bumper.y+(dy/d)*BUMPER.MAX_SPEED);
|
|
289
|
+
} else { bumper.x=bumper.targetX; bumper.y=bumper.targetY; }
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// ===== BALL CREATION =====
|
|
293
|
+
|
|
294
|
+
function createBall(rng, id) {
|
|
295
|
+
const x = 0.5 + rng.nextDouble() * 8;
|
|
296
|
+
const y = CONFIG.FIELD - 0.3;
|
|
297
|
+
const angle = (220 + rng.nextDouble() * 100) * Math.PI / 180;
|
|
298
|
+
const typeRoll = rng.nextDouble();
|
|
299
|
+
let type='normal', multiplier=1;
|
|
300
|
+
if (typeRoll < CONFIG.GOLDEN_CHANCE) { type='golden'; multiplier=3; }
|
|
301
|
+
else if (typeRoll < CONFIG.GOLDEN_CHANCE+CONFIG.EXPLOSIVE_CHANCE) { type='explosive'; }
|
|
302
|
+
return {
|
|
303
|
+
id, x, y,
|
|
304
|
+
dx: Math.cos(angle)*CONFIG.SPEED,
|
|
305
|
+
dy: Math.sin(angle)*CONFIG.SPEED,
|
|
306
|
+
value:9, ticksSinceCountdown:0, alive:true, type, multiplier
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function randomizeBounce(ball, rng) {
|
|
311
|
+
const variation = (rng.nextDouble() - 0.5) * 0.1 * Math.PI;
|
|
312
|
+
const angle = Math.atan2(ball.dy, ball.dx) + variation;
|
|
313
|
+
const speed = Math.sqrt(ball.dx**2 + ball.dy**2);
|
|
314
|
+
ball.dx = fpRound(Math.cos(angle) * speed);
|
|
315
|
+
ball.dy = fpRound(Math.sin(angle) * speed);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function collideBallBumper(ball, bumper, rng) {
|
|
319
|
+
const d = dist(ball.x,ball.y,bumper.x,bumper.y);
|
|
320
|
+
const minDist = CONFIG.BALL_R+BUMPER.RADIUS;
|
|
321
|
+
if (d < minDist && d > 0) {
|
|
322
|
+
const nx=(ball.x-bumper.x)/d, ny=(ball.y-bumper.y)/d;
|
|
323
|
+
const dot=ball.dx*nx+ball.dy*ny;
|
|
324
|
+
ball.dx=fpRound(ball.dx-2*dot*nx); ball.dy=fpRound(ball.dy-2*dot*ny);
|
|
325
|
+
ball.x=fpRound(bumper.x+nx*minDist); ball.y=fpRound(bumper.y+ny*minDist);
|
|
326
|
+
randomizeBounce(ball, rng);
|
|
327
|
+
return true;
|
|
328
|
+
}
|
|
329
|
+
return false;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// ===== GAME STATE =====
|
|
333
|
+
|
|
334
|
+
function createInitialState(serverSeed, numBalls, betPerBall=5) {
|
|
335
|
+
const serverSeedHash = sha256Hex(serverSeed);
|
|
336
|
+
const sessionId = sha256Hex(`${serverSeedHash}:uvs-paddla:1`);
|
|
337
|
+
return {
|
|
338
|
+
// UVS session header
|
|
339
|
+
uvsHeader: {
|
|
340
|
+
type: 'uvs-header',
|
|
341
|
+
uvsVersion: 1,
|
|
342
|
+
sessionId,
|
|
343
|
+
serverSeedHash,
|
|
344
|
+
clientSeed: 'uvs-paddla', // bumper position encoded per-tick in combinedSeed
|
|
345
|
+
minNonce: 1,
|
|
346
|
+
params: { numBalls, betPerBall },
|
|
347
|
+
extensions: ['physics-arcade@1.0'],
|
|
348
|
+
timestamp: new Date().toISOString()
|
|
349
|
+
},
|
|
350
|
+
// Game state
|
|
351
|
+
serverSeed,
|
|
352
|
+
rng: null, // initialized per-tick in tick()
|
|
353
|
+
balls: [],
|
|
354
|
+
bumper: createBumper(),
|
|
355
|
+
tickCount: 0,
|
|
356
|
+
ballsSpawned: 0,
|
|
357
|
+
numBalls,
|
|
358
|
+
betPerBall,
|
|
359
|
+
spawnCooldown: 0,
|
|
360
|
+
progressive: 1,
|
|
361
|
+
timeoutCount: 0,
|
|
362
|
+
totalWin: 0,
|
|
363
|
+
finished: false,
|
|
364
|
+
nextBallId: 1,
|
|
365
|
+
inputLog: []
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// ===== TICK =====
|
|
370
|
+
|
|
371
|
+
function tick(state, bumperTarget) {
|
|
372
|
+
if (state.finished) return [];
|
|
373
|
+
const events = [];
|
|
374
|
+
|
|
375
|
+
state.tickCount++;
|
|
376
|
+
if (state.spawnCooldown > 0) state.spawnCooldown--;
|
|
377
|
+
|
|
378
|
+
// Apply bumper input first
|
|
379
|
+
if (bumperTarget) {
|
|
380
|
+
state.bumper.targetX = clamp(bumperTarget.x, BUMPER.MIN_X, BUMPER.MAX_X);
|
|
381
|
+
state.bumper.targetY = clamp(bumperTarget.y, BUMPER.MIN_Y, BUMPER.MAX_Y);
|
|
382
|
+
}
|
|
383
|
+
moveBumper(state.bumper);
|
|
384
|
+
|
|
385
|
+
// UVS: fresh PRNG per tick, combinedSeed encodes tick + bumper position
|
|
386
|
+
const combinedSeed = _tickCombinedSeed(
|
|
387
|
+
state.serverSeed, state.bumper.x, state.bumper.y, state.tickCount
|
|
388
|
+
);
|
|
389
|
+
state.rng = new UVS_PRNG(combinedSeed);
|
|
390
|
+
|
|
391
|
+
// Log input for replay
|
|
392
|
+
state.inputLog.push({
|
|
393
|
+
tick: state.tickCount,
|
|
394
|
+
target: { x: state.bumper.targetX, y: state.bumper.targetY }
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
// Spawn
|
|
398
|
+
if (state.tickCount % CONFIG.SPAWN_INTERVAL === 0 &&
|
|
399
|
+
state.balls.length < CONFIG.MAX_ON_FIELD &&
|
|
400
|
+
state.spawnCooldown <= 0 &&
|
|
401
|
+
state.ballsSpawned < state.numBalls) {
|
|
402
|
+
const ball = createBall(state.rng, state.nextBallId++);
|
|
403
|
+
state.balls.push(ball);
|
|
404
|
+
state.ballsSpawned++;
|
|
405
|
+
state.spawnCooldown = CONFIG.SPAWN_COOLDOWN;
|
|
406
|
+
events.push({ type:'spawn', ball });
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// Update balls
|
|
410
|
+
for (const b of state.balls) {
|
|
411
|
+
if (!b.alive) continue;
|
|
412
|
+
b.ticksSinceCountdown++;
|
|
413
|
+
b.x=fpRound(b.x+b.dx); b.y=fpRound(b.y+b.dy);
|
|
414
|
+
const R=CONFIG.BALL_R, F=CONFIG.FIELD;
|
|
415
|
+
let hitWall=false;
|
|
416
|
+
if (b.x-R<0) { b.x=R; b.dx=-b.dx; hitWall=true; }
|
|
417
|
+
if (b.x+R>F) { b.x=F-R; b.dx=-b.dx; hitWall=true; }
|
|
418
|
+
if (b.y-R<0) { b.y=R; b.dy=-b.dy; hitWall=true; }
|
|
419
|
+
if (b.y+R>F) { b.y=F-R; b.dy=-b.dy; hitWall=true; }
|
|
420
|
+
if (b.type==='normal' && b.ticksSinceCountdown>=CONFIG.COUNTDOWN && b.value>0) {
|
|
421
|
+
b.value--; b.ticksSinceCountdown=0;
|
|
422
|
+
if (b.value<=0) { b.alive=false; b.diedFromTimeout=true; events.push({type:'timeout',ball:b}); }
|
|
423
|
+
}
|
|
424
|
+
if (b.alive && hitWall) randomizeBounce(b, state.rng);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// Bumper collision
|
|
428
|
+
for (const b of state.balls) {
|
|
429
|
+
if (b.alive && collideBallBumper(b, state.bumper, state.rng))
|
|
430
|
+
events.push({ type:'bumperHit', ball:b });
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// Center recharge
|
|
434
|
+
for (const b of state.balls) {
|
|
435
|
+
if (b.alive && isInCenter(b)) {
|
|
436
|
+
const dx=b.x-CONFIG.CENTER_X, dy=b.y-CONFIG.CENTER_Y;
|
|
437
|
+
const d=Math.sqrt(dx*dx+dy*dy);
|
|
438
|
+
if (d>0) { b.dx=(dx/d)*CONFIG.SPEED; b.dy=(dy/d)*CONFIG.SPEED; randomizeBounce(b,state.rng); }
|
|
439
|
+
if (b.type==='normal' && b.value<9) {
|
|
440
|
+
b.value=9; b.ticksSinceCountdown=0;
|
|
441
|
+
events.push({ type:'recharge', ball:b });
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// Goals
|
|
447
|
+
for (const ball of state.balls) {
|
|
448
|
+
if (!ball.alive) continue;
|
|
449
|
+
if (isGoal(ball)) {
|
|
450
|
+
const bs=state.betPerBall/5;
|
|
451
|
+
const prize=moneyRound(ball.value*ball.multiplier*state.progressive*bs);
|
|
452
|
+
state.totalWin=moneyRound(state.totalWin+prize);
|
|
453
|
+
if (ball.type==='golden') state.timeoutCount=0;
|
|
454
|
+
if (state.progressive<CONFIG.PROGRESSIVE_CAP) state.progressive++;
|
|
455
|
+
events.push({ type:'goal', ball, prize, side:isInLeftGoal(ball)?'left':'right' });
|
|
456
|
+
ball.alive=false;
|
|
457
|
+
if (ball.type==='explosive') {
|
|
458
|
+
state.timeoutCount=0;
|
|
459
|
+
events.push({ type:'explosion', ball, x:ball.x, y:ball.y });
|
|
460
|
+
for (const o of state.balls) {
|
|
461
|
+
if (o.alive && o.id!==ball.id && isInUpperHalf(o)) {
|
|
462
|
+
const ep=moneyRound(o.value*o.multiplier*state.progressive*bs);
|
|
463
|
+
state.totalWin=moneyRound(state.totalWin+ep);
|
|
464
|
+
if (state.progressive<CONFIG.PROGRESSIVE_CAP) state.progressive++;
|
|
465
|
+
events.push({ type:'exploded', ball:o, prize:ep });
|
|
466
|
+
o.alive=false;
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// Ball-ball collisions
|
|
474
|
+
for (let i=0;i<state.balls.length;i++) {
|
|
475
|
+
for (let j=i+1;j<state.balls.length;j++) {
|
|
476
|
+
const b1=state.balls[i], b2=state.balls[j];
|
|
477
|
+
if (!b1.alive||!b2.alive) continue;
|
|
478
|
+
if (dist(b1.x,b1.y,b2.x,b2.y)<CONFIG.BALL_R*2) {
|
|
479
|
+
const s1=b1.type!=='normal', s2=b2.type!=='normal';
|
|
480
|
+
if (s1&&s2) {
|
|
481
|
+
const dx=b2.x-b1.x, dy=b2.y-b1.y, d=Math.sqrt(dx*dx+dy*dy)||1;
|
|
482
|
+
const nx=dx/d, ny=dy/d, ov=CONFIG.BALL_R*2-d;
|
|
483
|
+
if (ov>0) { b1.x-=nx*ov*0.5; b1.y-=ny*ov*0.5; b2.x+=nx*ov*0.5; b2.y+=ny*ov*0.5; }
|
|
484
|
+
b1.dx=-nx*CONFIG.SPEED; b1.dy=-ny*CONFIG.SPEED;
|
|
485
|
+
b2.dx=nx*CONFIG.SPEED; b2.dy=ny*CONFIG.SPEED;
|
|
486
|
+
randomizeBounce(b1,state.rng); randomizeBounce(b2,state.rng);
|
|
487
|
+
continue;
|
|
488
|
+
}
|
|
489
|
+
if (s1) { b2.alive=false; const cp=moneyRound(state.betPerBall/5); state.totalWin=moneyRound(state.totalWin+cp); events.push({type:'collision',winner:b1,loser:b2,prize:cp}); continue; }
|
|
490
|
+
if (s2) { b1.alive=false; const cp=moneyRound(state.betPerBall/5); state.totalWin=moneyRound(state.totalWin+cp); events.push({type:'collision',winner:b2,loser:b1,prize:cp}); continue; }
|
|
491
|
+
if (b1.value===b2.value) {
|
|
492
|
+
const prize=moneyRound(b1.value*2*(state.betPerBall/5));
|
|
493
|
+
state.totalWin=moneyRound(state.totalWin+prize);
|
|
494
|
+
events.push({ type:'double', b1, b2, prize });
|
|
495
|
+
if (state.rng.nextDouble()<0.5) b2.alive=false; else b1.alive=false;
|
|
496
|
+
} else {
|
|
497
|
+
const cp=moneyRound(state.betPerBall/5); state.totalWin=moneyRound(state.totalWin+cp);
|
|
498
|
+
const loser=b1.value<b2.value?b1:b2, winner=b1.value<b2.value?b2:b1;
|
|
499
|
+
loser.alive=false;
|
|
500
|
+
const dx=winner.x-loser.x, dy=winner.y-loser.y, d=Math.sqrt(dx*dx+dy*dy)||1;
|
|
501
|
+
winner.dx=(dx/d)*CONFIG.SPEED; winner.dy=(dy/d)*CONFIG.SPEED;
|
|
502
|
+
randomizeBounce(winner,state.rng);
|
|
503
|
+
events.push({ type:'collision', winner, loser, prize:cp });
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// Timeouts
|
|
510
|
+
for (const b of state.balls) {
|
|
511
|
+
if (!b.alive && b.diedFromTimeout) {
|
|
512
|
+
state.timeoutCount++;
|
|
513
|
+
if (state.timeoutCount>=CONFIG.TIMEOUT_LIMIT) {
|
|
514
|
+
state.progressive=1; state.timeoutCount=0;
|
|
515
|
+
events.push({ type:'progressiveReset' });
|
|
516
|
+
}
|
|
517
|
+
b.diedFromTimeout=false;
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
state.balls = state.balls.filter(b=>b.alive);
|
|
522
|
+
|
|
523
|
+
// Auto-collect special balls
|
|
524
|
+
if (state.balls.length>0 && !state.balls.some(b=>b.type==='normal')) {
|
|
525
|
+
for (const b of state.balls) {
|
|
526
|
+
if (b.alive) {
|
|
527
|
+
const prize=moneyRound(b.value*b.multiplier*state.progressive*(state.betPerBall/5));
|
|
528
|
+
state.totalWin=moneyRound(state.totalWin+prize);
|
|
529
|
+
if (state.progressive<CONFIG.PROGRESSIVE_CAP) state.progressive++;
|
|
530
|
+
events.push({ type:'autoCollect', ball:b, prize });
|
|
531
|
+
b.alive=false;
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
state.balls=[];
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
// End condition
|
|
538
|
+
if (state.ballsSpawned>=state.numBalls && state.balls.length===0) {
|
|
539
|
+
state.finished=true;
|
|
540
|
+
events.push({ type:'gameEnd', totalWin:state.totalWin });
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
return events;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
// ===== REPLAY =====
|
|
547
|
+
|
|
548
|
+
function replay(serverSeed, numBalls, inputLog, betPerBall=5) {
|
|
549
|
+
const state = createInitialState(serverSeed, numBalls, betPerBall);
|
|
550
|
+
let inputIdx=0, safety=0;
|
|
551
|
+
const maxTicks = numBalls * CONFIG.MAX_TICKS_PER_BALL;
|
|
552
|
+
while (!state.finished && safety<maxTicks) {
|
|
553
|
+
let target=null;
|
|
554
|
+
if (inputIdx<inputLog.length && inputLog[inputIdx].tick===state.tickCount+1) {
|
|
555
|
+
target=inputLog[inputIdx].target; inputIdx++;
|
|
556
|
+
} else if (state.tickCount>0) {
|
|
557
|
+
target={ x:state.bumper.targetX, y:state.bumper.targetY };
|
|
558
|
+
}
|
|
559
|
+
tick(state,target); safety++;
|
|
560
|
+
}
|
|
561
|
+
return state;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
// ===== FINISH =====
|
|
565
|
+
|
|
566
|
+
function finishGame(state) {
|
|
567
|
+
const target={ x:state.bumper.targetX, y:state.bumper.targetY };
|
|
568
|
+
let safety=0;
|
|
569
|
+
while (!state.finished && safety<100000) { tick(state,target); safety++; }
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
// ===== EXPORT =====
|
|
573
|
+
|
|
574
|
+
if (typeof module !== 'undefined' && module.exports) {
|
|
575
|
+
module.exports = {
|
|
576
|
+
ENGINE_VERSION,
|
|
577
|
+
CONFIG, BUMPER,
|
|
578
|
+
UVS_PRNG,
|
|
579
|
+
createInitialState, tick, replay, finishGame,
|
|
580
|
+
clamp, fpRound, bytesToHex,
|
|
581
|
+
sha256Hex, sha512Hex
|
|
582
|
+
};
|
|
583
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "paddla-engine",
|
|
3
|
+
"version": "9.0.1",
|
|
4
|
+
"description": "PADDLA physics arcade engine — UVS 1.0 compatible (ChaCha20 + SHA-512)",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "Uncloned Math <uncloned.work>",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "https://github.com/constarik/paddla-engine.git"
|
|
11
|
+
},
|
|
12
|
+
"keywords": ["paddla", "uvs", "provably-fair", "chacha20", "gaming"]
|
|
13
|
+
}
|