mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-27 16:08:58 -07:00
[www] I award you no points
This commit is contained in:
@@ -1194,7 +1194,13 @@ function StaffPlayerActions({ account, navigate }: { account: PublicAccount; nav
|
|||||||
json: { amount },
|
json: { amount },
|
||||||
})
|
})
|
||||||
setTokens('')
|
setTokens('')
|
||||||
return `Sent ${amount.toLocaleString()} tokens. @${account.username} now has ${res.balance.toLocaleString()}.`
|
const what =
|
||||||
|
amount < 0
|
||||||
|
? `Took ${Math.abs(amount).toLocaleString()} tokens back`
|
||||||
|
: amount === 0
|
||||||
|
? 'Sent an empty box'
|
||||||
|
: `Sent ${amount.toLocaleString()} tokens`
|
||||||
|
return `${what}. @${account.username} now has ${res.balance.toLocaleString()}.`
|
||||||
})
|
})
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -1203,9 +1209,7 @@ function StaffPlayerActions({ account, navigate }: { account: PublicAccount; nav
|
|||||||
<span className="staff-gift">
|
<span className="staff-gift">
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
min={1}
|
|
||||||
step={1}
|
step={1}
|
||||||
inputMode="numeric"
|
|
||||||
value={tokens}
|
value={tokens}
|
||||||
required
|
required
|
||||||
onChange={(e) => setTokens(e.target.value)}
|
onChange={(e) => setTokens(e.target.value)}
|
||||||
@@ -1215,7 +1219,9 @@ function StaffPlayerActions({ account, navigate }: { account: PublicAccount; nav
|
|||||||
</button>
|
</button>
|
||||||
</span>
|
</span>
|
||||||
<span className="hint">
|
<span className="hint">
|
||||||
Arrives as a gift box, and is added to their balance right away.
|
Arrives as a gift box, and is added to their balance right away. A negative amount takes
|
||||||
|
tokens back instead — it still sends a box, and it can't take more than they have.
|
||||||
|
Zero sends an empty box and moves nothing.
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
{gift.error && <p className="error">{gift.error}</p>}
|
{gift.error && <p className="error">{gift.error}</p>}
|
||||||
|
|||||||
+49
-12
@@ -36,6 +36,8 @@ import {
|
|||||||
CurrencyType,
|
CurrencyType,
|
||||||
DEFAULT_STARTING_TOKENS,
|
DEFAULT_STARTING_TOKENS,
|
||||||
ensureStartingBalances,
|
ensureStartingBalances,
|
||||||
|
getBalance,
|
||||||
|
spendCurrency,
|
||||||
} from '../../econ/src/balance-db'
|
} from '../../econ/src/balance-db'
|
||||||
import { grantCustomAvatarItem, ownedCustomAvatarItemIds } from '../../econ/src/inventory-custom-db'
|
import { grantCustomAvatarItem, ownedCustomAvatarItemIds } from '../../econ/src/inventory-custom-db'
|
||||||
// The notification ids and the kick frame's recovered shape, owned by `notify`. Both are
|
// The notification ids and the kick frame's recovered shape, owned by `notify`. Both are
|
||||||
@@ -647,7 +649,8 @@ async function announceGift(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Send a player RecCenterTokens in a gift box.
|
* Send a player RecCenterTokens in a gift box — or, with a NEGATIVE amount, take some back.
|
||||||
|
*
|
||||||
*
|
*
|
||||||
* The same shape as econ's own server-handed currency (a game reward's Laser Tag tickets):
|
* The same shape as econ's own server-handed currency (a game reward's Laser Tag tickets):
|
||||||
* the balance is CREDITED here — opening a box only deletes it, it grants nothing — then a
|
* the balance is CREDITED here — opening a box only deletes it, it grants nothing — then a
|
||||||
@@ -658,6 +661,11 @@ async function announceGift(
|
|||||||
*
|
*
|
||||||
* Both frames are best-effort: the tokens are banked by the time they go out, and an offline
|
* Both frames are best-effort: the tokens are banked by the time they go out, and an offline
|
||||||
* player meets the box in `GET /api/avatar/v2/gifts` and the balance on their next read.
|
* player meets the box in `GET /api/avatar/v2/gifts` and the balance on their next read.
|
||||||
|
*
|
||||||
|
* A negative amount debits through the same guarded spend a purchase uses, so it cannot
|
||||||
|
* overdraw: a player who can't afford it is refused and keeps what they have. Zero moves
|
||||||
|
* nothing at all. Both still mint a box, carrying that `Currency` — nobody has seen what the
|
||||||
|
* client makes of an empty or a negative one, and finding out is the point.
|
||||||
*/
|
*/
|
||||||
export async function giftTokensHandler(c: Context<App>) {
|
export async function giftTokensHandler(c: Context<App>) {
|
||||||
const playerId = playerIdParam(c)
|
const playerId = playerIdParam(c)
|
||||||
@@ -665,11 +673,11 @@ export async function giftTokensHandler(c: Context<App>) {
|
|||||||
|
|
||||||
const body = (await c.req.json().catch(() => ({}))) as { amount?: unknown }
|
const body = (await c.req.json().catch(() => ({}))) as { amount?: unknown }
|
||||||
const amount = Number(body.amount)
|
const amount = Number(body.amount)
|
||||||
if (!Number.isInteger(amount) || amount <= 0) {
|
if (!Number.isInteger(amount)) {
|
||||||
return c.json({ error: 'Enter a whole number of tokens greater than 0' }, 400)
|
return c.json({ error: 'Enter a whole number of tokens, positive or negative' }, 400)
|
||||||
}
|
}
|
||||||
const maxGift = intVar(c.env.MAX_TOKEN_GIFT, DEFAULT_MAX_TOKEN_GIFT)
|
const maxGift = intVar(c.env.MAX_TOKEN_GIFT, DEFAULT_MAX_TOKEN_GIFT)
|
||||||
if (amount > maxGift) {
|
if (Math.abs(amount) > maxGift) {
|
||||||
return c.json({ error: `A gift can carry at most ${maxGift.toLocaleString()} tokens` }, 400)
|
return c.json({ error: `A gift can carry at most ${maxGift.toLocaleString()} tokens` }, 400)
|
||||||
}
|
}
|
||||||
if ((await getAccount(c.env.DB, playerId)) === null) {
|
if ((await getAccount(c.env.DB, playerId)) === null) {
|
||||||
@@ -680,13 +688,38 @@ export async function giftTokensHandler(c: Context<App>) {
|
|||||||
// and a never-touched balance would otherwise start from this gift instead of the grant.
|
// and a never-touched balance would otherwise start from this gift instead of the grant.
|
||||||
const startingTokens = intVar(c.env.STARTING_TOKENS, DEFAULT_STARTING_TOKENS)
|
const startingTokens = intVar(c.env.STARTING_TOKENS, DEFAULT_STARTING_TOKENS)
|
||||||
await ensureStartingBalances(c.env.DB, playerId, startingTokens)
|
await ensureStartingBalances(c.env.DB, playerId, startingTokens)
|
||||||
const balance = await creditCurrency(
|
// A NEGATIVE amount takes tokens back, through the same guarded debit a purchase spends
|
||||||
c.env.DB,
|
// with — so it can't overdraw, and a player who can't afford it keeps what they have.
|
||||||
playerId,
|
if (amount < 0) {
|
||||||
CurrencyType.RecCenterTokens,
|
const spent = await spendCurrency(
|
||||||
amount,
|
c.env.DB,
|
||||||
startingTokens
|
playerId,
|
||||||
)
|
CurrencyType.RecCenterTokens,
|
||||||
|
-amount,
|
||||||
|
startingTokens
|
||||||
|
)
|
||||||
|
if (!spent) {
|
||||||
|
const held = await getBalance(
|
||||||
|
c.env.DB,
|
||||||
|
playerId,
|
||||||
|
CurrencyType.RecCenterTokens,
|
||||||
|
startingTokens
|
||||||
|
)
|
||||||
|
return c.json({ error: `They only have ${held.toLocaleString()} tokens to take` }, 400)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Zero moves nothing and still sends a box — an empty one, for the same reason a negative
|
||||||
|
// one is allowed: nobody has seen what the client draws for it.
|
||||||
|
const balance =
|
||||||
|
amount > 0
|
||||||
|
? await creditCurrency(
|
||||||
|
c.env.DB,
|
||||||
|
playerId,
|
||||||
|
CurrencyType.RecCenterTokens,
|
||||||
|
amount,
|
||||||
|
startingTokens
|
||||||
|
)
|
||||||
|
: await getBalance(c.env.DB, playerId, CurrencyType.RecCenterTokens, startingTokens)
|
||||||
|
|
||||||
const content = staffGiftContent({
|
const content = staffGiftContent({
|
||||||
CurrencyType: CurrencyType.RecCenterTokens,
|
CurrencyType: CurrencyType.RecCenterTokens,
|
||||||
@@ -695,7 +728,11 @@ export async function giftTokensHandler(c: Context<App>) {
|
|||||||
const gift = await createGift(c.env.DB, playerId, content)
|
const gift = await createGift(c.env.DB, playerId, content)
|
||||||
|
|
||||||
await recordPlayerAudit(c, 'gift_tokens', { playerId, amount, balance, giftId: gift.id })
|
await recordPlayerAudit(c, 'gift_tokens', { playerId, amount, balance, giftId: gift.id })
|
||||||
logger.info('staff gifted tokens', { moderatorId: staffId(c), playerId, amount })
|
logger.info(amount < 0 ? 'staff took tokens back' : 'staff gifted tokens', {
|
||||||
|
moderatorId: staffId(c),
|
||||||
|
playerId,
|
||||||
|
amount,
|
||||||
|
})
|
||||||
|
|
||||||
// The balance first, so the box's announcement lands on a total that already includes it.
|
// The balance first, so the box's announcement lands on a total that already includes it.
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1339,9 +1339,11 @@ it('gifts a player tokens in a gift box', async () => {
|
|||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('refuses a token gift that is not a positive whole number, too large, or to nobody', async () => {
|
it('refuses a token gift that is not a whole number, too large, or to nobody', async () => {
|
||||||
await updateAccount(env.DB, 8301, { username: 'NotGifted' })
|
await updateAccount(env.DB, 8301, { username: 'NotGifted' })
|
||||||
for (const amount of [0, -5, 1.5, 'lots', 10_001]) {
|
// A fraction and a word are not amounts, and the cap binds in both directions, since a
|
||||||
|
// negative gift takes tokens away. Zero is allowed — see the empty box below.
|
||||||
|
for (const amount of [1.5, 'lots', 10_001, -10_001]) {
|
||||||
const res = await staffPost('/api/staff/players/8301/gift-tokens', 8110, { amount })
|
const res = await staffPost('/api/staff/players/8301/gift-tokens', 8110, { amount })
|
||||||
expect(res.status).toBe(400)
|
expect(res.status).toBe(400)
|
||||||
}
|
}
|
||||||
@@ -1352,6 +1354,73 @@ it('refuses a token gift that is not a positive whole number, too large, or to n
|
|||||||
expect(await auditRows('gift_tokens', 8301)).toEqual([])
|
expect(await auditRows('gift_tokens', 8301)).toEqual([])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// A negative gift takes tokens back — through the same guarded debit a purchase spends with,
|
||||||
|
// so it cannot overdraw. It still mints a box, carrying the negative amount: what the client
|
||||||
|
// makes of one is the thing being found out.
|
||||||
|
it('takes tokens back on a negative gift, and cannot overdraw', async () => {
|
||||||
|
const hub = () => env.RECFLARE_NOTIFICATIONS_HUB.getByName('global')
|
||||||
|
await hub().fetch('http://do/all', { method: 'DELETE' })
|
||||||
|
await updateAccount(env.DB, 8360, { username: 'Indebted' })
|
||||||
|
|
||||||
|
const res = await staffPost('/api/staff/players/8360/gift-tokens', 8110, { amount: -400 })
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
const body = (await res.json()) as { balance: number; giftId: number }
|
||||||
|
expect(body.balance).toBe(DEFAULT_STARTING_TOKENS - 400)
|
||||||
|
await expect(
|
||||||
|
getBalance(env.DB, 8360, CurrencyType.RecCenterTokens, DEFAULT_STARTING_TOKENS)
|
||||||
|
).resolves.toBe(DEFAULT_STARTING_TOKENS - 400)
|
||||||
|
|
||||||
|
const gifts = await getPendingGifts(env.DB, 8360)
|
||||||
|
expect(gifts).toHaveLength(1)
|
||||||
|
expect(gifts[0]).toMatchObject({ Id: body.giftId, Currency: -400 })
|
||||||
|
|
||||||
|
// The balance frame carries the resulting total, as it does for a credit.
|
||||||
|
const frames = (await (await hub().fetch('http://do/all')).json()) as Array<{
|
||||||
|
notificationType: number
|
||||||
|
data: Record<string, unknown>
|
||||||
|
}>
|
||||||
|
expect(frames[0].data).toMatchObject({ Balance: DEFAULT_STARTING_TOKENS - 400 })
|
||||||
|
|
||||||
|
expect(await auditRows('gift_tokens', 8360)).toEqual([
|
||||||
|
{
|
||||||
|
actor: 8110,
|
||||||
|
data: {
|
||||||
|
playerId: 8360,
|
||||||
|
amount: -400,
|
||||||
|
balance: DEFAULT_STARTING_TOKENS - 400,
|
||||||
|
giftId: body.giftId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
// More than they hold changes nothing: no debit, no box, no audit row beyond the first.
|
||||||
|
// Under the cap, so this is the BALANCE refusing it and not the typo guard (they hold
|
||||||
|
// DEFAULT_STARTING_TOKENS - 400 by now).
|
||||||
|
const tooMuch = await staffPost('/api/staff/players/8360/gift-tokens', 8110, {
|
||||||
|
amount: -(DEFAULT_STARTING_TOKENS - 100),
|
||||||
|
})
|
||||||
|
expect(tooMuch.status).toBe(400)
|
||||||
|
await expect(
|
||||||
|
getBalance(env.DB, 8360, CurrencyType.RecCenterTokens, DEFAULT_STARTING_TOKENS)
|
||||||
|
).resolves.toBe(DEFAULT_STARTING_TOKENS - 400)
|
||||||
|
expect(await getPendingGifts(env.DB, 8360)).toHaveLength(1)
|
||||||
|
expect(await auditRows('gift_tokens', 8360)).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Zero is a real gift here: nothing moves, and the player gets an empty box.
|
||||||
|
it('sends an empty box on a zero token gift', async () => {
|
||||||
|
await updateAccount(env.DB, 8361, { username: 'Emptyhanded' })
|
||||||
|
const res = await staffPost('/api/staff/players/8361/gift-tokens', 8110, { amount: 0 })
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
const body = (await res.json()) as { balance: number; giftId: number }
|
||||||
|
expect(body.balance).toBe(DEFAULT_STARTING_TOKENS)
|
||||||
|
|
||||||
|
const gifts = await getPendingGifts(env.DB, 8361)
|
||||||
|
expect(gifts).toHaveLength(1)
|
||||||
|
expect(gifts[0]).toMatchObject({ Id: body.giftId, Currency: 0 })
|
||||||
|
expect(await auditRows('gift_tokens', 8361)).toMatchObject([{ data: { amount: 0 } }])
|
||||||
|
})
|
||||||
|
|
||||||
// An account that has never renamed itself stores no count and is read as the default 3, so
|
// An account that has never renamed itself stores no count and is read as the default 3, so
|
||||||
// the first grant makes it 4 rather than 1.
|
// the first grant makes it 4 rather than 1.
|
||||||
it('adds a username change on top of the default', async () => {
|
it('adds a username change on top of the default', async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user