mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-27 07:58:55 -07:00
[www] staff gifting
This commit is contained in:
@@ -159,6 +159,10 @@ RECFLARE_DOMAIN=rec.example.com
|
|||||||
# staff can give in total.
|
# staff can give in total.
|
||||||
# RECFLARE_MAX_TOKEN_GIFT=10000
|
# 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
|
# 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
|
# 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.
|
# (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 }) {
|
function StaffPlayerActions({ account, navigate }: { account: PublicAccount; navigate: Navigate }) {
|
||||||
const [tokens, setTokens] = useState('')
|
const [tokens, setTokens] = useState('')
|
||||||
const [confirmingClear, setConfirmingClear] = useState(false)
|
const [confirmingClear, setConfirmingClear] = useState(false)
|
||||||
|
const [customItemId, setCustomItemId] = useState('')
|
||||||
|
const [xp, setXp] = useState('')
|
||||||
const gift = useAction()
|
const gift = useAction()
|
||||||
|
const xpGift = useAction()
|
||||||
|
const customGift = useAction()
|
||||||
const usernameChange = useAction()
|
const usernameChange = useAction()
|
||||||
const clearPassword = useAction()
|
const clearPassword = useAction()
|
||||||
const base = `/api/staff/players/${account.accountId}`
|
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>}
|
{gift.done && <p className="ok">{gift.done}</p>}
|
||||||
</form>
|
</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">
|
<div className="mod-filter-actions staff-actions">
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
|
|||||||
@@ -1568,6 +1568,11 @@ button[type='submit']:disabled {
|
|||||||
max-width: 180px;
|
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 {
|
.staff-actions {
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
|
|||||||
@@ -51,6 +51,11 @@ export type Env = SharedHonoEnv & {
|
|||||||
* DEFAULT_MAX_TOKEN_GIFT (10,000).
|
* DEFAULT_MAX_TOKEN_GIFT (10,000).
|
||||||
*/
|
*/
|
||||||
MAX_TOKEN_GIFT?: string | number
|
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
|
* 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`).
|
* IP survives the hop (see wrangler.jsonc and src/upstream.ts `postAuthForm`).
|
||||||
|
|||||||
+251
-45
@@ -1,5 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
addUsernameChange,
|
addUsernameChange,
|
||||||
|
addXp,
|
||||||
clearPasswordHash,
|
clearPasswordHash,
|
||||||
createGift,
|
createGift,
|
||||||
getAccount,
|
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
|
// is the same write `api` would do. www owns the ENDPOINTS, not the storage — see the
|
||||||
// module comment below.
|
// module comment below.
|
||||||
import { banEvasionMatch, linkedAccounts } from '../../api/src/bans-db'
|
import { banEvasionMatch, linkedAccounts } from '../../api/src/bans-db'
|
||||||
|
import { getCustomAvatarItem } from '../../api/src/custom-avatar-items-db'
|
||||||
import {
|
import {
|
||||||
banBlockDetails,
|
banBlockDetails,
|
||||||
banFromReport,
|
banFromReport,
|
||||||
@@ -35,6 +37,7 @@ import {
|
|||||||
DEFAULT_STARTING_TOKENS,
|
DEFAULT_STARTING_TOKENS,
|
||||||
ensureStartingBalances,
|
ensureStartingBalances,
|
||||||
} from '../../econ/src/balance-db'
|
} 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
|
// The notification ids and the kick frame's recovered shape, owned by `notify`. Both are
|
||||||
// imported as values/types with no runtime dependencies.
|
// imported as values/types with no runtime dependencies.
|
||||||
import { NotificationType } from '../../notify/src/notification-types'
|
import { NotificationType } from '../../notify/src/notification-types'
|
||||||
@@ -46,6 +49,7 @@ import type {
|
|||||||
BalanceResponsePayload,
|
BalanceResponsePayload,
|
||||||
GiftPackagePayload,
|
GiftPackagePayload,
|
||||||
ModerationKickPayload,
|
ModerationKickPayload,
|
||||||
|
PlayerProgressionLevelPayload,
|
||||||
} from '../../notify/src/notification-payloads'
|
} from '../../notify/src/notification-payloads'
|
||||||
import type { App, Env } from './context'
|
import type { App, Env } from './context'
|
||||||
|
|
||||||
@@ -518,8 +522,26 @@ const COACH_ACCOUNT_ID = 1
|
|||||||
*/
|
*/
|
||||||
export const DEFAULT_MAX_TOKEN_GIFT = 10_000
|
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. */
|
/** A path's `:id` as a player id, or null when it isn't a positive integer. */
|
||||||
function playerIdParam(c: Context<App>): number | null {
|
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.
|
* Send a player RecCenterTokens in a gift box.
|
||||||
*
|
*
|
||||||
@@ -589,66 +688,173 @@ export async function giftTokensHandler(c: Context<App>) {
|
|||||||
startingTokens
|
startingTokens
|
||||||
)
|
)
|
||||||
|
|
||||||
const content: GiftContent = {
|
const content = staffGiftContent({
|
||||||
FromPlayerId: COACH_ACCOUNT_ID,
|
|
||||||
GiftContext: 0,
|
|
||||||
ConsumableItemDesc: '',
|
|
||||||
ConsumableCount: 0,
|
|
||||||
AvatarItemDesc: '',
|
|
||||||
AvatarItemType: 0,
|
|
||||||
CurrencyType: CurrencyType.RecCenterTokens,
|
CurrencyType: CurrencyType.RecCenterTokens,
|
||||||
Currency: amount,
|
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)
|
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('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 {
|
try {
|
||||||
await hub.notifyPlayer(playerId, NotificationType.StorefrontBalanceUpdate, {
|
await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer(
|
||||||
Balance: balance,
|
playerId,
|
||||||
CurrencyType: CurrencyType.RecCenterTokens,
|
NotificationType.StorefrontBalanceUpdate,
|
||||||
Platform: ALL_PLATFORMS,
|
{
|
||||||
} satisfies BalanceResponsePayload)
|
Balance: balance,
|
||||||
await hub.notifyPlayer(playerId, NotificationType.GiftPackageReceivedImmediate, {
|
CurrencyType: CurrencyType.RecCenterTokens,
|
||||||
Id: gift.id,
|
Platform: ALL_PLATFORMS,
|
||||||
FromPlayerId: content.FromPlayerId ?? COACH_ACCOUNT_ID,
|
} satisfies BalanceResponsePayload
|
||||||
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)
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error('failed to announce a staff token gift', {
|
logger.error('failed to push a staff token gift balance', {
|
||||||
playerId,
|
playerId,
|
||||||
giftId: gift.id,
|
|
||||||
error: err instanceof Error ? err.message : String(err),
|
error: err instanceof Error ? err.message : String(err),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
await announceGift(c, playerId, gift.id, content)
|
||||||
|
|
||||||
return c.json({ playerId, amount, balance, giftId: gift.id })
|
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`,
|
* 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.
|
* so it shows the next time the player opens the name screen.
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
PRESENCE_TTL_SECONDS,
|
PRESENCE_TTL_SECONDS,
|
||||||
setPresence,
|
setPresence,
|
||||||
} from '@repo/domain/src/presence-db'
|
} 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'
|
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
|
// 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.
|
// 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 { recordStat, STAT_SCHEMA_DDL } from '@repo/domain/src/stats-db'
|
||||||
import { generateToken } from '@repo/jwt'
|
import { generateToken } from '@repo/jwt'
|
||||||
|
|
||||||
|
import {
|
||||||
|
createCustomAvatarItem,
|
||||||
|
SCHEMA_DDL as CUSTOM_AVATAR_ITEM_SCHEMA_DDL,
|
||||||
|
} from '../../../../api/src/custom-avatar-items-db'
|
||||||
import {
|
import {
|
||||||
createReport,
|
createReport,
|
||||||
getReportById,
|
getReportById,
|
||||||
@@ -36,6 +41,11 @@ import {
|
|||||||
DEFAULT_STARTING_TOKENS,
|
DEFAULT_STARTING_TOKENS,
|
||||||
getBalance,
|
getBalance,
|
||||||
} from '../../../../econ/src/balance-db'
|
} 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 { discordConfig, parseRoleIds, qualifies } from '../../discord'
|
||||||
import { DOCUMENTED_SERVICES } from '../../docs'
|
import { DOCUMENTED_SERVICES } from '../../docs'
|
||||||
import { DISCORD_INVITE, ISSUES_URL, PRIVACY_EMAIL } from '../../links'
|
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.
|
// `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 BALANCE_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||||
for (const stmt of RECEIVED_GIFT_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:
|
// 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 = [
|
const writes = [
|
||||||
'/api/staff/players/1/gift-tokens',
|
'/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/username-changes',
|
||||||
'/api/staff/players/1/clear-password',
|
'/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)
|
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,
|
clearPasswordHandler,
|
||||||
createReportHandler,
|
createReportHandler,
|
||||||
getReportHandler,
|
getReportHandler,
|
||||||
|
giftCustomItemHandler,
|
||||||
giftTokensHandler,
|
giftTokensHandler,
|
||||||
|
giftXpHandler,
|
||||||
linkedAccountsHandler,
|
linkedAccountsHandler,
|
||||||
playerHistoryHandler,
|
playerHistoryHandler,
|
||||||
requireStaff,
|
requireStaff,
|
||||||
@@ -480,6 +482,8 @@ const app = new Hono<App>()
|
|||||||
.get('/api/staff/players/:id/linked', linkedAccountsHandler)
|
.get('/api/staff/players/:id/linked', linkedAccountsHandler)
|
||||||
// The staff card on a player's profile page — account fixes the game has no endpoint for.
|
// 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-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/username-changes', addUsernameChangeHandler)
|
||||||
.post('/api/staff/players/:id/clear-password', clearPasswordHandler)
|
.post('/api/staff/players/:id/clear-password', clearPasswordHandler)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user