mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 22:51:30 -07:00
add game config configuration
This commit is contained in:
+43
-17
@@ -68,16 +68,26 @@ const SPENDABLE: readonly number[] = [
|
||||
export const isSpendable = (currencyType: number): boolean => SPENDABLE.includes(currencyType)
|
||||
|
||||
/**
|
||||
* What a player starts with, granted lazily the first time their balances are read
|
||||
* 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.
|
||||
*
|
||||
* This is the whole signup grant — change the number here and it applies to every
|
||||
* player who hasn't been granted yet. It is NOT re-granted: a player who spends down
|
||||
* to 0 keeps a 0 row, and the grant is skipped because the row exists.
|
||||
* This is the whole signup grant. It is NOT re-granted: a player who spends down to 0
|
||||
* keeps a 0 row, and the grant is skipped because the row exists. That also means
|
||||
* raising `STARTING_TOKENS` later only affects players who haven't been granted yet —
|
||||
* existing players keep the amount they were granted under the old setting.
|
||||
*/
|
||||
export const STARTING_BALANCES: ReadonlyArray<{ currencyType: number; amount: number }> = [
|
||||
{ currencyType: CurrencyType.RecCenterTokens, amount: 10_000 },
|
||||
]
|
||||
export function startingBalances(
|
||||
startingTokens: number
|
||||
): ReadonlyArray<{ currencyType: number; amount: number }> {
|
||||
return [{ currencyType: CurrencyType.RecCenterTokens, amount: startingTokens }]
|
||||
}
|
||||
|
||||
/**
|
||||
* `Platform` in the client's balance DTO. -2 is "all platforms" — we don't track
|
||||
@@ -108,19 +118,32 @@ export interface Balance {
|
||||
*
|
||||
* Called on read rather than at account creation so accounts that predate this table
|
||||
* (every existing player) get their grant too.
|
||||
*
|
||||
* `startingTokens` is passed in rather than read from a module constant because it's
|
||||
* operator configuration (`STARTING_TOKENS`), and every path that can trigger the grant
|
||||
* has to agree on it — a caller that skipped it would quietly grant the built-in default
|
||||
* to whichever player happened to touch that path first.
|
||||
*/
|
||||
export async function ensureStartingBalances(db: D1Database, accountId: number): Promise<void> {
|
||||
export async function ensureStartingBalances(
|
||||
db: D1Database,
|
||||
accountId: number,
|
||||
startingTokens: number
|
||||
): Promise<void> {
|
||||
const stmt = db.prepare(
|
||||
'INSERT OR IGNORE INTO balance (account_id, currency_type, amount) VALUES (?1, ?2, ?3)'
|
||||
)
|
||||
await db.batch(
|
||||
STARTING_BALANCES.map((b) => stmt.bind(accountId, b.currencyType, b.amount))
|
||||
startingBalances(startingTokens).map((b) => stmt.bind(accountId, b.currencyType, b.amount))
|
||||
)
|
||||
}
|
||||
|
||||
/** Every balance an account holds (after its starting grant is applied). */
|
||||
export async function getBalances(db: D1Database, accountId: number): Promise<Balance[]> {
|
||||
await ensureStartingBalances(db, accountId)
|
||||
export async function getBalances(
|
||||
db: D1Database,
|
||||
accountId: number,
|
||||
startingTokens: number
|
||||
): Promise<Balance[]> {
|
||||
await ensureStartingBalances(db, accountId, startingTokens)
|
||||
const { results } = await db
|
||||
.prepare(
|
||||
'SELECT currency_type, amount FROM balance WHERE account_id = ?1 ORDER BY currency_type'
|
||||
@@ -134,9 +157,10 @@ export async function getBalances(db: D1Database, accountId: number): Promise<Ba
|
||||
export async function getBalance(
|
||||
db: D1Database,
|
||||
accountId: number,
|
||||
currencyType: number
|
||||
currencyType: number,
|
||||
startingTokens: number
|
||||
): Promise<number> {
|
||||
await ensureStartingBalances(db, accountId)
|
||||
await ensureStartingBalances(db, accountId, startingTokens)
|
||||
const row = await db
|
||||
.prepare('SELECT amount FROM balance WHERE account_id = ?1 AND currency_type = ?2')
|
||||
.bind(accountId, currencyType)
|
||||
@@ -155,7 +179,8 @@ export async function creditCurrency(
|
||||
db: D1Database,
|
||||
accountId: number,
|
||||
currencyType: number,
|
||||
amount: number
|
||||
amount: number,
|
||||
startingTokens: number
|
||||
): Promise<number> {
|
||||
if (!Number.isInteger(amount) || amount <= 0) {
|
||||
throw new Error(`creditCurrency: amount must be a positive integer, got ${amount}`)
|
||||
@@ -167,7 +192,7 @@ export async function creditCurrency(
|
||||
)
|
||||
.bind(accountId, currencyType, amount)
|
||||
.run()
|
||||
return getBalance(db, accountId, currencyType)
|
||||
return getBalance(db, accountId, currencyType, startingTokens)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -182,12 +207,13 @@ export async function spendCurrency(
|
||||
db: D1Database,
|
||||
accountId: number,
|
||||
currencyType: number,
|
||||
amount: number
|
||||
amount: number,
|
||||
startingTokens: number
|
||||
): Promise<boolean> {
|
||||
if (!Number.isInteger(amount) || amount <= 0) {
|
||||
throw new Error(`spendCurrency: amount must be a positive integer, got ${amount}`)
|
||||
}
|
||||
await ensureStartingBalances(db, accountId)
|
||||
await ensureStartingBalances(db, accountId, startingTokens)
|
||||
const { meta } = await db
|
||||
.prepare(
|
||||
`UPDATE balance SET amount = amount - ?3
|
||||
|
||||
@@ -10,6 +10,15 @@ export type Env = SharedHonoEnv & {
|
||||
DB: D1Database
|
||||
/** Static storefront catalogs (`static/storefronts/sf*.json`), fetched by path. */
|
||||
ASSETS: Fetcher
|
||||
/**
|
||||
* The RecCenterTokens a new player is granted (see balance-db.ts). Optional — unset
|
||||
* falls back to DEFAULT_STARTING_TOKENS, and 0 means players start broke.
|
||||
*
|
||||
* Typed `string | number` because a var declared in wrangler.jsonc `vars` arrives as a
|
||||
* number while the same var set from the dashboard or `--var` arrives as a string —
|
||||
* read it through `intVar`, never as a bare number.
|
||||
*/
|
||||
STARTING_TOKENS?: string | number
|
||||
}
|
||||
|
||||
/** Variables can be extended */
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Hono } from 'hono'
|
||||
import { useWorkersLogger } from 'workers-tagged-logger'
|
||||
|
||||
import { withNotFound, withOnError } from '@repo/hono-helpers'
|
||||
import { intVar, withNotFound, withOnError } from '@repo/hono-helpers'
|
||||
import { validateAndGetAccountId } from '@repo/jwt'
|
||||
|
||||
import defaultAvatarItems from '../static/default-avatar-items.json'
|
||||
@@ -9,7 +9,7 @@ import defaultAvatar from '../static/default-avatar.json'
|
||||
import myProgress from '../static/my-progress.json'
|
||||
import weeklyChallenge from '../static/weekly-challenge.json'
|
||||
import { getAvatar, setAvatar } from './avatar-db'
|
||||
import { ALL_PLATFORMS, getBalance, isSpendable } from './balance-db'
|
||||
import { ALL_PLATFORMS, DEFAULT_STARTING_TOKENS, getBalance, isSpendable } from './balance-db'
|
||||
import { getOutfits, setOutfit } from './outfit-db'
|
||||
|
||||
import type { Context } from 'hono'
|
||||
@@ -220,7 +220,14 @@ const app = new Hono<App>()
|
||||
if (id === null) return unauthorized(c)
|
||||
const currencyType = Number.parseInt(c.req.param('currencyType'), 10)
|
||||
if (Number.isNaN(currencyType)) return c.body(null, 400)
|
||||
const amount = isSpendable(currencyType) ? await getBalance(c.env.DB, id, currencyType) : 0
|
||||
const amount = isSpendable(currencyType)
|
||||
? await getBalance(
|
||||
c.env.DB,
|
||||
id,
|
||||
currencyType,
|
||||
intVar(c.env.STARTING_TOKENS, DEFAULT_STARTING_TOKENS)
|
||||
)
|
||||
: 0
|
||||
return c.json([{ CurrencyType: currencyType, Platform: ALL_PLATFORMS, Balance: amount }])
|
||||
})
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import { SCHEMA_DDL } from '../../avatar-db'
|
||||
import {
|
||||
BALANCE_SCHEMA_DDL,
|
||||
CurrencyType,
|
||||
DEFAULT_STARTING_TOKENS,
|
||||
getBalance,
|
||||
spendCurrency,
|
||||
} from '../../balance-db'
|
||||
@@ -423,7 +424,9 @@ describe('econ endpoints', () => {
|
||||
|
||||
test('GET /api/storefronts/v4/balance/2 reflects what the player has spent', async () => {
|
||||
// Spend from account 7 (a fresh account: the read below grants it first).
|
||||
expect(await spendCurrency(env.DB, 7, CurrencyType.RecCenterTokens, 2500)).toBe(true)
|
||||
expect(
|
||||
await spendCurrency(env.DB, 7, CurrencyType.RecCenterTokens, 2500, DEFAULT_STARTING_TOKENS)
|
||||
).toBe(true)
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/storefronts/v4/balance/2`, {
|
||||
headers: await bearer('7'),
|
||||
})
|
||||
@@ -431,15 +434,47 @@ describe('econ endpoints', () => {
|
||||
})
|
||||
|
||||
test('a spend the player cannot afford changes nothing', async () => {
|
||||
const before = await getBalance(env.DB, 8, CurrencyType.RecCenterTokens)
|
||||
expect(await spendCurrency(env.DB, 8, CurrencyType.RecCenterTokens, before + 1)).toBe(false)
|
||||
expect(await getBalance(env.DB, 8, CurrencyType.RecCenterTokens)).toBe(before)
|
||||
const before = await getBalance(
|
||||
env.DB,
|
||||
8,
|
||||
CurrencyType.RecCenterTokens,
|
||||
DEFAULT_STARTING_TOKENS
|
||||
)
|
||||
expect(
|
||||
await spendCurrency(
|
||||
env.DB,
|
||||
8,
|
||||
CurrencyType.RecCenterTokens,
|
||||
before + 1,
|
||||
DEFAULT_STARTING_TOKENS
|
||||
)
|
||||
).toBe(false)
|
||||
expect(await getBalance(env.DB, 8, CurrencyType.RecCenterTokens, DEFAULT_STARTING_TOKENS)).toBe(
|
||||
before
|
||||
)
|
||||
})
|
||||
|
||||
test('the starting grant comes from the STARTING_TOKENS var', async () => {
|
||||
// The grant an operator actually runs is the var; DEFAULT_STARTING_TOKENS is only the
|
||||
// fallback. `env` is shared by every test in this file, so restore it in `finally`.
|
||||
const original = env.STARTING_TOKENS
|
||||
try {
|
||||
env.STARTING_TOKENS = 250
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/storefronts/v4/balance/2`, {
|
||||
headers: await bearer('11'),
|
||||
})
|
||||
expect(await res.json()).toEqual([{ CurrencyType: 2, Platform: -2, Balance: 250 }])
|
||||
} finally {
|
||||
env.STARTING_TOKENS = original
|
||||
}
|
||||
})
|
||||
|
||||
test('the starting grant is not re-granted after spending down to zero', async () => {
|
||||
// The grant is INSERT OR IGNORE against the row, not a top-up: a player who spends
|
||||
// everything stays at 0 rather than being refilled by their next balance read.
|
||||
expect(await spendCurrency(env.DB, 9, CurrencyType.RecCenterTokens, 10_000)).toBe(true)
|
||||
expect(
|
||||
await spendCurrency(env.DB, 9, CurrencyType.RecCenterTokens, 10_000, DEFAULT_STARTING_TOKENS)
|
||||
).toBe(true)
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/storefronts/v4/balance/2`, {
|
||||
headers: await bearer('9'),
|
||||
})
|
||||
|
||||
@@ -44,6 +44,10 @@
|
||||
"head_sampling_rate": 1 // 100%
|
||||
}
|
||||
},
|
||||
// The signup token grant (STARTING_TOKENS) is deliberately NOT set here. It's injected
|
||||
// at deploy time from the gitignored .env (RECFLARE_STARTING_TOKENS, see .env.example),
|
||||
// so tuning it never means editing a versioned file. Unset — the default — falls back
|
||||
// to DEFAULT_STARTING_TOKENS in src/balance-db.ts.
|
||||
"vars": {
|
||||
"ENVIRONMENT": "development", // overridden during deployment
|
||||
"SENTRY_RELEASE": "unknown" // overridden during deployment
|
||||
|
||||
Reference in New Issue
Block a user