mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-25 23:18:51 -07:00
[auth] cli add tokens to plus members
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
-- Expose the account's Rec Room Plus flag (`hasPlus` in the JSON blob) as an indexed
|
||||
-- generated column, so "every Plus member" is an index walk rather than a scan of every
|
||||
-- account's JSON. The reader is the operator's periodic token reload
|
||||
-- (`just reload-plus`, packages/tools/src/cmd/admin.cmd.ts), which credits the
|
||||
-- `balance` row of each Plus member in one statement.
|
||||
--
|
||||
-- The index is PARTIAL: it holds only the rows where the flag is set, so it is as small
|
||||
-- as the subscriber list and never grows with the account table. A query must say
|
||||
-- exactly `has_plus = 1` to use it. `json_extract` yields 1 for JSON true, 0 for false
|
||||
-- and NULL when the key is absent, so neither a revoked nor a never-granted account is
|
||||
-- in it. Kept in sync with SCHEMA_DDL in @repo/domain's accounts-db.ts (and its mirror
|
||||
-- in apps/econ/src/avatar-db.ts).
|
||||
|
||||
ALTER TABLE account ADD COLUMN has_plus INTEGER GENERATED ALWAYS AS (json_extract(data, '$.hasPlus')) VIRTUAL;
|
||||
CREATE INDEX IF NOT EXISTS idx_account_has_plus ON account (has_plus) WHERE has_plus = 1;
|
||||
@@ -15,9 +15,11 @@ export const SCHEMA_DDL: string[] = [
|
||||
data TEXT NOT NULL,
|
||||
avatar TEXT,
|
||||
account_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.accountId')) VIRTUAL,
|
||||
username_lower TEXT GENERATED ALWAYS AS (lower(json_extract(data, '$.username'))) VIRTUAL
|
||||
username_lower TEXT GENERATED ALWAYS AS (lower(json_extract(data, '$.username'))) VIRTUAL,
|
||||
has_plus INTEGER GENERATED ALWAYS AS (json_extract(data, '$.hasPlus')) VIRTUAL
|
||||
)`,
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS idx_accounts_account_id ON account (account_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_account_has_plus ON account (has_plus) WHERE has_plus = 1`,
|
||||
]
|
||||
|
||||
/** The stored avatar payload — opaque JSON the client sets and reads back. */
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { BalancePlatform } from '../../notify/src/notification-payloads'
|
||||
import { CurrencyType } from './currency'
|
||||
|
||||
// The currency vocabulary and the Plus reload SQL live in the import-free `currency.ts` so
|
||||
// the Node CLI in @repo/tools can share them; re-exported here so this stays the module
|
||||
// everything else reads balances from.
|
||||
export { CurrencyType, DEFAULT_STARTING_TOKENS, PLUS_MEMBERS_SQL, plusReloadSql } from './currency'
|
||||
export type { CurrencyTypeValue } from './currency'
|
||||
|
||||
/**
|
||||
* Currency balances on the shared `recflare` D1 database.
|
||||
@@ -14,50 +21,6 @@ import { BalancePlatform } from '../../notify/src/notification-payloads'
|
||||
* it doesn't clash with the auth/rooms migration histories on the same database.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The currencies the client knows about (its `CurrencyType` enum, obfuscated
|
||||
* `GKPEKOLBBJL` — which lists every member below except `RoomInventoryItem`). The client sends
|
||||
* these ints in the balance/storefront paths — `/api/storefronts/v4/balance/2` is
|
||||
* RecCenterTokens — so the values are fixed by the client, not by us.
|
||||
*
|
||||
* What each one is:
|
||||
* - `Invalid` (0): the enum's zero value. Never a real balance; a request for it is
|
||||
* a client bug or a probe, and `isSpendable` rejects it.
|
||||
* - `LaserTagTickets` (1): earned in the Laser Tag activity, spent in its own store.
|
||||
* - `RecCenterTokens` (2): THE general-purpose currency — what players mean by
|
||||
* "tokens", earned everywhere and spent in the avatar/gift-drop storefronts. This
|
||||
* is the only one the client fetches on load, and the only one we grant at signup.
|
||||
* - `LostSkullsGold` (100) / `DraculaSilver` (101): per-activity currencies for the
|
||||
* Isle of Lost Skulls and Rise of Jumbo quests. Earned and spent inside those
|
||||
* activities only.
|
||||
* - `RecRoyaleSeason1` (200): a season currency for Rec Royale; legacy, no live faucet.
|
||||
* - `RoomCurrency` (300) / `RoomInventoryItem` (301): NOT global balances. These are
|
||||
* scoped to a specific room and served by the `/api/roomcurrencies/*` and
|
||||
* `/api/roomconsumables/*` endpoints, whose rows are keyed by room as well as by
|
||||
* account. They must never be stored in this (account, currency) table — a single
|
||||
* row here couldn't say WHICH room's currency it is, so a player's coins in one
|
||||
* room would spend in every other. `isSpendable` rejects them for that reason.
|
||||
* - `ProgressionEvent` (400): an XP/progression counter the client models as a
|
||||
* currency. Not spendable.
|
||||
* - `RoomieCredits` (500): the newest member of the client's enum. Nothing here grants or
|
||||
* spends it yet; it is listed so the enum matches the client's and a value arriving on
|
||||
* the wire has a name rather than reading as an unknown number.
|
||||
*/
|
||||
export const CurrencyType = {
|
||||
Invalid: 0,
|
||||
LaserTagTickets: 1,
|
||||
RecCenterTokens: 2,
|
||||
LostSkullsGold: 100,
|
||||
DraculaSilver: 101,
|
||||
RecRoyaleSeason1: 200,
|
||||
RoomCurrency: 300,
|
||||
RoomInventoryItem: 301,
|
||||
ProgressionEvent: 400,
|
||||
RoomieCredits: 500,
|
||||
} as const
|
||||
|
||||
export type CurrencyTypeValue = (typeof CurrencyType)[keyof typeof CurrencyType]
|
||||
|
||||
/**
|
||||
* The account-scoped currencies this table stores. Everything else in `CurrencyType`
|
||||
* is either not a balance (Invalid, ProgressionEvent) or is room-scoped and belongs to
|
||||
@@ -74,13 +37,6 @@ const SPENDABLE: readonly number[] = [
|
||||
/** Whether a currency is an account-scoped balance this table may hold. */
|
||||
export const isSpendable = (currencyType: number): boolean => SPENDABLE.includes(currencyType)
|
||||
|
||||
/**
|
||||
* The signup grant, in RecCenterTokens, when the `STARTING_TOKENS` var is unset.
|
||||
* An operator overrides it in wrangler.jsonc `vars`; 0 is a valid setting and means
|
||||
* players start broke.
|
||||
*/
|
||||
export const DEFAULT_STARTING_TOKENS = 10_000
|
||||
|
||||
/**
|
||||
* What a player starts with, granted lazily the first time their balances are touched
|
||||
* (see `ensureStartingBalances`). Currencies absent here start at 0.
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* The currency vocabulary — which currencies exist and what a player starts with — and the
|
||||
* operator's Plus token reload, which is SQL text built from them.
|
||||
*
|
||||
* Separate from `balance-db.ts` for the same reason `catalog-load.ts` is separate from
|
||||
* `catalog-db.ts`: that module types its queries with `D1Database`, a Workers type, and the
|
||||
* `runx admin reload-plus` CLI (@repo/tools) that runs the reload is a plain Node process
|
||||
* with no such types. Nothing here imports anything, so both sides can use it; `balance-db.ts`
|
||||
* re-exports all of it, so the worker keeps importing from there.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The currencies the client knows about (its `CurrencyType` enum, obfuscated
|
||||
* `GKPEKOLBBJL` — which lists every member below except `RoomInventoryItem`). The client sends
|
||||
* these ints in the balance/storefront paths — `/api/storefronts/v4/balance/2` is
|
||||
* RecCenterTokens — so the values are fixed by the client, not by us.
|
||||
*
|
||||
* What each one is:
|
||||
* - `Invalid` (0): the enum's zero value. Never a real balance; a request for it is
|
||||
* a client bug or a probe, and `isSpendable` rejects it.
|
||||
* - `LaserTagTickets` (1): earned in the Laser Tag activity, spent in its own store.
|
||||
* - `RecCenterTokens` (2): THE general-purpose currency — what players mean by
|
||||
* "tokens", earned everywhere and spent in the avatar/gift-drop storefronts. This
|
||||
* is the only one the client fetches on load, and the only one we grant at signup.
|
||||
* - `LostSkullsGold` (100) / `DraculaSilver` (101): per-activity currencies for the
|
||||
* Isle of Lost Skulls and Rise of Jumbo quests. Earned and spent inside those
|
||||
* activities only.
|
||||
* - `RecRoyaleSeason1` (200): a season currency for Rec Royale; legacy, no live faucet.
|
||||
* - `RoomCurrency` (300) / `RoomInventoryItem` (301): NOT global balances. These are
|
||||
* scoped to a specific room and served by the `/api/roomcurrencies/*` and
|
||||
* `/api/roomconsumables/*` endpoints, whose rows are keyed by room as well as by
|
||||
* account. They must never be stored in this (account, currency) table — a single
|
||||
* row here couldn't say WHICH room's currency it is, so a player's coins in one
|
||||
* room would spend in every other. `isSpendable` rejects them for that reason.
|
||||
* - `ProgressionEvent` (400): an XP/progression counter the client models as a
|
||||
* currency. Not spendable.
|
||||
* - `RoomieCredits` (500): the newest member of the client's enum. Nothing here grants or
|
||||
* spends it yet; it is listed so the enum matches the client's and a value arriving on
|
||||
* the wire has a name rather than reading as an unknown number.
|
||||
*/
|
||||
export const CurrencyType = {
|
||||
Invalid: 0,
|
||||
LaserTagTickets: 1,
|
||||
RecCenterTokens: 2,
|
||||
LostSkullsGold: 100,
|
||||
DraculaSilver: 101,
|
||||
RecRoyaleSeason1: 200,
|
||||
RoomCurrency: 300,
|
||||
RoomInventoryItem: 301,
|
||||
ProgressionEvent: 400,
|
||||
RoomieCredits: 500,
|
||||
} as const
|
||||
|
||||
export type CurrencyTypeValue = (typeof CurrencyType)[keyof typeof CurrencyType]
|
||||
|
||||
/**
|
||||
* The signup grant, in RecCenterTokens, when the `STARTING_TOKENS` var is unset.
|
||||
* An operator overrides it in wrangler.jsonc `vars`; 0 is a valid setting and means
|
||||
* players start broke.
|
||||
*/
|
||||
export const DEFAULT_STARTING_TOKENS = 10_000
|
||||
|
||||
/**
|
||||
* The periodic Rec Room Plus token reload: credit `amount` RecCenterTokens to every account
|
||||
* whose `hasPlus` flag is set, in ONE statement. Run by the operator from the CLI
|
||||
* (`just reload-plus <amount>`), which is why this is SQL text rather than a `D1Database`
|
||||
* helper — `wrangler d1 execute --command` takes a statement, not bindings, so the two
|
||||
* numbers are validated here and inlined as integer literals.
|
||||
*
|
||||
* Subscribers are found through `account.has_plus`, the generated column with the partial
|
||||
* index (auth migration 0009); the predicate must stay exactly `has_plus = 1` for the
|
||||
* index to be used.
|
||||
*
|
||||
* A subscriber with NO balance row yet is one who has never touched econ, so their signup
|
||||
* grant (`ensureStartingBalances`) is still pending. Their row is created here with
|
||||
* `startingTokens + amount` — the grant and the reload together — because a row holding
|
||||
* only the reload would make `ensureStartingBalances` skip the grant forever (the grant is
|
||||
* "row exists → already granted"). Existing rows simply gain `amount`, whatever they hold,
|
||||
* including a 0 spent down to. Same `startingTokens` rule as everywhere else: it is the
|
||||
* operator's `STARTING_TOKENS`, passed in, never a module default read here.
|
||||
*
|
||||
* `RETURNING` reports each credited account and its resulting balance, since wrangler's
|
||||
* meta carries no reliable change count.
|
||||
*/
|
||||
export function plusReloadSql(amount: number, startingTokens: number): string {
|
||||
if (!Number.isInteger(amount) || amount <= 0) {
|
||||
throw new Error(`plusReloadSql: amount must be a positive integer, got ${amount}`)
|
||||
}
|
||||
if (!Number.isInteger(startingTokens) || startingTokens < 0) {
|
||||
throw new Error(
|
||||
`plusReloadSql: startingTokens must be a non-negative integer, got ${startingTokens}`
|
||||
)
|
||||
}
|
||||
return `INSERT INTO balance (account_id, currency_type, amount)
|
||||
SELECT account_id, ${CurrencyType.RecCenterTokens}, ${startingTokens + amount}
|
||||
FROM account WHERE has_plus = 1
|
||||
ON CONFLICT (account_id, currency_type) DO UPDATE SET amount = amount + ${amount}
|
||||
RETURNING account_id, amount`
|
||||
}
|
||||
|
||||
/**
|
||||
* Every Plus subscriber with their current RecCenterTokens balance — NULL for one whose
|
||||
* signup grant is still pending (see `plusReloadSql`). The read half of the reload: what
|
||||
* the CLI shows before it credits, and all a `--dry-run` does.
|
||||
*/
|
||||
export const PLUS_MEMBERS_SQL = `SELECT a.account_id, json_extract(a.data, '$.username') AS username, b.amount
|
||||
FROM account a
|
||||
LEFT JOIN balance b ON b.account_id = a.account_id AND b.currency_type = ${CurrencyType.RecCenterTokens}
|
||||
WHERE a.has_plus = 1
|
||||
ORDER BY a.account_id`
|
||||
@@ -44,6 +44,8 @@ import {
|
||||
CurrencyType,
|
||||
DEFAULT_STARTING_TOKENS,
|
||||
getBalance,
|
||||
PLUS_MEMBERS_SQL,
|
||||
plusReloadSql,
|
||||
spendCurrency,
|
||||
} from '../../balance-db'
|
||||
import {
|
||||
@@ -5656,3 +5658,73 @@ describe('catalog', () => {
|
||||
expect(await countCatalog(env.DB)).toEqual({ avatar_item: 1 })
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* The operator's Plus token reload (`just reload-plus`), which is SQL text rather than a
|
||||
* helper because it is run through `wrangler d1 execute`. Exercised here against a real D1
|
||||
* so the upsert's two halves — "existing row gains the amount" and "missing row is created
|
||||
* with the signup grant folded in" — are pinned, and so the `has_plus` generated column the
|
||||
* statement depends on is proven to exist in the mirrored schema.
|
||||
*/
|
||||
describe('plus token reload', () => {
|
||||
const seed = async (accountId: number, hasPlus: boolean | undefined) => {
|
||||
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
|
||||
.bind(
|
||||
JSON.stringify({
|
||||
accountId,
|
||||
username: `Plus${accountId}`,
|
||||
...(hasPlus === undefined ? {} : { hasPlus }),
|
||||
})
|
||||
)
|
||||
.run()
|
||||
}
|
||||
|
||||
test('credits every hasPlus account once, folding the signup grant into new rows', async () => {
|
||||
await seed(8001, true) // subscriber with an existing balance
|
||||
await seed(8002, true) // subscriber who has never touched econ: no balance row
|
||||
await seed(8003, false) // explicitly not a subscriber
|
||||
await seed(8004, undefined) // flag never set
|
||||
await env.DB.prepare(
|
||||
'INSERT OR REPLACE INTO balance (account_id, currency_type, amount) VALUES (8001, 2, 250)'
|
||||
).run()
|
||||
await env.DB.prepare(
|
||||
'INSERT OR REPLACE INTO balance (account_id, currency_type, amount) VALUES (8003, 2, 250)'
|
||||
).run()
|
||||
|
||||
// The read half: subscribers only, with the pending grant shown as a NULL balance.
|
||||
const members = await env.DB.prepare(PLUS_MEMBERS_SQL).all<{
|
||||
account_id: number
|
||||
username: string
|
||||
amount: number | null
|
||||
}>()
|
||||
expect(members.results.filter((r) => r.account_id >= 8001 && r.account_id <= 8004)).toEqual([
|
||||
{ account_id: 8001, username: 'Plus8001', amount: 250 },
|
||||
{ account_id: 8002, username: 'Plus8002', amount: null },
|
||||
])
|
||||
|
||||
const res = await env.DB.prepare(plusReloadSql(1000, 500)).all<{
|
||||
account_id: number
|
||||
amount: number
|
||||
}>()
|
||||
const credited = res.results.filter((r) => r.account_id >= 8001 && r.account_id <= 8004)
|
||||
expect(credited).toEqual(
|
||||
expect.arrayContaining([
|
||||
{ account_id: 8001, amount: 1250 },
|
||||
{ account_id: 8002, amount: 1500 },
|
||||
])
|
||||
)
|
||||
expect(credited).toHaveLength(2)
|
||||
|
||||
// The non-subscribers were left exactly as they were, and the fresh row is now a real
|
||||
// grant: `ensureStartingBalances` sees it and does not grant again.
|
||||
expect(await getBalance(env.DB, 8003, CurrencyType.RecCenterTokens, 500)).toBe(250)
|
||||
expect(await getBalance(env.DB, 8004, CurrencyType.RecCenterTokens, 500)).toBe(500)
|
||||
expect(await getBalance(env.DB, 8002, CurrencyType.RecCenterTokens, 500)).toBe(1500)
|
||||
})
|
||||
|
||||
test('rejects a non-positive amount or a negative grant before touching the database', () => {
|
||||
expect(() => plusReloadSql(0, 500)).toThrow('positive integer')
|
||||
expect(() => plusReloadSql(1.5, 500)).toThrow('positive integer')
|
||||
expect(() => plusReloadSql(100, -1)).toThrow('non-negative integer')
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user