mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-25 15:09:18 -07:00
[www] staff gifting
This commit is contained in:
@@ -159,6 +159,10 @@ RECFLARE_DOMAIN=rec.example.com
|
||||
# staff can give in total.
|
||||
# RECFLARE_MAX_TOKEN_GIFT=10000
|
||||
|
||||
# The same guard for one XP gift from that staff card (`www`). Every level to the top costs
|
||||
# 15,090 XP in all (level 1 to 50).
|
||||
# RECFLARE_MAX_XP_GIFT=100
|
||||
|
||||
# Signup on the website is configured OUTSIDE this file: it's guarded by a Cloudflare
|
||||
# Turnstile widget, and both of that widget's keys live in the shared Secrets Store
|
||||
# (RECFLARE_SECRETS_STORE above), alongside JWT_SECRET — not as vars, not as worker secrets.
|
||||
|
||||
@@ -1172,7 +1172,11 @@ function PlayerPage({
|
||||
function StaffPlayerActions({ account, navigate }: { account: PublicAccount; navigate: Navigate }) {
|
||||
const [tokens, setTokens] = useState('')
|
||||
const [confirmingClear, setConfirmingClear] = useState(false)
|
||||
const [customItemId, setCustomItemId] = useState('')
|
||||
const [xp, setXp] = useState('')
|
||||
const gift = useAction()
|
||||
const xpGift = useAction()
|
||||
const customGift = useAction()
|
||||
const usernameChange = useAction()
|
||||
const clearPassword = useAction()
|
||||
const base = `/api/staff/players/${account.accountId}`
|
||||
@@ -1218,6 +1222,86 @@ function StaffPlayerActions({ account, navigate }: { account: PublicAccount; nav
|
||||
{gift.done && <p className="ok">{gift.done}</p>}
|
||||
</form>
|
||||
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
void xpGift.run(async () => {
|
||||
const amount = Number(xp)
|
||||
const res = await call<{ level: number; levelsGained: number }>(`${base}/gift-xp`, {
|
||||
authed: true,
|
||||
json: { amount },
|
||||
})
|
||||
setXp('')
|
||||
const levels =
|
||||
res.levelsGained > 0
|
||||
? ` They went up ${res.levelsGained} level${res.levelsGained === 1 ? '' : 's'}, to ${res.level}.`
|
||||
: ` They're level ${res.level}.`
|
||||
return `Sent ${amount.toLocaleString()} XP.${levels}`
|
||||
})
|
||||
}}
|
||||
>
|
||||
<label>
|
||||
Gift XP
|
||||
<span className="staff-gift">
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
step={1}
|
||||
inputMode="numeric"
|
||||
value={xp}
|
||||
required
|
||||
onChange={(e) => setXp(e.target.value)}
|
||||
/>
|
||||
<button type="submit" disabled={xpGift.pending}>
|
||||
{xpGift.pending ? 'Sending…' : 'Send'}
|
||||
</button>
|
||||
</span>
|
||||
<span className="hint">
|
||||
Arrives as a gift box. Levels it crosses count, but don't pay their level-up
|
||||
rewards.
|
||||
</span>
|
||||
</label>
|
||||
{xpGift.error && <p className="error">{xpGift.error}</p>}
|
||||
{xpGift.done && <p className="ok">{xpGift.done}</p>}
|
||||
</form>
|
||||
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
void customGift.run(async () => {
|
||||
const res = await call<{ name: string }>(`${base}/gift-custom-item`, {
|
||||
authed: true,
|
||||
json: { customAvatarItemId: customItemId.trim() },
|
||||
})
|
||||
setCustomItemId('')
|
||||
return `Sent “${res.name}” to @${account.username}.`
|
||||
})
|
||||
}}
|
||||
>
|
||||
<label>
|
||||
Gift custom item
|
||||
<span className="staff-gift">
|
||||
<input
|
||||
type="text"
|
||||
value={customItemId}
|
||||
placeholder="Custom avatar item id"
|
||||
spellCheck={false}
|
||||
required
|
||||
onChange={(e) => setCustomItemId(e.target.value)}
|
||||
/>
|
||||
<button type="submit" disabled={customGift.pending}>
|
||||
{customGift.pending ? 'Sending…' : 'Send'}
|
||||
</button>
|
||||
</span>
|
||||
<span className="hint">
|
||||
Arrives as a gift box, as if bought from the store. Free to them, and the creator
|
||||
isn't paid.
|
||||
</span>
|
||||
</label>
|
||||
{customGift.error && <p className="error">{customGift.error}</p>}
|
||||
{customGift.done && <p className="ok">{customGift.done}</p>}
|
||||
</form>
|
||||
|
||||
<div className="mod-filter-actions staff-actions">
|
||||
<button
|
||||
type="submit"
|
||||
|
||||
@@ -1568,6 +1568,11 @@ button[type='submit']:disabled {
|
||||
max-width: 180px;
|
||||
}
|
||||
|
||||
/* A custom item id is a GUID, far wider than a token amount. */
|
||||
.staff-gift input[type='text'] {
|
||||
max-width: 360px;
|
||||
}
|
||||
|
||||
.staff-actions {
|
||||
flex-wrap: wrap;
|
||||
margin-top: 8px;
|
||||
|
||||
@@ -51,6 +51,11 @@ export type Env = SharedHonoEnv & {
|
||||
* DEFAULT_MAX_TOKEN_GIFT (10,000).
|
||||
*/
|
||||
MAX_TOKEN_GIFT?: string | number
|
||||
/**
|
||||
* The most XP one staff XP gift can carry (see src/staff.ts), a typo guard like
|
||||
* MAX_TOKEN_GIFT. Unset means DEFAULT_MAX_XP_GIFT (100).
|
||||
*/
|
||||
MAX_XP_GIFT?: string | number
|
||||
/**
|
||||
* Service binding to the `auth` worker — how the BFF reaches it, so the browser's real
|
||||
* IP survives the hop (see wrangler.jsonc and src/upstream.ts `postAuthForm`).
|
||||
|
||||
+251
-45
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
addUsernameChange,
|
||||
addXp,
|
||||
clearPasswordHash,
|
||||
createGift,
|
||||
getAccount,
|
||||
@@ -15,6 +16,7 @@ import { validateAndGetAccountId, validateAndGetRoles } from '@repo/jwt'
|
||||
// is the same write `api` would do. www owns the ENDPOINTS, not the storage — see the
|
||||
// module comment below.
|
||||
import { banEvasionMatch, linkedAccounts } from '../../api/src/bans-db'
|
||||
import { getCustomAvatarItem } from '../../api/src/custom-avatar-items-db'
|
||||
import {
|
||||
banBlockDetails,
|
||||
banFromReport,
|
||||
@@ -35,6 +37,7 @@ import {
|
||||
DEFAULT_STARTING_TOKENS,
|
||||
ensureStartingBalances,
|
||||
} from '../../econ/src/balance-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
|
||||
// imported as values/types with no runtime dependencies.
|
||||
import { NotificationType } from '../../notify/src/notification-types'
|
||||
@@ -46,6 +49,7 @@ import type {
|
||||
BalanceResponsePayload,
|
||||
GiftPackagePayload,
|
||||
ModerationKickPayload,
|
||||
PlayerProgressionLevelPayload,
|
||||
} from '../../notify/src/notification-payloads'
|
||||
import type { App, Env } from './context'
|
||||
|
||||
@@ -518,8 +522,26 @@ const COACH_ACCOUNT_ID = 1
|
||||
*/
|
||||
export const DEFAULT_MAX_TOKEN_GIFT = 10_000
|
||||
|
||||
/** The message on a staff token gift's box. */
|
||||
const TOKEN_GIFT_MESSAGE = 'A gift from the staff!'
|
||||
/**
|
||||
* The most XP one gift can carry when the operator's `MAX_XP_GIFT` is unset — a typo guard,
|
||||
* as {@link DEFAULT_MAX_TOKEN_GIFT} is. Kept small for now: enough to cross the first few
|
||||
* levels, where every level to the top costs 15,090 in all (level 1 to 50).
|
||||
*/
|
||||
export const DEFAULT_MAX_XP_GIFT = 100
|
||||
|
||||
/**
|
||||
* `GiftContext.Purchased_Gift_A` — what a staff box says it came from.
|
||||
*
|
||||
* The client classifies a box by its context and only tests a small set (500–503
|
||||
* `Purchased_Gift_A`–`D`, 1300 `Friendotron_Gift`); `Default` (0) falls outside it, and a box
|
||||
* whose contents it won't render shows "cannot display". The TOKEN box is left on 0 because
|
||||
* it renders as it is — this is the thing being changed, so it is not changed everywhere at
|
||||
* once.
|
||||
*/
|
||||
const GIFT_CONTEXT_PURCHASED_GIFT_A = 500
|
||||
|
||||
/** The message on the box a staff gift comes in. */
|
||||
const STAFF_GIFT_MESSAGE = 'A gift from the staff!'
|
||||
|
||||
/** A path's `:id` as a player id, or null when it isn't a positive integer. */
|
||||
function playerIdParam(c: Context<App>): number | null {
|
||||
@@ -547,6 +569,83 @@ async function recordPlayerAudit(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The stored content of a box staff hand over: from the Coach, as every box the server hands
|
||||
* over on nobody's behalf is (who really sent it is on the audit row), carrying nothing but
|
||||
* what `fields` puts in it.
|
||||
*/
|
||||
function staffGiftContent(fields: Partial<GiftContent>): GiftContent {
|
||||
return {
|
||||
FromPlayerId: COACH_ACCOUNT_ID,
|
||||
GiftContext: 0,
|
||||
ConsumableItemDesc: '',
|
||||
ConsumableCount: 0,
|
||||
AvatarItemDesc: '',
|
||||
AvatarItemType: 0,
|
||||
CurrencyType: 0,
|
||||
Currency: 0,
|
||||
Xp: 0,
|
||||
PackageType: 0,
|
||||
Message: STAFF_GIFT_MESSAGE,
|
||||
EquipmentPrefabName: '',
|
||||
EquipmentModificationGuid: '',
|
||||
GiftRarity: 0,
|
||||
Platform: -1,
|
||||
PlatformsToSpawnOn: -1,
|
||||
BalanceType: null,
|
||||
...fields,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a player the box they were just handed: `GiftPackageReceivedImmediate`, the frame
|
||||
* econ sends for a box the player never clicked for, read off the stored content so the
|
||||
* frame and `GET /api/avatar/v2/gifts` describe one box. A custom item's box also carries
|
||||
* `CustomAvatarItemId`, as econ's does — the recovered decoder names no such member and
|
||||
* drops unknown ones, so it costs nothing if unread. Best-effort: the box is already stored,
|
||||
* and an offline player meets it on their next read of their gifts.
|
||||
*/
|
||||
async function announceGift(
|
||||
c: Context<App>,
|
||||
playerId: number,
|
||||
giftId: number,
|
||||
content: GiftContent
|
||||
): Promise<void> {
|
||||
const frame: GiftPackagePayload = {
|
||||
Id: giftId,
|
||||
FromPlayerId: content.FromPlayerId ?? COACH_ACCOUNT_ID,
|
||||
ConsumableItemDesc: content.ConsumableItemDesc,
|
||||
AvatarItemType: content.AvatarItemType,
|
||||
AvatarItemDesc: content.AvatarItemDesc,
|
||||
EquipmentPrefabName: content.EquipmentPrefabName,
|
||||
EquipmentModificationGuid: content.EquipmentModificationGuid,
|
||||
CurrencyType: content.CurrencyType,
|
||||
Currency: content.Currency,
|
||||
Xp: content.Xp,
|
||||
GiftContext: content.GiftContext ?? 0,
|
||||
GiftRarity: content.GiftRarity,
|
||||
Message: content.Message,
|
||||
Platform: -1,
|
||||
PlatformsToSpawnOn: -1,
|
||||
BalanceType: ALL_PLATFORMS,
|
||||
}
|
||||
try {
|
||||
await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer(
|
||||
playerId,
|
||||
NotificationType.GiftPackageReceivedImmediate,
|
||||
content.CustomAvatarItemId
|
||||
? { ...frame, CustomAvatarItemId: content.CustomAvatarItemId }
|
||||
: { ...frame }
|
||||
)
|
||||
} catch (err) {
|
||||
logger.error('failed to announce a staff gift', {
|
||||
playerId,
|
||||
giftId,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a player RecCenterTokens in a gift box.
|
||||
*
|
||||
@@ -589,66 +688,173 @@ export async function giftTokensHandler(c: Context<App>) {
|
||||
startingTokens
|
||||
)
|
||||
|
||||
const content: GiftContent = {
|
||||
FromPlayerId: COACH_ACCOUNT_ID,
|
||||
GiftContext: 0,
|
||||
ConsumableItemDesc: '',
|
||||
ConsumableCount: 0,
|
||||
AvatarItemDesc: '',
|
||||
AvatarItemType: 0,
|
||||
const content = staffGiftContent({
|
||||
CurrencyType: CurrencyType.RecCenterTokens,
|
||||
Currency: amount,
|
||||
Xp: 0,
|
||||
PackageType: 0,
|
||||
Message: TOKEN_GIFT_MESSAGE,
|
||||
EquipmentPrefabName: '',
|
||||
EquipmentModificationGuid: '',
|
||||
GiftRarity: 0,
|
||||
Platform: -1,
|
||||
PlatformsToSpawnOn: -1,
|
||||
BalanceType: null,
|
||||
}
|
||||
})
|
||||
const gift = await createGift(c.env.DB, playerId, content)
|
||||
|
||||
await recordPlayerAudit(c, 'gift_tokens', { playerId, amount, balance, giftId: gift.id })
|
||||
logger.info('staff gifted tokens', { moderatorId: staffId(c), playerId, amount })
|
||||
|
||||
const hub = c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE)
|
||||
// The balance first, so the box's announcement lands on a total that already includes it.
|
||||
try {
|
||||
await hub.notifyPlayer(playerId, NotificationType.StorefrontBalanceUpdate, {
|
||||
Balance: balance,
|
||||
CurrencyType: CurrencyType.RecCenterTokens,
|
||||
Platform: ALL_PLATFORMS,
|
||||
} satisfies BalanceResponsePayload)
|
||||
await hub.notifyPlayer(playerId, NotificationType.GiftPackageReceivedImmediate, {
|
||||
Id: gift.id,
|
||||
FromPlayerId: content.FromPlayerId ?? COACH_ACCOUNT_ID,
|
||||
ConsumableItemDesc: content.ConsumableItemDesc,
|
||||
AvatarItemType: content.AvatarItemType,
|
||||
AvatarItemDesc: content.AvatarItemDesc,
|
||||
EquipmentPrefabName: content.EquipmentPrefabName,
|
||||
EquipmentModificationGuid: content.EquipmentModificationGuid,
|
||||
CurrencyType: content.CurrencyType,
|
||||
Currency: content.Currency,
|
||||
Xp: content.Xp,
|
||||
GiftContext: content.GiftContext ?? 0,
|
||||
GiftRarity: content.GiftRarity,
|
||||
Message: content.Message,
|
||||
Platform: -1,
|
||||
PlatformsToSpawnOn: -1,
|
||||
BalanceType: ALL_PLATFORMS,
|
||||
} satisfies GiftPackagePayload)
|
||||
await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer(
|
||||
playerId,
|
||||
NotificationType.StorefrontBalanceUpdate,
|
||||
{
|
||||
Balance: balance,
|
||||
CurrencyType: CurrencyType.RecCenterTokens,
|
||||
Platform: ALL_PLATFORMS,
|
||||
} satisfies BalanceResponsePayload
|
||||
)
|
||||
} catch (err) {
|
||||
logger.error('failed to announce a staff token gift', {
|
||||
logger.error('failed to push a staff token gift balance', {
|
||||
playerId,
|
||||
giftId: gift.id,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
}
|
||||
await announceGift(c, playerId, gift.id, content)
|
||||
|
||||
return c.json({ playerId, amount, balance, giftId: gift.id })
|
||||
}
|
||||
|
||||
/**
|
||||
* Give a player XP, in a gift box — the way a game reward pays its XP. The XP is banked
|
||||
* here (the box, like every box, grants nothing when opened) and spent on any levels it now
|
||||
* pays for; a `PlayerProgressionLevelUpdate` moves the level bar, and the box is announced
|
||||
* so the player sees what arrived.
|
||||
*
|
||||
* Levels crossed do NOT pay their level-up prize boxes: those are rolled from econ's catalog
|
||||
* (`grantLevelUpGifts`), which lives inside the econ app. The levels themselves are stored.
|
||||
*/
|
||||
export async function giftXpHandler(c: Context<App>) {
|
||||
const playerId = playerIdParam(c)
|
||||
if (playerId === null) return c.json({ error: 'A numeric player id is required' }, 400)
|
||||
|
||||
const body = (await c.req.json().catch(() => ({}))) as { amount?: unknown }
|
||||
const amount = Number(body.amount)
|
||||
if (!Number.isInteger(amount) || amount <= 0) {
|
||||
return c.json({ error: 'Enter a whole number of XP greater than 0' }, 400)
|
||||
}
|
||||
const maxGift = intVar(c.env.MAX_XP_GIFT, DEFAULT_MAX_XP_GIFT)
|
||||
if (amount > maxGift) {
|
||||
return c.json({ error: `A gift can carry at most ${maxGift.toLocaleString()} XP` }, 400)
|
||||
}
|
||||
if ((await getAccount(c.env.DB, playerId)) === null) {
|
||||
return c.json({ error: 'No such player' }, 404)
|
||||
}
|
||||
|
||||
const { progression, levelsGained } = await addXp(c.env.DB, playerId, amount)
|
||||
const content = staffGiftContent({
|
||||
GiftContext: GIFT_CONTEXT_PURCHASED_GIFT_A,
|
||||
Xp: amount,
|
||||
})
|
||||
const gift = await createGift(c.env.DB, playerId, content)
|
||||
|
||||
await recordPlayerAudit(c, 'gift_xp', {
|
||||
playerId,
|
||||
amount,
|
||||
level: progression.Level,
|
||||
levelsGained,
|
||||
giftId: gift.id,
|
||||
})
|
||||
logger.info('staff gifted xp', { moderatorId: staffId(c), playerId, amount, levelsGained })
|
||||
|
||||
try {
|
||||
await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer(
|
||||
playerId,
|
||||
NotificationType.PlayerProgressionLevelUpdate,
|
||||
{
|
||||
PlayerId: progression.PlayerId,
|
||||
Level: progression.Level,
|
||||
XP: progression.XP,
|
||||
} satisfies PlayerProgressionLevelPayload
|
||||
)
|
||||
} catch (err) {
|
||||
logger.error('failed to push a staff xp gift progression', {
|
||||
playerId,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
}
|
||||
await announceGift(c, playerId, gift.id, content)
|
||||
|
||||
return c.json({
|
||||
playerId,
|
||||
amount,
|
||||
level: progression.Level,
|
||||
xp: progression.XP,
|
||||
levelsGained,
|
||||
giftId: gift.id,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Give a player a custom avatar item, delivered the way a store purchase delivers one:
|
||||
* ownership is a row in `inventory_custom` (what `econ`'s owned read serves the item from),
|
||||
* and the item comes in a gift box naming it by `CustomAvatarItemId` — without the box the
|
||||
* player is shown nothing. Unlike a purchase nobody pays and the creator is not paid: this is
|
||||
* a grant, not a sale.
|
||||
*
|
||||
* Refused as the store refuses a sale to this player: an unknown item or a DRAFT
|
||||
* (`Accessibility` 0, visible to its creator alone — handing it out would publish it for
|
||||
* them), the item's own creator (who owns it already), or a player who already has it.
|
||||
*/
|
||||
export async function giftCustomItemHandler(c: Context<App>) {
|
||||
const playerId = playerIdParam(c)
|
||||
if (playerId === null) return c.json({ error: 'A numeric player id is required' }, 400)
|
||||
|
||||
const body = (await c.req.json().catch(() => ({}))) as { customAvatarItemId?: unknown }
|
||||
const requested =
|
||||
typeof body.customAvatarItemId === 'string' ? body.customAvatarItemId.trim() : ''
|
||||
if (requested === '') return c.json({ error: 'Enter a custom item id' }, 400)
|
||||
if ((await getAccount(c.env.DB, playerId)) === null) {
|
||||
return c.json({ error: 'No such player' }, 404)
|
||||
}
|
||||
|
||||
// A GUID's case is not part of its identity, and ids are stored as the export spelled them.
|
||||
const item =
|
||||
(await getCustomAvatarItem(c.env.DB, requested)) ??
|
||||
(await getCustomAvatarItem(c.env.DB, requested.toLowerCase()))
|
||||
if (item === null || item.Accessibility === 0) {
|
||||
return c.json({ error: 'No such custom item' }, 404)
|
||||
}
|
||||
if (item.CreatorAccountId === playerId) {
|
||||
return c.json({ error: 'This player created that item, so they already have it' }, 409)
|
||||
}
|
||||
const owned = await ownedCustomAvatarItemIds(c.env.DB, playerId, [item.CustomAvatarItemId])
|
||||
if (owned.has(item.CustomAvatarItemId.toLowerCase())) {
|
||||
return c.json({ error: 'This player already owns that item' }, 409)
|
||||
}
|
||||
|
||||
await grantCustomAvatarItem(c.env.DB, playerId, item.CustomAvatarItemId)
|
||||
const content = staffGiftContent({
|
||||
GiftContext: GIFT_CONTEXT_PURCHASED_GIFT_A,
|
||||
CustomAvatarItemId: item.CustomAvatarItemId,
|
||||
})
|
||||
const gift = await createGift(c.env.DB, playerId, content)
|
||||
|
||||
await recordPlayerAudit(c, 'gift_custom_item', {
|
||||
playerId,
|
||||
customAvatarItemId: item.CustomAvatarItemId,
|
||||
name: item.Name,
|
||||
giftId: gift.id,
|
||||
})
|
||||
logger.info('staff gifted a custom item', {
|
||||
moderatorId: staffId(c),
|
||||
playerId,
|
||||
customAvatarItemId: item.CustomAvatarItemId,
|
||||
})
|
||||
await announceGift(c, playerId, gift.id, content)
|
||||
|
||||
return c.json({
|
||||
playerId,
|
||||
customAvatarItemId: item.CustomAvatarItemId,
|
||||
name: item.Name,
|
||||
giftId: gift.id,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Give a player one more username change. The client reads the count off `/account/me`,
|
||||
* so it shows the next time the player opens the name screen.
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
PRESENCE_TTL_SECONDS,
|
||||
setPresence,
|
||||
} from '@repo/domain/src/presence-db'
|
||||
import { getProgression, PROGRESSION_SCHEMA_DDL } from '@repo/domain/src/progression-db'
|
||||
import { ROOM_INSTANCE_SCHEMA_DDL } from '@repo/domain/src/room-instance-db'
|
||||
// Banning a player moves them into their own dorm, which is a room (created on demand) with
|
||||
// a subroom — so those tables have to be here, as they are on the shared database.
|
||||
@@ -17,6 +18,10 @@ import { ROOM_SCHEMA_DDL, SUBROOM_SCHEMA_DDL } from '@repo/domain/src/rooms-db'
|
||||
import { recordStat, STAT_SCHEMA_DDL } from '@repo/domain/src/stats-db'
|
||||
import { generateToken } from '@repo/jwt'
|
||||
|
||||
import {
|
||||
createCustomAvatarItem,
|
||||
SCHEMA_DDL as CUSTOM_AVATAR_ITEM_SCHEMA_DDL,
|
||||
} from '../../../../api/src/custom-avatar-items-db'
|
||||
import {
|
||||
createReport,
|
||||
getReportById,
|
||||
@@ -36,6 +41,11 @@ import {
|
||||
DEFAULT_STARTING_TOKENS,
|
||||
getBalance,
|
||||
} from '../../../../econ/src/balance-db'
|
||||
import {
|
||||
grantCustomAvatarItem,
|
||||
INVENTORY_CUSTOM_SCHEMA_DDL,
|
||||
ownedCustomAvatarItemIds,
|
||||
} from '../../../../econ/src/inventory-custom-db'
|
||||
import { discordConfig, parseRoleIds, qualifies } from '../../discord'
|
||||
import { DOCUMENTED_SERVICES } from '../../docs'
|
||||
import { DISCORD_INVITE, ISSUES_URL, PRIVACY_EMAIL } from '../../links'
|
||||
@@ -108,6 +118,12 @@ beforeAll(async () => {
|
||||
// `balance` and `received_gift` are owned by `econ`; a staff token gift writes both.
|
||||
for (const stmt of BALANCE_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
for (const stmt of RECEIVED_GIFT_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
// A custom item gift reads `custom_avatar_item` (owned by `api`) and writes
|
||||
// `inventory_custom` (owned by `econ`).
|
||||
for (const stmt of CUSTOM_AVATAR_ITEM_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
for (const stmt of INVENTORY_CUSTOM_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
// And an XP gift writes `progression`.
|
||||
for (const stmt of PROGRESSION_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
})
|
||||
|
||||
// Web signup is open, but only behind the Turnstile check. These pin the closed door:
|
||||
@@ -697,6 +713,8 @@ it('refuses every staff endpoint without a token, and without a staff role', asy
|
||||
]
|
||||
const writes = [
|
||||
'/api/staff/players/1/gift-tokens',
|
||||
'/api/staff/players/1/gift-custom-item',
|
||||
'/api/staff/players/1/gift-xp',
|
||||
'/api/staff/players/1/username-changes',
|
||||
'/api/staff/players/1/clear-password',
|
||||
]
|
||||
@@ -1376,3 +1394,151 @@ it('clears a player’s password so they can set a new one in game', async () =>
|
||||
])
|
||||
expect((await staffPost('/api/staff/players/8397/clear-password', 8110, {})).status).toBe(404)
|
||||
})
|
||||
|
||||
/** A custom avatar item made by `creatorAccountId`; published unless `accessibility` says. */
|
||||
const seedCustomItem = (id: string, creatorAccountId: number, accessibility = 1) =>
|
||||
createCustomAvatarItem(env.DB, {
|
||||
customAvatarItemId: id,
|
||||
creatorAccountId,
|
||||
name: `Shirt ${id.slice(0, 4)}`,
|
||||
description: '',
|
||||
price: 300,
|
||||
baseAvatarItemId: 1,
|
||||
baseAvatarItemColor: '',
|
||||
accessibility,
|
||||
designFilename: 'design.png',
|
||||
thumbnailImageFilename: 'thumb.png',
|
||||
})
|
||||
|
||||
// Delivered as a purchase delivers one — owned, and boxed by `CustomAvatarItemId` — but nobody
|
||||
// is charged and the creator is not paid: a grant, not a sale.
|
||||
it('gifts a player a custom item in a gift box', async () => {
|
||||
const hub = () => env.RECFLARE_NOTIFICATIONS_HUB.getByName('global')
|
||||
await hub().fetch('http://do/all', { method: 'DELETE' })
|
||||
await updateAccount(env.DB, 8330, { username: 'Wearer' })
|
||||
const item = await seedCustomItem('0a1b2c3d-0000-4000-8000-000000000001', 8331)
|
||||
|
||||
// Upper-cased on purpose: a GUID's case is not part of its identity.
|
||||
const res = await staffPost('/api/staff/players/8330/gift-custom-item', 8110, {
|
||||
customAvatarItemId: item.CustomAvatarItemId.toUpperCase(),
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
const body = (await res.json()) as { giftId: number; name: string }
|
||||
expect(body.name).toBe(item.Name)
|
||||
|
||||
await expect(ownedCustomAvatarItemIds(env.DB, 8330, [item.CustomAvatarItemId])).resolves.toEqual(
|
||||
new Set([item.CustomAvatarItemId])
|
||||
)
|
||||
const gifts = await getPendingGifts(env.DB, 8330)
|
||||
expect(gifts).toHaveLength(1)
|
||||
expect(gifts[0]).toMatchObject({
|
||||
Id: body.giftId,
|
||||
FromPlayerId: 1,
|
||||
CustomAvatarItemId: item.CustomAvatarItemId,
|
||||
AvatarItemDesc: '',
|
||||
Currency: 0,
|
||||
// Purchased_Gift_A: one of the contexts the client's box classifier tests. Default (0)
|
||||
// is not, and a box it can't classify renders as "cannot display".
|
||||
GiftContext: 500,
|
||||
})
|
||||
|
||||
// Only the box is announced: no balance moved, the creator's included.
|
||||
const frames = (await (await hub().fetch('http://do/all')).json()) as Array<{
|
||||
playerId: number
|
||||
notificationType: number
|
||||
data: Record<string, unknown>
|
||||
}>
|
||||
expect(frames.map((f) => [f.playerId, f.notificationType])).toEqual([[8330, 31]])
|
||||
expect(frames[0].data).toMatchObject({
|
||||
Id: body.giftId,
|
||||
CustomAvatarItemId: item.CustomAvatarItemId,
|
||||
GiftContext: 500,
|
||||
})
|
||||
|
||||
expect(await auditRows('gift_custom_item', 8330)).toEqual([
|
||||
{
|
||||
actor: 8110,
|
||||
data: {
|
||||
playerId: 8330,
|
||||
customAvatarItemId: item.CustomAvatarItemId,
|
||||
name: item.Name,
|
||||
giftId: body.giftId,
|
||||
},
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('refuses a custom item gift the store would refuse', async () => {
|
||||
await updateAccount(env.DB, 8340, { username: 'Picky' })
|
||||
const published = await seedCustomItem('0a1b2c3d-0000-4000-8000-000000000002', 8341)
|
||||
const draft = await seedCustomItem('0a1b2c3d-0000-4000-8000-000000000003', 8341, 0)
|
||||
const own = await seedCustomItem('0a1b2c3d-0000-4000-8000-000000000004', 8340)
|
||||
await grantCustomAvatarItem(env.DB, 8340, published.CustomAvatarItemId)
|
||||
|
||||
const gift = (customAvatarItemId: unknown, playerId = 8340) =>
|
||||
staffPost(`/api/staff/players/${playerId}/gift-custom-item`, 8110, { customAvatarItemId })
|
||||
|
||||
expect((await gift('')).status).toBe(400)
|
||||
expect((await gift('no-such-item')).status).toBe(404)
|
||||
// A draft is visible to its creator alone; handing it out would publish it for them.
|
||||
expect((await gift(draft.CustomAvatarItemId)).status).toBe(404)
|
||||
expect((await gift(own.CustomAvatarItemId)).status).toBe(409)
|
||||
expect((await gift(published.CustomAvatarItemId)).status).toBe(409)
|
||||
expect((await gift(published.CustomAvatarItemId, 8396)).status).toBe(404)
|
||||
|
||||
expect(await getPendingGifts(env.DB, 8340)).toEqual([])
|
||||
expect(await auditRows('gift_custom_item', 8340)).toEqual([])
|
||||
})
|
||||
|
||||
// Banked and levelled like a game reward's XP: 25 takes a fresh player from level 1 through
|
||||
// 2 (10) and 3 (10) with 5 into level 3. The bar moves, and the box shows what arrived.
|
||||
it('gifts a player XP in a gift box', async () => {
|
||||
const hub = () => env.RECFLARE_NOTIFICATIONS_HUB.getByName('global')
|
||||
await hub().fetch('http://do/all', { method: 'DELETE' })
|
||||
await updateAccount(env.DB, 8350, { username: 'Climber' })
|
||||
|
||||
const res = await staffPost('/api/staff/players/8350/gift-xp', 8110, { amount: 25 })
|
||||
expect(res.status).toBe(200)
|
||||
const body = (await res.json()) as { giftId: number }
|
||||
expect(body).toMatchObject({ level: 3, xp: 5, levelsGained: 2 })
|
||||
await expect(getProgression(env.DB, 8350)).resolves.toEqual({ PlayerId: 8350, Level: 3, XP: 5 })
|
||||
|
||||
const gifts = await getPendingGifts(env.DB, 8350)
|
||||
expect(gifts).toHaveLength(1)
|
||||
expect(gifts[0]).toMatchObject({
|
||||
Id: body.giftId,
|
||||
FromPlayerId: 1,
|
||||
Xp: 25,
|
||||
Currency: 0,
|
||||
GiftContext: 500,
|
||||
})
|
||||
|
||||
const frames = (await (await hub().fetch('http://do/all')).json()) as Array<{
|
||||
playerId: number
|
||||
notificationType: number | string
|
||||
data: Record<string, unknown>
|
||||
}>
|
||||
expect(frames.map((f) => [f.playerId, f.notificationType])).toEqual([
|
||||
[8350, 'PlayerProgressionLevelUpdate'],
|
||||
[8350, 31],
|
||||
])
|
||||
expect(frames[0].data).toEqual({ PlayerId: 8350, Level: 3, XP: 5 })
|
||||
expect(frames[1].data).toMatchObject({ Id: body.giftId, Xp: 25, GiftContext: 500 })
|
||||
|
||||
expect(await auditRows('gift_xp', 8350)).toEqual([
|
||||
{
|
||||
actor: 8110,
|
||||
data: { playerId: 8350, amount: 25, level: 3, levelsGained: 2, giftId: body.giftId },
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('refuses an XP gift that is not a positive whole number, too large, or to nobody', async () => {
|
||||
await updateAccount(env.DB, 8351, { username: 'Stuck' })
|
||||
for (const amount of [0, -5, 1.5, 'lots', 101]) {
|
||||
expect((await staffPost('/api/staff/players/8351/gift-xp', 8110, { amount })).status).toBe(400)
|
||||
}
|
||||
expect((await staffPost('/api/staff/players/8395/gift-xp', 8110, { amount: 5 })).status).toBe(404)
|
||||
await expect(getProgression(env.DB, 8351)).resolves.toEqual({ PlayerId: 8351, Level: 1, XP: 0 })
|
||||
expect(await auditRows('gift_xp', 8351)).toEqual([])
|
||||
})
|
||||
|
||||
@@ -40,7 +40,9 @@ import {
|
||||
clearPasswordHandler,
|
||||
createReportHandler,
|
||||
getReportHandler,
|
||||
giftCustomItemHandler,
|
||||
giftTokensHandler,
|
||||
giftXpHandler,
|
||||
linkedAccountsHandler,
|
||||
playerHistoryHandler,
|
||||
requireStaff,
|
||||
@@ -480,6 +482,8 @@ const app = new Hono<App>()
|
||||
.get('/api/staff/players/:id/linked', linkedAccountsHandler)
|
||||
// The staff card on a player's profile page — account fixes the game has no endpoint for.
|
||||
.post('/api/staff/players/:id/gift-tokens', giftTokensHandler)
|
||||
.post('/api/staff/players/:id/gift-custom-item', giftCustomItemHandler)
|
||||
.post('/api/staff/players/:id/gift-xp', giftXpHandler)
|
||||
.post('/api/staff/players/:id/username-changes', addUsernameChangeHandler)
|
||||
.post('/api/staff/players/:id/clear-password', clearPasswordHandler)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user