{"_id":"@buffalo-game/originals-client","_rev":"4-e163000899c3eb495b80fd7f6b00750b","name":"@buffalo-game/originals-client","dist-tags":{"latest":"0.3.3"},"versions":{"0.1.0":{"name":"@buffalo-game/originals-client","version":"0.1.0","license":"SEE LICENSE IN LICENSE","_id":"@buffalo-game/originals-client@0.1.0","maintainers":[{"name":"buffalo-ops","email":"manta@dev.gamegen.diy"}],"dist":{"shasum":"d0f0f4633b1fa4dd7ef5832e404854283e081ce9","tarball":"https://registry.npmjs.org/@buffalo-game/originals-client/-/originals-client-0.1.0.tgz","fileCount":11,"integrity":"sha512-TK+yjZbJvb5ny9bT3FKIW+8NMUAbRA/1Gdb6yL5meAVNzTse6v56X7cQcfx5MK6w5R6m37hMHLrj9NqDQ2FXTQ==","signatures":[{"sig":"MEUCIQDza69dxW64vQo0+ho0jCjLab7T4m+qK8hsPNbUy+vyBQIgZsyO6/P17TKAZTyN07TFfEFe2nLZHjpsgxlfLSowNUU=","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":60543},"main":"./dist/index.js","type":"module","_from":"file:buffalo-game-originals-client-0.1.0.tgz","types":"./dist/index.d.ts","exports":{".":{"types":"./dist/index.d.ts","default":"./dist/index.js"},"./fixtures":{"types":"./dist/fixtures.d.ts","default":"./dist/fixtures.js"}},"scripts":{"build":"tsc -b"},"_npmUser":{"name":"buffalo-ops","email":"manta@dev.gamegen.diy"},"_resolved":"/private/var/folders/w0/874ycw9s0dv73b2d1476sl8m0000gn/T/2788d57f667eecbd339f1847e522a691/buffalo-game-originals-client-0.1.0.tgz","_integrity":"sha512-TK+yjZbJvb5ny9bT3FKIW+8NMUAbRA/1Gdb6yL5meAVNzTse6v56X7cQcfx5MK6w5R6m37hMHLrj9NqDQ2FXTQ==","_npmVersion":"10.9.2","description":"Browser SDK for the Originals RGS: session, money, round state machine, event playback.","directories":{},"_nodeVersion":"22.14.0","dependencies":{"@buffalo-game/originals-protocol":"0.1.0"},"publishConfig":{"access":"public"},"_hasShrinkwrap":false,"_npmOperationalInternal":{"tmp":"tmp/originals-client_0.1.0_1787123813267_0.4380195128150197","host":"s3://npm-registry-packages-npm-production"}},"0.2.0":{"name":"@buffalo-game/originals-client","version":"0.2.0","license":"SEE LICENSE IN LICENSE","_id":"@buffalo-game/originals-client@0.2.0","maintainers":[{"name":"buffalo-ops","email":"manta@dev.gamegen.diy"}],"dist":{"shasum":"11ded9701a4c28b4648d6e190279be446556b158","tarball":"https://registry.npmjs.org/@buffalo-game/originals-client/-/originals-client-0.2.0.tgz","fileCount":11,"integrity":"sha512-5TKxCD6EebGucuE+RzIDvFOur5uGtL+oRyv13TdMTyFgY/CebYw8r1E3fZw61jVFm8XMCHC+LKzWdSsqDYJZWw==","signatures":[{"sig":"MEYCIQCrkmjI41+4Um5oGNdF9gMdQv/DnbLSWtSpxJglNJcfGwIhAInINJAZcob6XerwVFAjzom4cW8UTZGJKz2luVYkPjFu","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":66133},"main":"./dist/index.js","type":"module","_from":"file:.release/client/buffalo-game-originals-client-0.2.0.tgz","types":"./dist/index.d.ts","exports":{".":{"types":"./dist/index.d.ts","default":"./dist/index.js"},"./fixtures":{"types":"./dist/fixtures.d.ts","default":"./dist/fixtures.js"}},"scripts":{"build":"tsc -b"},"_npmUser":{"name":"GitHub Actions","email":"npm-oidc-no-reply@github.com","trustedPublisher":{"id":"github","oidcConfigId":"oidc:1a93fedf-4147-43d2-a519-c1066618e490"}},"_resolved":"/home/runner/work/gamegen-originals/gamegen-originals/.release/client/buffalo-game-originals-client-0.2.0.tgz","_integrity":"sha512-5TKxCD6EebGucuE+RzIDvFOur5uGtL+oRyv13TdMTyFgY/CebYw8r1E3fZw61jVFm8XMCHC+LKzWdSsqDYJZWw==","_npmVersion":"12.0.2","description":"Browser SDK for the Originals RGS: session, money, round state machine, event playback.","directories":{},"_nodeVersion":"24.19.0","dependencies":{"@buffalo-game/originals-protocol":"0.2.0"},"publishConfig":{"access":"public"},"_hasShrinkwrap":false,"_npmOperationalInternal":{"tmp":"tmp/originals-client_0.2.0_1787383036949_0.9017566813431133","host":"s3://npm-registry-packages-npm-production"}},"0.3.0":{"name":"@buffalo-game/originals-client","version":"0.3.0","license":"SEE LICENSE IN LICENSE","_id":"@buffalo-game/originals-client@0.3.0","maintainers":[{"name":"buffalo-ops","email":"manta@dev.gamegen.diy"}],"dist":{"shasum":"748a0ded486318145a7dbc97fd103d0e9e59ed12","tarball":"https://registry.npmjs.org/@buffalo-game/originals-client/-/originals-client-0.3.0.tgz","fileCount":12,"integrity":"sha512-PB74nZjtNYNDvQhbny2Ad8gbrJbDjGLi2DiwHufOUkwNsXmzZXZlorqWGq+bPJ31lHCU393uWvcNk0Yre6qqHQ==","signatures":[{"sig":"MEUCIQD7wi8Wgu2bySngPc/4tauDXBiDu7gFkI9PTIypbWirtAIgRmovJ7zmhbSbk0j92eNb+B2OpfkRPFr9OOcwS1iFMtI=","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":105260},"main":"./dist/index.js","type":"module","_from":"file:.release/client/buffalo-game-originals-client-0.3.0.tgz","types":"./dist/index.d.ts","exports":{".":{"types":"./dist/index.d.ts","default":"./dist/index.js"},"./fixtures":{"types":"./dist/fixtures.d.ts","default":"./dist/fixtures.js"}},"scripts":{"build":"tsc -b"},"_npmUser":{"name":"GitHub Actions","email":"npm-oidc-no-reply@github.com","trustedPublisher":{"id":"github","oidcConfigId":"oidc:1a93fedf-4147-43d2-a519-c1066618e490"}},"_resolved":"/home/runner/work/gamegen-originals/gamegen-originals/.release/client/buffalo-game-originals-client-0.3.0.tgz","_integrity":"sha512-PB74nZjtNYNDvQhbny2Ad8gbrJbDjGLi2DiwHufOUkwNsXmzZXZlorqWGq+bPJ31lHCU393uWvcNk0Yre6qqHQ==","_npmVersion":"12.0.2","description":"Browser SDK for the Originals RGS: session, money, round state machine, event playback.","directories":{},"_nodeVersion":"24.19.0","dependencies":{"@buffalo-game/originals-protocol":"0.3.0"},"publishConfig":{"access":"public"},"_hasShrinkwrap":false,"_npmOperationalInternal":{"tmp":"tmp/originals-client_0.3.0_1787668687852_0.5431709765503272","host":"s3://npm-registry-packages-npm-production"}},"0.3.3":{"_id":"@buffalo-game/originals-client@0.3.3","dist":{"shasum":"be3e4383fa03d3e460d65c6ff956691d46cd2e59","tarball":"https://registry.npmjs.org/@buffalo-game/originals-client/-/originals-client-0.3.3.tgz","fileCount":18,"integrity":"sha512-nHfZJsMLhNt52DkaPBMy+X7N1bbbIgS8iOUsprCHstI/JlhM2j5bkae6AgbBvxI5h6yUH2tkIBMO9TjCflAHLw==","signatures":[{"sig":"MEYCIQDQsJzmqV/ZyAUeI9nUFSMTwxhCwbvW8eCpE6ASwLyFswIhAI1vxTIKkQwIbge4t9bFQaUTjlzHTLQ8DMJ6O18xG3G1","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"},{"keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U","sig":"MEYCIQDQxpRQjwKnpo/FzTjdRgqcsIBDtChlUxSD2jHfTp4o/AIhANYErTh9KWEzBz1x69IsjzzKtUQHis3UlYCLZnXkPzfm"}],"unpackedSize":158908},"main":"./dist/index.js","name":"@buffalo-game/originals-client","type":"module","_from":"file:.release/client/buffalo-game-originals-client-0.3.3.tgz","types":"./dist/index.d.ts","exports":{".":{"types":"./dist/index.d.ts","default":"./dist/index.js"},"./fixtures":{"types":"./dist/fixtures.d.ts","default":"./dist/fixtures.js"}},"license":"SEE LICENSE IN LICENSE","scripts":{"build":"tsc -b"},"version":"0.3.3","_npmUser":{"name":"GitHub Actions","email":"npm-oidc-no-reply@github.com","trustedPublisher":{"id":"github","oidcConfigId":"oidc:1a93fedf-4147-43d2-a519-c1066618e490"}},"_resolved":"/home/runner/work/gamegen-originals/gamegen-originals/.release/client/buffalo-game-originals-client-0.3.3.tgz","_integrity":"sha512-nHfZJsMLhNt52DkaPBMy+X7N1bbbIgS8iOUsprCHstI/JlhM2j5bkae6AgbBvxI5h6yUH2tkIBMO9TjCflAHLw==","_npmVersion":"12.0.2","description":"Browser SDK for the Originals RGS: session, money, round state machine, event playback.","directories":{},"maintainers":[{"name":"buffalo-ops","email":"manta@dev.gamegen.diy"}],"_nodeVersion":"24.19.0","dependencies":{"@buffalo-game/originals-protocol":"0.3.3"},"publishConfig":{"access":"public"},"_hasShrinkwrap":false,"_npmOperationalInternal":{"host":"s3://npm-registry-packages-npm-production","tmp":"tmp/originals-client_0.3.3_1788465227032_0.23904575180011833"}}},"time":{"created":"2026-08-19T07:16:53.039Z","modified":"2026-09-03T19:53:47.351Z","0.1.0":"2026-08-19T07:16:53.436Z","0.2.0":"2026-08-22T07:17:17.089Z","0.3.0":"2026-08-25T14:38:08.008Z","0.3.3":"2026-09-03T19:53:47.124Z"},"license":"SEE LICENSE IN LICENSE","description":"Browser SDK for the Originals RGS: session, money, round state machine, event playback.","maintainers":[{"name":"buffalo-ops","email":"manta@dev.gamegen.diy"}],"readme":"# @buffalo-game/originals-client\n\nTypeScript client for the Originals RGS — the server that decides the outcome of\na round and moves the money for it.\n\n> **Licence.** Proprietary. Use requires a current service agreement. See\n> [LICENSE](./LICENSE). Publication on npm is for distribution to authorised\n> licensees; it is not an offer of a licence to the public.\n\n## Install\n\n```bash\nnpm install @buffalo-game/originals-client\n# or: pnpm add @buffalo-game/originals-client\n```\n\n`@buffalo-game/originals-protocol` comes with it and carries the wire types.\n\n⚠️ **Coming from `0.2.0`?** `0.3.0` is a breaking release in three independent\nways — amounts are JSON integers rather than decimal strings, multipliers are\nbare floats rather than 1e6-scaled strings, and `config.maxPayout` is gone.\n**Only the multiplier change can be wrong without an error**; the other two are\nrefused outright. [CHANGELOG.md](./CHANGELOG.md) has the field list and a\nmigration order.\n\n## The shape of a session\n\nA player arrives from the operator with a **`sessionID`** in the URL. It is the\nsession's door number, not a one-time ticket: `authenticate` turns it from\nunauthorized into authorized, and the same value is then sent in the body of\nevery later call for as long as the session lives (four hours).\n\n```ts\nimport { OriginalsClient, sessionIdFromUrl } from '@buffalo-game/originals-client'\n\nconst sessionID = sessionIdFromUrl()          // reads ?sessionID= from location\nif (!sessionID) throw new Error('opened without a sessionID')\n\nconst client = new OriginalsClient({ rgsUrl })  // rgsUrl also comes from the URL\nconst { balance, config, round } = await client.authenticate(sessionID)\n```\n\n**`authenticate` is reentrant, and that is how a page reload is meant to\nrecover.** Calling it again with the same `sessionID` re-enters the same\nsession and answers with `round` — the round the player is in the middle of,\nwith its stake — rather than refusing. Nothing needs to be stored between page\nloads: the URL is the store.\n\n⚠️ **Two consequences, both deliberate, both yours to handle:**\n\n- **Do not strip `?sessionID=` out of the address bar.** A page that scrubs\n  its own URL cannot recover from a refresh.\n- **The URL is a bearer credential for the whole session.** Anyone who obtains\n  it can bet and read the balance as that player until the session ends\n  (they cannot withdraw — that never passes through the RGS). Serve the game\n  over HTTPS, set `Referrer-Policy: no-referrer`, keep query strings out of\n  access and CDN logs, and **embed the game in an iframe rather than\n  redirecting the top-level window** — a top-level redirect puts the credential\n  into browser history and `Referer` headers, and we have no way to stop that\n  from our side.\n\n`authenticate` returns everything needed to draw the game before the first bet:\n\n- `balance` — the player's money, now\n- `config` — the bet ladder, the ceiling, and `params`: **what this game lets\n  the player choose**\n- `round` — a round the player is still in the middle of. **Absent when there\n  is none** (it was `null` through `0.2.0`; Stake's own response has no such key)\n- `game` — `{ id, version }`, the game and math version actually being served\n\n⚠️ `if (round) …` is unchanged and still right. The one shape that broke is\n`round === null` as a positive test for \"nothing in progress\" — it compiles\nagainst the optional field and quietly never matches. Use `!round`.\n\n### The bet ladder\n\n```ts\nconfig.minBet            // 200000    — the floor\nconfig.effectiveMaxBet   // 100000000 — what THIS player may bet, right now\nconfig.gameMaxBet        // 500000000 — what the game itself allows\nconfig.stepBet           // 10000     — the rung spacing; see below\nconfig.betLevels         // [200000, 500000, …] — the rungs to offer\nconfig.defaultBet        // where to start the input\nconfig.defaultBetLevel   // the same figure, under Stake Engine's field name\nconfig.maxBet            // the same figure as effectiveMaxBet, under Stake's key\nconfig.gameModes         // [{ mode, costMultiplier, maxBet }] — the modes THIS player may bet on\nconfig.user.id           // a stable 64-hex key for the player; not a credential\nconfig.maxWinMultiplier  // 1000      — the ceiling, a bare float: 1000×\nconfig.jurisdiction      // twelve presentation rules; see below\n```\n\n### What the jurisdiction allows\n\n`config.jurisdiction` is always there — eleven booleans and one duration, Stake\nEngine's `JurisdictionFlags` field for field:\n\n```ts\nconfig.jurisdiction.socialCasino          // present the round as play-for-fun\nconfig.jurisdiction.disabledFullscreen    // ...disabledTurbo, disabledSuperTurbo,\nconfig.jurisdiction.disabledAutoplay      //    disabledSlamstop, disabledSpacebar,\nconfig.jurisdiction.disabledBuyFeature    //    — do not offer the control\nconfig.jurisdiction.displayNetPosition    // ...displayRTP, displaySessionTimer\nconfig.jurisdiction.minimumRoundDuration  // 0 — seconds before the next bet is accepted\n```\n\n**Honour the ones your game implements.** A `disabled*` flag means the control\nmust not be offered at all — not greyed out, not hidden behind a setting. A\n`display*` flag means the figure must be on screen. `minimumRoundDuration` is a\nfloor on how soon you may accept the next bet, `0` meaning none. In the markets\nthat set these they are law, not preference, and the operator is the licensee\nwho answers for them.\n\n🔴 **Today every operator sends the same all-`false` object, and none of them\ncan change it.** Read that before you build anything on these flags:\n\n- The RGS resolves the twelve values from the operator's record, and falls back\n  to `DEFAULT_JURISDICTION` — every flag `false`, `minimumRoundDuration: 0` —\n  when the record declares nothing.\n- **No deployed operator record declares anything.** The directory a deployed\n  RGS reads carries an operator's id and secret and nothing else; there is no\n  attribute to put a jurisdiction in and no interface for setting one. So the\n  fallback is not the exceptional case — **it is the only case in production**,\n  and will be until that configuration path is built.\n- `OperatorRecord.betLimits` has the identical gap for the identical reason, so\n  this is a known dead end rather than a surprise.\n\n**What that means for you.** The field is real, always present, and safe to read\n— that part is finished, and it is what lets a Stake-shaped client authenticate\nat all. What is not finished is anybody's ability to put a *non*-default value in\nit. So:\n\n⚠️ **All-`false` is not a clearance.** It means \"nothing has been declared\",\nwhich today is indistinguishable from \"nothing applies\" because no operator can\ndeclare. Do not read it as a regulator's answer, and do not use it to justify\nenabling a control in a market that restricts it.\n\n⚠️ **Write the handling now anyway.** When the configuration path lands, real\nvalues start arriving without a version bump — the wire shape does not change,\nonly the contents. A game that only handles all-`false` today is a game that\nsilently ignores a jurisdiction the day one is set.\n\n`DEFAULT_JURISDICTION` is re-exported here as **the inert baseline in one\nplace**: use it to build fixtures for the non-default cases your game has to\nsurvive, without hand-writing twelve field names each time.\n\n```ts\nimport { DEFAULT_JURISDICTION } from '@buffalo-game/originals-client'\nconst restricted = { ...DEFAULT_JURISDICTION, disabledAutoplay: true, minimumRoundDuration: 3 }\nconst noAutoplay: boolean = restricted.disabledAutoplay // true\n```\n\n⚠️ It is **not** a way to detect whether an operator configured anything. An\noperator that declared all twelve at their inert values and an operator that\ndeclared nothing produce byte-identical responses; comparing against this\nconstant cannot tell them apart, and nothing on the wire can.\n\n**There is no payout-ceiling field.** An operator that caps what it will pay for\none round gets that cap applied as a *bet* limit: the server divides it by\n`maxWinMultiplier` and the quotient is already inside `effectiveMaxBet`. So the\none number you enforce is the one number you are given, and a player refused for\nthe operator's payout cap is refused by the same range check as any other\nover-large stake. `maxPayout` was on the wire through `0.2.0` and is gone in\n`0.3.0`; if you read it, delete that code — you were reading the input to a\nfigure you already had.\n\n**A stake is legal at `minBet + n × stepBet`, not at any multiple of\n`stepBet`.** The two happen to coincide whenever `minBet` is itself a multiple of\n`stepBet`, which every ladder shipped so far is — so a client that gets this\nwrong looks correct today and starts offering stakes the server rejects the\nfirst time a game ships a `minBet` off the step grid.\n\n**`effectiveMaxBet` and `gameMaxBet` are not the same number and must not be\nconfused.** The game's own ceiling is `gameMaxBet`; `effectiveMaxBet` is that\nceiling after the operator's and the wallet's limits have been folded in, for\nthis player, now. **Validate against `effectiveMaxBet`.** Show `gameMaxBet` only\nif you want to explain why a player cannot bet more.\n\nThey are deliberately two fields rather than one so that neither can silently\nstand in for the other.\n\n**Do not hardcode the parameter space.** `config.params.space` maps each axis to\nits legal values, and any combination of one value per axis is legal:\n\n```jsonc\n{ \"space\": { \"risk\": [\"LOW\", \"MEDIUM\", \"HIGH\"], \"rows\": [8, 9, 10, 11, 12, 13, 14, 15, 16] } }\n```\n\n`params.constants` (if present) is for rendering only — the server does not read\nit back.\n\n`config.params` is typed `Readonly<Record<string, unknown>>`, so narrow it before\nuse — the shape above is a runtime contract, not a compile-time one.\n\n## Playing a round\n\n```ts\nconst result = await client.play({ mode: 'BASE', amount: 1000000, meta: { risk: 'HIGH', rows: 16 } })\n```\n\n⚠️ **`meta` was called `params` through `0.2.0`.** Only the name moved — it is\nstill the game's own parameters, still typed `Readonly<Record<string, unknown>>`.\n`meta` is what Stake Engine calls this slot, and sharing its name is what avoids\na rule about which of two spellings wins. `config.params`, on the *response*\nside, is a different thing and keeps its name.\n\nSingle-step games settle here. Multi-step games come back with\n`round.active === true`, and the player's in-round decisions go through `action`\nuntil `endRound`:\n\n```ts\nif (round.active) {\n  await client.action({ roundId, action: { cell: 12 } })\n  await client.endRound({ roundId })\n}\n```\n\n⚠️ **`round.active` was `round.status` through `0.2.0`**, where it read\n`'ACTIVE'` or `'ENDED'`; the `RoundStatus` type is gone. It reports only whether\nthe round is open — a win, a loss, a cash-out and a push all come back\n`active: false`, and `payoutMultiplier` is what tells them apart.\n\n`endRound`'s `roundId` is optional — `await client.endRound({})` ends whichever\nround the player has open, which is Stake's own shape. **Name it when you have\nit**, as the line above does: a named round is bound into the idempotency record\nbefore the wallet is touched, so a retry provably settles that round, and a\nrepeat gets \"already settled\" rather than the vaguer \"no round in progress\".\n\n`play`, `action`, and `endRound` each return `round`, `events`, and `balance`.\n`authenticate` returns `balance` / `config` / `round` / `game` (no `events`),\nand `balance()` returns only `{ balance }`.\n\n## Two optional fields, for clients written to Stake Engine's shape\n\nThe request envelope is closed — an unknown field is refused rather than\nignored, so a misspelt `paramz` cannot place a bet at default parameters. Two\nfields are inside it only because Stake Engine's own clients send them, and\nrefusing them would refuse those clients outright:\n\n```ts\nawait client.authenticate('the sessionID from the launch URL', { language: 'en' })\nawait client.play({ mode: 'BASE', amount: 1000000, currency: 'USD' })\n```\n\nBoth are optional and **omitting them is the normal case** — nothing in this SDK\nneeds either.\n\n- **`language`** is accepted by the server and never read. There is no localized\n  surface behind it: every message the RGS produces is English. It is also not\n  news to the server, which put it in your launch URL as `?lang=` in the first\n  place. Pass it if your client mirrors Stake's `authenticate` body; it changes\n  nothing.\n- **`currency`** is checked, not obeyed. Your session's currency was fixed when\n  the operator launched the game. Sending a *different* code is refused with\n  `ERR_VAL` — deliberately, because a bet placed in a currency you did not mean\n  is worse than a rejected request. Send what arrived in `balance.currency`, or\n  leave it out.\n\nThe game's own payload is also Stake's shape now: `play` takes **`meta`**, not\n`params` (see above). Every other unknown field is still refused, which is what\nkeeps a misspelt `metaa` from placing a bet at default parameters.\n\n## Money is a JSON integer, and it has a ceiling\n\n**All amounts are JSON integers in millionths** (`MONEY_SCALE` is 1e6, matching\nStake Engine). `1000000` is one unit of currency; `1` is a millionth of one.\nThis changed in `0.3.0` — through `0.2.0` the same values were decimal strings.\n\n```ts\namount: 1000000     // ✅  $1.00\namount: '1000000'   // ❌  rejected — a string is not this encoding\namount: 1.5         // ❌  rejected — not an integer\namount: -1000000    // ❌  rejected — not a magnitude\n```\n\nAmounts are exact to the millionth, in every currency. A currency's decimal\nplaces are a **display** choice — `balance` carries no `decimals` field, and a\npayout can legitimately be finer than the smallest coin (¥0.0045 arrives as\n`4500` and was really paid). Take the symbol and the places to render from\n`requireCurrency(balance.currency)`, and use `formatMoney(amount, currency,\n{ decimals: MONEY_DECIMALS })` when you need to show the exact figure — Stake's\nown approval checklist asks for sub-cent payouts to display correctly.\n\nNever do arithmetic on these as floats. A rounding error in a bet amount is a\nrounding error in someone's money: convert to a display value at the edge of\nyour UI, never in transit, and keep the transported value integral.\n\n⚠️ **The encoding is bounded.** `MAX_WIRE_MINOR` (exported) is\n`Number.MAX_SAFE_INTEGER` millionths — about 9.007e9 currency units. Past it\n`JSON.parse` has already changed the value before anyone can check it, so the\nserver refuses rather than rounds: `ERR_VAL` on the way in, `ERR_GEN` on the way\nout, never a truncated figure. **Bets never reach this; balances can.** A bet\nslider clamps to `effectiveMaxBet`, but a balance comes from the operator's\nwallet and nothing in your UI bounds it — in high-denomination currencies the\nwall is real, around $360,000 in VND and $563,000 in IDR.\n\n**Multipliers are numbers as well — but unscaled, so do not divide.**\n`payoutMultiplier` and `maxWinMultiplier` are bare floats: `2` is 2×, and\n`payoutMultiplier: 1.9` sits next to `payout: 19000000` in the same response.\nOnly the amount is scaled.\n\n<!-- verify-readme-types: skip — a before/after pair, so it declares `shown` twice on purpose -->\n\n```ts\n// 0.2.0 — divide to get the real figure\nconst shown = Number(round.payoutMultiplier) / 1e6 // '1900000' -> 1.9\n// 0.3.0 — it already is the real figure\nconst shown = round.payoutMultiplier               //  1.9      -> 1.9\n```\n\n⚠️ **A leftover `/ 1e6` does not throw — it renders `0.0000019`.** This is one\nof the two things in `0.3.0` that can be wrong without an error — the other is\n`round === null` on an `authenticate` response, under \"The shape of a session\"\nabove. Upgrade both packages together: on `round.payoutMultiplier` and\n`config.maxWinMultiplier` every such line then becomes a compile error.\n\n⚠️ **Events are the exception, and the compiler cannot help you there.**\n`GameEvent` is an open map — a game may emit any shape — so the\n`payoutMultiplier` on a `winInfo` or `roundEnd` frame arrives as `unknown`, and\n`unknown` passes through `Number(...)` and `!==` silently. If you animate from\nthe event stream, narrow the frame to get the field checked:\n\n```ts\nimport type { RoundEndEvent } from '@buffalo-game/originals-client'\n\nconst end = events.find((e): e is RoundEndEvent => e.type === 'roundEnd')\nif (end !== undefined) showMultiplier(end.payoutMultiplier) // number, checked\n```\n\nOtherwise grep the playback path for `1e6` by hand before you ship.\n\nThis is Stake's shape: its RGS answers `payoutMultiplier: 1.09` beside\n`payout: 1090000`, and its own SDK types the field `PayoutMultiplier: number`,\ndescribed as **\"Payout Multiplier for the bet. Payout / Amount.\"** `payout` is\nthe authoritative number either way — it is what the player was actually paid,\nand the multiplier is a figure derived from it. Never re-derive a payment from\nthe multiplier.\n\n## Retries are safe when you send a key — and you should know when one happened\n\n`play`, `action`, and `endRound` accept an **optional** `Idempotency-Key`\nheader. This client always sends one (a fresh UUID per call), so retrying with\nthe same key returns the **original** response rather than placing a second bet.\n\n⚠️ **The header is optional because Stake's protocol has none at all**, and a\nStake-shaped client that does not send it gets Stake's semantics: a request\nthat is retried after a network failure can debit twice. If you write your own\nclient against these routes, send the header. There is no server-side\nsubstitute — a key derived from the request body would deduplicate two\ndeliberate identical bets, which is a worse failure than the one it prevents.\n\nTwo fields on the result say what happened:\n\n```ts\nconst result = await client.play(request)\n\nresult.replayed          // true if the server answered from its record\nresult.balanceIsCurrent  // false if that record's balance may now be stale\n```\n\n`replayed` alone is not a problem — it means the retry worked. But a replayed\nresponse carries the balance **as it was**, so when `balanceIsCurrent` is\n`false`, call `balance()` before showing a figure to the player.\n\nTo retry a specific call yourself, pass the same key:\n\n```ts\nawait client.play(request, { idempotencyKey: key })\n```\n\n## Errors\n\nFailures throw `OriginalsClientError`. **The client has already classified it** —\nread `error.disposition` rather than matching on codes by hand:\n\n```ts\nimport { isOriginalsClientError } from '@buffalo-game/originals-client'\n\ntry {\n  await client.play(request)\n} catch (error) {\n  if (!isOriginalsClientError(error)) throw error\n\n  switch (error.disposition) {\n    case 'RETRYABLE':     // send it again, same idempotency key\n    case 'USER_ACTION':   // the player must change something (stake, balance)\n    case 'REFRESH_STATE': // your view is stale — re-read before acting\n    case 'RELAUNCH':      // the session is over; a new launch token is needed\n    case 'FATAL':         // do not retry\n  }\n}\n```\n\nThose five values are the whole set. `FATAL` is also the fallback for any 4xx the\nclient does not recognise, so a `switch` that omits it will silently do nothing.\n\n`classifyResponse(status, code)` is exported for anyone building their own\nclient; inside `OriginalsClient` it has already run.\n\n### The codes\n\n`ERR_VAL` · `ERR_IPB` · `ERR_IS` · `ERR_ATE` · `ERR_GLE` · `ERR_LOC` ·\n`ERR_GEN` · `ERR_MAINTENANCE` — the same set Stake Engine uses, plus three of\nour own for round and idempotency state:\n\n| Code | Means | What to do |\n|---|---|---|\n| `ERR_IN_PROGRESS` | The same request is still being processed | **Send it again**, same idempotency key |\n| `ERR_IDEMPOTENCY` | This key was used with a *different* body | **Never resend.** Fix the mismatch, or use a new key |\n| `ERR_ROUND_STATE` | The round is not in a state that allows this | Re-read the round; do not retry blindly |\n\nThe first two are both 409 and their correct responses are opposite. Do not\ncollapse them.\n\n## ⚠️ `launch()` is not for the browser\n\nThis package also exports `launch()`, which mints a launch token. It takes an\n`apiSecret`.\n\n**It is for an operator's own server, never for game code.** Calling it from a\nbrowser bundle puts an operator credential in every player's devtools. Game\nclients receive their `sessionID` in the URL and authorize it with `authenticate`.\n\n## What this client does not do\n\n- **It does not decide anything.** Outcomes, payouts, and balances all come from\n  the server. Any maths you keep on the client is for animation only, and a\n  disagreement between the two is a display bug, not a money bug.\n- **It does not store the credential anywhere you have to manage.** The client\n  keeps the `sessionID` in memory after `authenticate` so you do not re-pass it\n  on every call, and that is all: nothing goes into `sessionStorage`, no cookie,\n  no token exchange. The URL is the store, which is precisely what makes a\n  refresh survivable — a fresh page reads the id back out of the address bar and\n  calls `authenticate` again.\n- **It does not touch the URL, and neither should you.** `authenticate` is\n  reentrant: re-presenting the same `sessionID` re-enters the same session and\n  returns the round in progress. Scrubbing `?sessionID=` \"for tidiness\"\n  (`searchParams.delete` + `history.replaceState`) destroys the only copy of the\n  credential and makes every subsequent refresh unrecoverable. The cost of\n  leaving it there is real and is written up under \"The shape of a session\"\n  above; the mitigations are transport-level, not deletion.\n- **It does not recover a dead session.** When the session has expired or been\n  revoked, `ERR_IS` / `ERR_ATE` surface as `disposition: 'RELAUNCH'` and the\n  client does not retry — no retry can succeed. The player has to be launched\n  again by the operator. A round that reached a recorded state is not lost by\n  this: it comes back in the `round` field of the next launch's `authenticate`.\n\n## Fixtures\n\n`@buffalo-game/originals-client/fixtures` exports canned responses for writing\ntests without a server.\n\n```ts\nimport { launchResponseFixture } from '@buffalo-game/originals-client/fixtures'\n```\n","readmeFilename":"README.md"}